596 lines
26 KiB
Go
596 lines
26 KiB
Go
package openai
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
edgeservice "iop/apps/edge/internal/service"
|
|
"iop/packages/go/config"
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
func TestAnthropicChatBridgeMixedContentToolsAndResponse(t *testing.T) {
|
|
var fixture struct {
|
|
Request json.RawMessage `json:"request"`
|
|
ProviderResponse json.RawMessage `json:"provider_response"`
|
|
}
|
|
if err := json.Unmarshal(mustReadAnthropicFixture(t, "chat_bridge_cases.json"), &fixture); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
candidate := anthropicTestCandidate(t, "openai")
|
|
candidate.ActualModel = "served-chat"
|
|
fake := &providerFakeRunService{
|
|
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
|
|
poolSelectedCandidate: candidate,
|
|
tunnelFrames: anthropicTunnelFrames(http.StatusOK, "application/json", fixture.ProviderResponse[:41], fixture.ProviderResponse[41:]),
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "claude-route", Providers: map[string]string{"chat": "served-chat"}}})
|
|
w := serveAnthropicRequest(srv, "/v1/messages", string(fixture.Request))
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var response anthropicMessageResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if response.ID != "chatcmpl_fixture" || response.Model != "claude-route" || response.StopReason == nil || *response.StopReason != "tool_use" {
|
|
t.Fatalf("response envelope mismatch: %+v", response)
|
|
}
|
|
if response.Usage.InputTokens != 31 || response.Usage.OutputTokens != 7 || len(response.Content) != 2 {
|
|
t.Fatalf("response content or usage mismatch: %+v", response)
|
|
}
|
|
if response.Content[0]["type"] != "text" || response.Content[0]["text"] != "Done." || response.Content[1]["type"] != "tool_use" || response.Content[1]["id"] != "call_2" {
|
|
t.Fatalf("response block mapping mismatch: %+v", response.Content)
|
|
}
|
|
|
|
requests := fake.tunnelReqsSnapshot()
|
|
bodies := fake.tunnelBodiesSnapshot()
|
|
if len(requests) != 1 || len(bodies) != 1 || requests[0].Operation != string(config.OperationChatCompletions) {
|
|
t.Fatalf("Chat tunnel evidence mismatch: requests=%+v bodies=%d", requests, len(bodies))
|
|
}
|
|
var chat map[string]any
|
|
if err := json.Unmarshal(bodies[0], &chat); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if chat["model"] != "served-chat" || chat["max_tokens"] != float64(256) || chat["stream"] != false {
|
|
t.Fatalf("Chat request envelope mismatch: %+v", chat)
|
|
}
|
|
messages := anthropicAnySlice(t, chat["messages"])
|
|
if len(messages) != 4 {
|
|
t.Fatalf("Chat messages=%d, want 4: %+v", len(messages), messages)
|
|
}
|
|
system := anthropicAnyMap(t, messages[0])
|
|
user := anthropicAnyMap(t, messages[1])
|
|
assistant := anthropicAnyMap(t, messages[2])
|
|
toolResult := anthropicAnyMap(t, messages[3])
|
|
if system["role"] != "system" || system["content"] != "Use tools carefully." {
|
|
t.Fatalf("system mapping mismatch: %+v", system)
|
|
}
|
|
userContent := anthropicAnySlice(t, user["content"])
|
|
image := anthropicAnyMap(t, userContent[1])
|
|
imageURL := anthropicAnyMap(t, image["image_url"])
|
|
if user["role"] != "user" || len(userContent) != 2 || image["type"] != "image_url" || imageURL["url"] != "data:image/png;base64,aW1hZ2U=" {
|
|
t.Fatalf("mixed user content mapping mismatch: %+v", user)
|
|
}
|
|
toolCalls := anthropicAnySlice(t, assistant["tool_calls"])
|
|
call := anthropicAnyMap(t, toolCalls[0])
|
|
function := anthropicAnyMap(t, call["function"])
|
|
if assistant["role"] != "assistant" || call["id"] != "toolu_1" || function["name"] != "inspect" || function["arguments"] != `{"detail":"high"}` {
|
|
t.Fatalf("assistant tool mapping mismatch: %+v", assistant)
|
|
}
|
|
if toolResult["role"] != "tool" || toolResult["tool_call_id"] != "toolu_1" || toolResult["content"] != "clear" {
|
|
t.Fatalf("tool result mapping mismatch: %+v", toolResult)
|
|
}
|
|
tools := anthropicAnySlice(t, chat["tools"])
|
|
tool := anthropicAnyMap(t, tools[0])
|
|
toolFunction := anthropicAnyMap(t, tool["function"])
|
|
choice := anthropicAnyMap(t, chat["tool_choice"])
|
|
choiceFunction := anthropicAnyMap(t, choice["function"])
|
|
if toolFunction["name"] != "inspect" || choiceFunction["name"] != "inspect" || chat["parallel_tool_calls"] != true {
|
|
t.Fatalf("tool declaration or choice mismatch: tool=%+v choice=%+v", tool, choice)
|
|
}
|
|
}
|
|
|
|
func TestAnthropicChatBridgeThinkingCapabilityAndResponse(t *testing.T) {
|
|
candidate := anthropicTestCandidate(t, "openai")
|
|
candidate.ActualModel = "served-chat"
|
|
profile := candidate.ProtocolProfile.Clone()
|
|
profile.Extensions = map[string]any{"thinking": true}
|
|
candidate.ProtocolProfile = &profile
|
|
providerResponse := []byte(`{"id":"chat_think","choices":[{"message":{"role":"assistant","content":"answer","reasoning_content":"hidden"},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}`)
|
|
fake := &providerFakeRunService{
|
|
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
|
|
poolSelectedCandidate: candidate,
|
|
tunnelFrames: anthropicTunnelFrames(http.StatusOK, "application/json", providerResponse),
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "claude-route", Providers: map[string]string{"chat": "served-chat"}}})
|
|
body := `{"model":"claude-route","max_tokens":64,"thinking":{"type":"enabled","budget_tokens":24},"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"prior"},{"type":"text","text":"draft"}]},{"role":"user","content":"continue"}]}`
|
|
w := serveAnthropicRequest(srv, "/v1/messages", body)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var response anthropicMessageResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(response.Content) != 2 || response.Content[0]["type"] != "thinking" || response.Content[0]["thinking"] != "hidden" || response.Content[1]["text"] != "answer" {
|
|
t.Fatalf("thinking response mapping mismatch: %+v", response.Content)
|
|
}
|
|
var chat map[string]any
|
|
if err := json.Unmarshal(fake.tunnelBodiesSnapshot()[0], &chat); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if chat["think"] != true || chat["include_reasoning"] != true || chat["thinking_token_budget"] != float64(24) {
|
|
t.Fatalf("thinking request options mismatch: %+v", chat)
|
|
}
|
|
messages := anthropicAnySlice(t, chat["messages"])
|
|
assistant := anthropicAnyMap(t, messages[0])
|
|
if assistant["reasoning_content"] != "prior" {
|
|
t.Fatalf("thinking input mapping mismatch: %+v", assistant)
|
|
}
|
|
}
|
|
|
|
func TestAnthropicChatBridgeDropsUnsignedThinkingReplayForGenericProfile(t *testing.T) {
|
|
candidate := anthropicTestCandidate(t, "openai")
|
|
candidate.ActualModel = "served-chat"
|
|
fake := &providerFakeRunService{
|
|
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
|
|
poolSelectedCandidate: candidate,
|
|
tunnelFrames: anthropicTunnelFrames(http.StatusOK, "application/json",
|
|
[]byte(`{"id":"chat_replay","choices":[{"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}],"usage":{"prompt_tokens":8,"completion_tokens":1}}`)),
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "claude-route", Providers: map[string]string{"chat": "served-chat"}}})
|
|
body := `{"model":"claude-route","max_tokens":64,"thinking":{"type":"adaptive"},"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"private prior reasoning","signature":""},{"type":"text","text":"I will inspect the file."}]},{"role":"user","content":"continue"}]}`
|
|
w := serveAnthropicRequest(srv, "/v1/messages", body)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var chat map[string]any
|
|
if err := json.Unmarshal(fake.tunnelBodiesSnapshot()[0], &chat); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
messages := anthropicAnySlice(t, chat["messages"])
|
|
assistant := anthropicAnyMap(t, messages[0])
|
|
if _, ok := assistant["reasoning_content"]; ok {
|
|
t.Fatalf("generic Chat replay leaked unsupported reasoning: %+v", assistant)
|
|
}
|
|
content := anthropicAnySlice(t, assistant["content"])
|
|
if got := anthropicAnyMap(t, content[0])["text"]; got != "I will inspect the file." {
|
|
t.Fatalf("visible assistant content changed: %+v", assistant)
|
|
}
|
|
}
|
|
|
|
func TestAnthropicChatBridgeRejectsUnsupportedBeforeWire(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
body string
|
|
beta string
|
|
}{
|
|
{name: "top k", body: `{"model":"claude-route","max_tokens":16,"top_k":4,"messages":[{"role":"user","content":"hello"}]}`},
|
|
{name: "unknown block", body: `{"model":"claude-route","max_tokens":16,"messages":[{"role":"user","content":[{"type":"search_result","content":"unknown"}]}]}`},
|
|
{name: "unknown field", body: `{"model":"claude-route","max_tokens":16,"vendor_extension":true,"messages":[{"role":"user","content":"hello"}]}`},
|
|
{name: "thinking capability", body: `{"model":"claude-route","max_tokens":16,"thinking":{"type":"enabled","budget_tokens":8},"messages":[{"role":"user","content":"hello"}]}`},
|
|
{name: "unknown beta", body: `{"model":"claude-route","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`, beta: "unknown-beta-2099-01-01"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
candidate := anthropicTestCandidate(t, "openai")
|
|
candidate.ActualModel = "served-chat"
|
|
fake := &providerFakeRunService{
|
|
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
|
|
poolSelectedCandidate: candidate,
|
|
tunnelFrames: anthropicTunnelFrames(http.StatusOK, "application/json", []byte(`{"ok":true}`)),
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "claude-route", Providers: map[string]string{"chat": "served-chat"}}})
|
|
req := newAnthropicRequest(http.MethodPost, "/v1/messages", tc.body)
|
|
if tc.beta != "" {
|
|
req.Header.Set(anthropicBetaHeader, tc.beta)
|
|
}
|
|
w := serveAnthropicHTTPRequest(srv, req)
|
|
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), `"type":"invalid_request_error"`) {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if got := len(fake.tunnelReqsSnapshot()); got != 0 {
|
|
t.Fatalf("unsupported request reached provider wire: %d requests", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAnthropicChatBridgeClaudeCodeRequest(t *testing.T) {
|
|
candidate := anthropicTestCandidate(t, "gemini")
|
|
candidate.ActualModel = "gemini-3.6-flash"
|
|
providerResponse := []byte(`{"id":"chat_claude_code","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":11,"completion_tokens":2}}`)
|
|
fake := &providerFakeRunService{
|
|
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
|
|
poolSelectedCandidate: candidate,
|
|
tunnelFrames: anthropicTunnelFrames(http.StatusOK, "application/json", providerResponse),
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "gemini-route", Providers: map[string]string{"gemini": "gemini-3.6-flash"}}})
|
|
body := `{
|
|
"model":"gemini-route",
|
|
"max_tokens":1024,
|
|
"system":[
|
|
{"type":"text","text":"base"},
|
|
{"type":"text","text":"cached","cache_control":{"type":"ephemeral"}}
|
|
],
|
|
"messages":[{"role":"user","content":[
|
|
{"type":"text","text":"hello"},
|
|
{"type":"text","text":"cached prompt","cache_control":{"type":"ephemeral"}}
|
|
]}],
|
|
"thinking":{"type":"adaptive"},
|
|
"output_config":{"effort":"high","format":{"type":"json_schema","schema":{"type":"object","properties":{"title":{"type":"string"}},"required":["title"],"additionalProperties":false}}},
|
|
"metadata":{"user_id":"claude-code"},
|
|
"tools":[{"name":"Read","description":"Read a file","input_schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"file_path":{"type":"string"}},"required":["file_path"],"additionalProperties":false}}]
|
|
}`
|
|
req := newAnthropicRequest(http.MethodPost, "/v1/messages", body)
|
|
req.Header.Set(anthropicBetaHeader, strings.Join([]string{
|
|
"claude-code-20250219",
|
|
"interleaved-thinking-2025-05-14",
|
|
"mid-conversation-system-2026-04-07",
|
|
"effort-2025-11-24",
|
|
"structured-outputs-2025-12-15",
|
|
}, ","))
|
|
w := serveAnthropicHTTPRequest(srv, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var chat map[string]any
|
|
if err := json.Unmarshal(fake.tunnelBodiesSnapshot()[0], &chat); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if chat["model"] != "gemini-3.6-flash" || chat["reasoning_effort"] != "high" {
|
|
t.Fatalf("Claude Code model or effort mapping mismatch: %+v", chat)
|
|
}
|
|
for _, key := range []string{"think", "include_reasoning", "thinking_token_budget", "output_config"} {
|
|
if _, ok := chat[key]; ok {
|
|
t.Fatalf("adaptive request leaked unsupported field %q: %+v", key, chat)
|
|
}
|
|
}
|
|
if _, ok := chat["metadata"]; ok {
|
|
t.Fatalf("Anthropic metadata must not be forwarded to Chat providers: %+v", chat)
|
|
}
|
|
responseFormat := anthropicAnyMap(t, chat["response_format"])
|
|
jsonSchema := anthropicAnyMap(t, responseFormat["json_schema"])
|
|
schema := anthropicAnyMap(t, jsonSchema["schema"])
|
|
if responseFormat["type"] != "json_schema" || jsonSchema["name"] != "response" || jsonSchema["strict"] != true || schema["type"] != "object" {
|
|
t.Fatalf("structured output mapping mismatch: %+v", responseFormat)
|
|
}
|
|
messages := anthropicAnySlice(t, chat["messages"])
|
|
if len(messages) != 2 || anthropicAnyMap(t, messages[0])["content"] != "base\ncached" {
|
|
t.Fatalf("cache-controlled system mapping mismatch: %+v", messages)
|
|
}
|
|
tools := anthropicAnySlice(t, chat["tools"])
|
|
if anthropicAnyMap(t, anthropicAnyMap(t, tools[0])["function"])["name"] != "Read" {
|
|
t.Fatalf("Claude Code tool mapping mismatch: %+v", tools)
|
|
}
|
|
}
|
|
|
|
func TestAnthropicChatBridgeGeminiThoughtSignatureRoundTrip(t *testing.T) {
|
|
providerResponse := []byte(`{
|
|
"id":"chat_signature",
|
|
"choices":[{"message":{"role":"assistant","tool_calls":[{
|
|
"id":"call_1",
|
|
"type":"function",
|
|
"function":{"name":"Bash","arguments":"{\"command\":\"printf 5 > answer.txt\"}"},
|
|
"extra_content":{"google":{"thought_signature":"signature-1"}}
|
|
}]},"finish_reason":"tool_calls"}],
|
|
"usage":{"prompt_tokens":9,"completion_tokens":4}
|
|
}`)
|
|
response, err := convertChatResponseToAnthropic(providerResponse, "gemini-route")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(response.Content) != 1 {
|
|
t.Fatalf("content blocks=%d, want 1", len(response.Content))
|
|
}
|
|
encodedID, ok := response.Content[0]["id"].(string)
|
|
if !ok || encodedID == "call_1" {
|
|
t.Fatalf("thought signature was not encoded in tool_use id: %+v", response.Content[0])
|
|
}
|
|
toolID, signature, encoded := decodeAnthropicBridgeToolID(encodedID)
|
|
if !encoded || toolID != "call_1" || signature != "signature-1" {
|
|
t.Fatalf("encoded tool id mismatch: id=%q signature=%q encoded=%v", toolID, signature, encoded)
|
|
}
|
|
|
|
requestBody := fmt.Sprintf(`{
|
|
"model":"gemini-route",
|
|
"max_tokens":1024,
|
|
"messages":[
|
|
{"role":"assistant","content":[{"type":"tool_use","id":%q,"name":"Bash","input":{"command":"printf 5 > answer.txt"}}]},
|
|
{"role":"user","content":[{"type":"tool_result","tool_use_id":%q,"content":"done","is_error":false,"cache_control":{"type":"ephemeral"}}]}
|
|
]
|
|
}`, encodedID, encodedID)
|
|
profile, err := config.ResolveProtocolProfile("gemini", "", config.BuiltInProtocolProfileCatalog())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bridged, _, err := prepareAnthropicChatBridge([]byte(requestBody), "gemini-3.6-flash", profile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var chat map[string]any
|
|
if err := json.Unmarshal(bridged, &chat); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
messages := anthropicAnySlice(t, chat["messages"])
|
|
assistant := anthropicAnyMap(t, messages[0])
|
|
toolCall := anthropicAnyMap(t, anthropicAnySlice(t, assistant["tool_calls"])[0])
|
|
extra := anthropicAnyMap(t, anthropicAnyMap(t, toolCall["extra_content"])["google"])
|
|
toolResult := anthropicAnyMap(t, messages[1])
|
|
if toolCall["id"] != "call_1" || extra["thought_signature"] != "signature-1" || toolResult["tool_call_id"] != "call_1" {
|
|
t.Fatalf("Gemini thought signature round trip mismatch: assistant=%+v tool_result=%+v", assistant, toolResult)
|
|
}
|
|
}
|
|
|
|
func TestAnthropicChatBridgeProviderError(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
body string
|
|
}{
|
|
{name: "object", body: `{"error":{"type":"rate_limit_error","message":"slow down","code":429}}`},
|
|
{name: "Google array", body: `[{"error":{"type":"rate_limit_error","message":"slow down","code":429}}]`},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
candidate := anthropicTestCandidate(t, "openai")
|
|
candidate.ActualModel = "served-chat"
|
|
providerError := []byte(tc.body)
|
|
fake := &providerFakeRunService{
|
|
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
|
|
poolSelectedCandidate: candidate,
|
|
tunnelFrames: anthropicTunnelFrames(http.StatusTooManyRequests, "application/json", providerError[:13], providerError[13:]),
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "claude-route", Providers: map[string]string{"chat": "served-chat"}}})
|
|
w := serveAnthropicRequest(srv, "/v1/messages", `{"model":"claude-route","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`)
|
|
if w.Code != http.StatusTooManyRequests || !strings.Contains(w.Body.String(), `"type":"rate_limit_error"`) || !strings.Contains(w.Body.String(), `"message":"slow down"`) {
|
|
t.Fatalf("provider error mapping mismatch: status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAnthropicChatBridgeStreamFragmentationOrderAndTerminal(t *testing.T) {
|
|
fixture := mustReadAnthropicFixture(t, "chat_bridge_stream.sse")
|
|
parts := bytes.SplitN(fixture, []byte("---ANTHROPIC-OUTPUT---\n"), 2)
|
|
if len(parts) != 2 {
|
|
t.Fatal("chat bridge stream fixture is missing the Anthropic output section")
|
|
}
|
|
expectedParts := bytes.SplitN(parts[1], []byte("---END-ANTHROPIC-OUTPUT---"), 2)
|
|
if len(expectedParts) != 2 {
|
|
t.Fatal("chat bridge stream fixture is missing the Anthropic output terminator")
|
|
}
|
|
streamBody, expectedOutput := parts[0], expectedParts[0]
|
|
frames := make(chan *iop.ProviderTunnelFrame, 9)
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START, StatusCode: http.StatusOK, Headers: map[string]string{"Content-Type": "text/event-stream"}}
|
|
for index, fragment := range splitAnthropicFixture(streamBody, 5, 67, 139, 251, 409, len(streamBody)-3) {
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Sequence: int64(index + 1), Body: fragment}
|
|
}
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
|
|
close(frames)
|
|
|
|
candidate := anthropicTestCandidate(t, "openai")
|
|
candidate.ActualModel = "served-chat"
|
|
fake := &providerFakeRunService{
|
|
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
|
|
poolSelectedCandidate: candidate,
|
|
tunnelFrames: frames,
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "claude-route", Providers: map[string]string{"chat": "served-chat"}}})
|
|
w := serveAnthropicRequest(srv, "/v1/messages", `{"model":"claude-route","max_tokens":64,"stream":true,"messages":[{"role":"user","content":"hello"}]}`)
|
|
|
|
if w.Code != http.StatusOK || w.Header().Get("Content-Type") != "text/event-stream" {
|
|
t.Fatalf("stream response mismatch: status=%d headers=%v body=%s", w.Code, w.Header(), w.Body.String())
|
|
}
|
|
if !bytes.Equal(w.Body.Bytes(), expectedOutput) {
|
|
t.Fatalf("stream golden mismatch:\n got=%q\nwant=%q", w.Body.Bytes(), expectedOutput)
|
|
}
|
|
events := anthropicSSEEventNames(w.Body.Bytes())
|
|
wantEvents := []string{
|
|
"message_start",
|
|
"content_block_start", "content_block_delta", "content_block_stop",
|
|
"content_block_start", "content_block_delta", "content_block_stop",
|
|
"content_block_start", "content_block_delta", "content_block_stop",
|
|
"message_delta", "message_stop",
|
|
}
|
|
if strings.Join(events, ",") != strings.Join(wantEvents, ",") {
|
|
t.Fatalf("stream event order mismatch:\n got=%v\nwant=%v\nbody=%s", events, wantEvents, w.Body.String())
|
|
}
|
|
output := w.Body.String()
|
|
for _, fragment := range []string{`"thinking":"checking "`, `"type":"thinking_delta"`, `"text":"hello "`, `"type":"text_delta"`, `"id":"call_1"`, `"name":"lookup"`, `"partial_json":"{\"q\":\"iop\"}"`, `"stop_reason":"tool_use"`, `"input_tokens":19`, `"output_tokens":5`} {
|
|
if !strings.Contains(output, fragment) {
|
|
t.Fatalf("stream output missing %q: %s", fragment, output)
|
|
}
|
|
}
|
|
if strings.Count(output, "event: message_stop") != 1 {
|
|
t.Fatalf("message_stop count mismatch: %s", output)
|
|
}
|
|
var chat map[string]any
|
|
if err := json.Unmarshal(fake.tunnelBodiesSnapshot()[0], &chat); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
streamOptions := anthropicAnyMap(t, chat["stream_options"])
|
|
if chat["stream"] != true || streamOptions["include_usage"] != true {
|
|
t.Fatalf("stream request options mismatch: %+v", chat)
|
|
}
|
|
}
|
|
|
|
func TestAnthropicChatBridgeStreamStopsAtTerminalWithinFrame(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
body string
|
|
wantEvents []string
|
|
wantFinal string
|
|
}{
|
|
{
|
|
name: "DONE terminal",
|
|
body: "data: {\"id\":\"chat_1\",\"choices\":[{\"delta\":{\"content\":\"first \"}}]}\n\n" +
|
|
"data: [DONE]\n\n" +
|
|
"data: {\"id\":\"chat_1\",\"choices\":[{\"delta\":{\"content\":\"late\"}}]}\n\n",
|
|
wantEvents: []string{
|
|
"message_start",
|
|
"content_block_start", "content_block_delta", "content_block_stop",
|
|
"message_delta", "message_stop",
|
|
},
|
|
wantFinal: "message_stop",
|
|
},
|
|
{
|
|
name: "Upstream error terminal",
|
|
body: "data: {\"id\":\"chat_1\",\"choices\":[{\"delta\":{\"content\":\"first \"}}]}\n\n" +
|
|
"data: {\"error\":{\"type\":\"server_error\",\"message\":\"boom\"}}\n\n" +
|
|
"data: {\"id\":\"chat_1\",\"choices\":[{\"delta\":{\"content\":\"late\"}}]}\n\n",
|
|
wantEvents: []string{
|
|
"message_start",
|
|
"content_block_start", "content_block_delta",
|
|
"error",
|
|
},
|
|
wantFinal: "error",
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
frames := make(chan *iop.ProviderTunnelFrame, 3)
|
|
frames <- &iop.ProviderTunnelFrame{
|
|
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START,
|
|
StatusCode: http.StatusOK,
|
|
Headers: map[string]string{"Content-Type": "text/event-stream"},
|
|
}
|
|
frames <- &iop.ProviderTunnelFrame{
|
|
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY,
|
|
Sequence: 1,
|
|
Body: []byte(tc.body),
|
|
}
|
|
frames <- &iop.ProviderTunnelFrame{
|
|
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END,
|
|
End: true,
|
|
}
|
|
close(frames)
|
|
|
|
candidate := anthropicTestCandidate(t, "openai")
|
|
candidate.ActualModel = "served-chat"
|
|
fake := &providerFakeRunService{
|
|
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
|
|
poolSelectedCandidate: candidate,
|
|
tunnelFrames: frames,
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "claude-route", Providers: map[string]string{"chat": "served-chat"}}})
|
|
w := serveAnthropicRequest(srv, "/v1/messages", `{"model":"claude-route","max_tokens":64,"stream":true,"messages":[{"role":"user","content":"hello"}]}`)
|
|
|
|
if w.Code != http.StatusOK || w.Header().Get("Content-Type") != "text/event-stream" {
|
|
t.Fatalf("stream response mismatch: status=%d headers=%v body=%s", w.Code, w.Header(), w.Body.String())
|
|
}
|
|
|
|
output := w.Body.String()
|
|
if strings.Contains(output, "late") {
|
|
t.Fatalf("stream output contained late content after terminal: %s", output)
|
|
}
|
|
|
|
events := anthropicSSEEventNames(w.Body.Bytes())
|
|
if strings.Join(events, ",") != strings.Join(tc.wantEvents, ",") {
|
|
t.Fatalf("stream event order mismatch:\n got=%v\nwant=%v\nbody=%s", events, tc.wantEvents, output)
|
|
}
|
|
|
|
if len(events) == 0 || events[len(events)-1] != tc.wantFinal {
|
|
t.Fatalf("final event mismatch: got=%v wantFinal=%s", events, tc.wantFinal)
|
|
}
|
|
|
|
if count := strings.Count(output, "event: "+tc.wantFinal); count != 1 {
|
|
t.Fatalf("terminal event count mismatch for %s: count=%d body=%s", tc.wantFinal, count, output)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAnthropicChatBridgeStreamEncodesGeminiThoughtSignature(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
stream := newAnthropicBridgeStream(w, "gemini-route")
|
|
payload := `data: {"id":"chat_signature","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"Bash","arguments":"{\"command\":\"printf 5 > answer.txt\"}"},"extra_content":{"google":{"thought_signature":"signature-1"}}}]},"finish_reason":"tool_calls"}]}` + "\n\n" +
|
|
"data: [DONE]\n\n"
|
|
if err := stream.Feed([]byte(payload)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var encodedID string
|
|
for _, event := range bytes.Split(w.Body.Bytes(), []byte("\n\n")) {
|
|
var data []byte
|
|
for _, line := range bytes.Split(event, []byte("\n")) {
|
|
if bytes.HasPrefix(line, []byte("data: ")) {
|
|
data = bytes.TrimPrefix(line, []byte("data: "))
|
|
}
|
|
}
|
|
if len(data) == 0 {
|
|
continue
|
|
}
|
|
var item struct {
|
|
Type string `json:"type"`
|
|
ContentBlock struct {
|
|
Type string `json:"type"`
|
|
ID string `json:"id"`
|
|
} `json:"content_block"`
|
|
}
|
|
if json.Unmarshal(data, &item) == nil && item.Type == "content_block_start" && item.ContentBlock.Type == "tool_use" {
|
|
encodedID = item.ContentBlock.ID
|
|
}
|
|
}
|
|
toolID, signature, encoded := decodeAnthropicBridgeToolID(encodedID)
|
|
if !encoded || toolID != "call_1" || signature != "signature-1" {
|
|
t.Fatalf("stream signature encoding mismatch: encoded_id=%q id=%q signature=%q encoded=%v body=%s", encodedID, toolID, signature, encoded, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func newAnthropicRequest(method, path, body string) *http.Request {
|
|
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
|
req.Header.Set(anthropicVersionHeader, anthropicSupportedVersion)
|
|
return req
|
|
}
|
|
|
|
func serveAnthropicHTTPRequest(srv *Server, req *http.Request) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
srv.routes().ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func anthropicAnySlice(t *testing.T, value any) []any {
|
|
t.Helper()
|
|
items, ok := value.([]any)
|
|
if !ok {
|
|
t.Fatalf("value is %T, want []any: %+v", value, value)
|
|
}
|
|
return items
|
|
}
|
|
|
|
func anthropicAnyMap(t *testing.T, value any) map[string]any {
|
|
t.Helper()
|
|
item, ok := value.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("value is %T, want map[string]any: %+v", value, value)
|
|
}
|
|
return item
|
|
}
|
|
|
|
func anthropicSSEEventNames(body []byte) []string {
|
|
var names []string
|
|
for _, event := range bytes.Split(bytes.ReplaceAll(body, []byte("\r\n"), []byte("\n")), []byte("\n\n")) {
|
|
for _, line := range bytes.Split(event, []byte("\n")) {
|
|
if bytes.HasPrefix(line, []byte("event: ")) {
|
|
names = append(names, string(bytes.TrimPrefix(line, []byte("event: "))))
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return names
|
|
}
|