세 Agent의 direct route를 동일한 fail-closed preflight와 격리 실행 경계에서 비교하고, 관측되지 않은 preset 셀이 실행되는 것을 막기 위해 연결 계약과 증거 수집 흐름을 고정한다.
785 lines
38 KiB
Go
785 lines
38 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: "context management scalar", body: `{"model":"claude-route","max_tokens":16,"context_management":"compact","messages":[{"role":"user","content":"hello"}]}`, beta: "context-management-2025-06-27"},
|
|
{name: "context management array", body: `{"model":"claude-route","max_tokens":16,"context_management":[],"messages":[{"role":"user","content":"hello"}]}`, beta: "context-management-2025-06-27"},
|
|
{name: "thinking capability", body: `{"model":"claude-route","max_tokens":16,"thinking":{"type":"enabled","budget_tokens":8},"messages":[{"role":"user","content":"hello"}]}`},
|
|
{name: "tool strict", body: `{"model":"claude-route","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"tools":[{"name":"Read","input_schema":{"type":"object"},"strict":true}]}`, beta: "advanced-tool-use-2025-11-20"},
|
|
{name: "tool eager input streaming", body: `{"model":"claude-route","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"tools":[{"name":"Read","input_schema":{"type":"object"},"eager_input_streaming":true}]}`, beta: "advanced-tool-use-2025-11-20"},
|
|
{name: "thinking display value", body: `{"model":"claude-route","max_tokens":16,"thinking":{"type":"adaptive","display":"raw"},"messages":[{"role":"user","content":"hello"}]}`},
|
|
{name: "thinking display type", body: `{"model":"claude-route","max_tokens":16,"thinking":{"type":"adaptive","display":1},"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 TestAnthropicContextManagementNullCompatibility(t *testing.T) {
|
|
body := []byte(`{"model":"claude-route","max_tokens":16,"context_management":null,"messages":[{"role":"user","content":"hello"}]}`)
|
|
req, err := decodeAnthropicMessageRequest(body, true)
|
|
if err != nil {
|
|
t.Fatalf("null context_management rejected: %v", err)
|
|
}
|
|
if !bytes.Equal(bytes.TrimSpace(req.ContextManagement), []byte("null")) {
|
|
t.Fatalf("context_management changed: %s", req.ContextManagement)
|
|
}
|
|
}
|
|
|
|
func TestAnthropicChatBridgeEffortMapping(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
body string
|
|
wantEffort string
|
|
wantStatus int
|
|
omitEffort bool
|
|
}{
|
|
{name: "omitted", body: `{"model":"claude-route","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`, wantStatus: http.StatusOK, omitEffort: true},
|
|
{name: "low", body: `{"model":"claude-route","max_tokens":64,"output_config":{"effort":"low"},"messages":[{"role":"user","content":"hi"}]}`, wantEffort: "low", wantStatus: http.StatusOK},
|
|
{name: "medium", body: `{"model":"claude-route","max_tokens":64,"output_config":{"effort":"medium"},"messages":[{"role":"user","content":"hi"}]}`, wantEffort: "medium", wantStatus: http.StatusOK},
|
|
{name: "high", body: `{"model":"claude-route","max_tokens":64,"output_config":{"effort":"high"},"messages":[{"role":"user","content":"hi"}]}`, wantEffort: "high", wantStatus: http.StatusOK},
|
|
{name: "xhigh", body: `{"model":"claude-route","max_tokens":64,"output_config":{"effort":"xhigh"},"messages":[{"role":"user","content":"hi"}]}`, wantEffort: "xhigh", wantStatus: http.StatusOK},
|
|
{name: "max", body: `{"model":"claude-route","max_tokens":64,"output_config":{"effort":"max"},"messages":[{"role":"user","content":"hi"}]}`, wantEffort: "max", wantStatus: http.StatusOK},
|
|
{name: "unknown value", body: `{"model":"claude-route","max_tokens":64,"output_config":{"effort":"ultra"},"messages":[{"role":"user","content":"hi"}]}`, wantStatus: http.StatusBadRequest},
|
|
{name: "empty", body: `{"model":"claude-route","max_tokens":64,"output_config":{"effort":""},"messages":[{"role":"user","content":"hi"}]}`, wantStatus: http.StatusBadRequest},
|
|
{name: "null", body: `{"model":"claude-route","max_tokens":64,"output_config":{"effort":null},"messages":[{"role":"user","content":"hi"}]}`, wantStatus: http.StatusBadRequest},
|
|
{name: "non-string", body: `{"model":"claude-route","max_tokens":64,"output_config":{"effort":1},"messages":[{"role":"user","content":"hi"}]}`, wantStatus: http.StatusBadRequest},
|
|
{name: "case-folded effort key", body: `{"model":"claude-route","max_tokens":64,"output_config":{"Effort":"ultra"},"messages":[{"role":"user","content":"hi"}]}`, wantStatus: http.StatusBadRequest},
|
|
{name: "duplicate effort", body: `{"model":"claude-route","max_tokens":64,"output_config":{"effort":"ultra","effort":"max"},"messages":[{"role":"user","content":"hi"}]}`, wantStatus: http.StatusBadRequest},
|
|
{name: "duplicate output config", body: `{"model":"claude-route","max_tokens":64,"output_config":{"effort":"ultra"},"output_config":{"effort":"max"},"messages":[{"role":"user","content":"hi"}]}`, wantStatus: http.StatusBadRequest},
|
|
} {
|
|
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(`{"id":"chat_effort","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)),
|
|
}
|
|
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", tc.body)
|
|
|
|
if w.Code != tc.wantStatus {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if tc.wantStatus == http.StatusBadRequest {
|
|
if !strings.Contains(w.Body.String(), `"type":"invalid_request_error"`) {
|
|
t.Fatalf("expected invalid_request_error: %s", w.Body.String())
|
|
}
|
|
if got := len(fake.tunnelReqsSnapshot()); got != 0 {
|
|
t.Fatalf("rejected request reached provider wire: %d requests", got)
|
|
}
|
|
return
|
|
}
|
|
var chat map[string]any
|
|
if err := json.Unmarshal(fake.tunnelBodiesSnapshot()[0], &chat); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tc.omitEffort {
|
|
if _, ok := chat["reasoning_effort"]; ok {
|
|
t.Fatalf("omitted effort set reasoning_effort: %+v", chat)
|
|
}
|
|
return
|
|
}
|
|
if got := chat["reasoning_effort"]; got != tc.wantEffort {
|
|
t.Fatalf("reasoning_effort=%v, want %q", got, tc.wantEffort)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAnthropicChatBridgeEffortExactTokenPreservation(t *testing.T) {
|
|
for _, effort := range []string{"low", "medium", "high", "xhigh", "max"} {
|
|
t.Run(effort, 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(`{"id":"chat","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)),
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "claude-route", Providers: map[string]string{"chat": "served-chat"}}})
|
|
body := fmt.Sprintf(`{"model":"claude-route","max_tokens":64,"output_config":{"effort":%q},"messages":[{"role":"user","content":"hi"}]}`, effort)
|
|
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)
|
|
}
|
|
if got, ok := chat["reasoning_effort"].(string); !ok || got != effort {
|
|
t.Fatalf("reasoning_effort=%v, want %q", chat["reasoning_effort"], effort)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAnthropicChatBridgeEffortRejectsInvalidValue(t *testing.T) {
|
|
for _, effort := range []string{"HIGH", "XHigh", "maxx", "xhighx", "h"} {
|
|
t.Run(effort, 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"}}})
|
|
body := fmt.Sprintf(`{"model":"claude-route","max_tokens":64,"output_config":{"effort":%q},"messages":[{"role":"user","content":"hi"}]}`, effort)
|
|
w := serveAnthropicRequest(srv, "/v1/messages", body)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Body.String(), `"type":"invalid_request_error"`) {
|
|
t.Fatalf("expected invalid_request_error: %s", w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Body.String(), "xhigh") || !strings.Contains(w.Body.String(), "max") {
|
|
t.Fatalf("error should list xhigh and max as allowed: %s", w.Body.String())
|
|
}
|
|
if got := len(fake.tunnelReqsSnapshot()); got != 0 {
|
|
t.Fatalf("rejected 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"},
|
|
"context_management":{"edits":[{"type":"clear_tool_uses_20250919","trigger":{"type":"input_tokens","value":50000}}]},
|
|
"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},"defer_loading":true}]
|
|
}`
|
|
req := newAnthropicRequest(http.MethodPost, "/v1/messages", body)
|
|
req.Header.Set(anthropicBetaHeader, strings.Join([]string{
|
|
"advanced-tool-use-2025-11-20",
|
|
"claude-code-20250219",
|
|
"context-management-2025-06-27",
|
|
"interleaved-thinking-2025-05-14",
|
|
"mid-conversation-system-2026-04-07",
|
|
"prompt-caching-scope-2026-01-05",
|
|
"redact-thinking-2026-02-12",
|
|
"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())
|
|
}
|
|
requests := fake.tunnelReqsSnapshot()
|
|
if len(requests) != 1 || requests[0].Headers[anthropicBetaHeader] != "" {
|
|
t.Fatalf("Chat bridge forwarded Anthropic compatibility beta: %+v", requests)
|
|
}
|
|
|
|
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", "context_management"} {
|
|
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"])
|
|
tool := anthropicAnyMap(t, tools[0])
|
|
if _, ok := tool["defer_loading"]; ok {
|
|
t.Fatalf("Claude Code defer_loading leaked into normalized Chat tool: %+v", tool)
|
|
}
|
|
function := anthropicAnyMap(t, tool["function"])
|
|
if _, ok := function["defer_loading"]; ok {
|
|
t.Fatalf("Claude Code defer_loading leaked into normalized Chat function: %+v", function)
|
|
}
|
|
if function["name"] != "Read" {
|
|
t.Fatalf("Claude Code tool mapping mismatch: %+v", tools)
|
|
}
|
|
}
|
|
|
|
func TestAnthropicChatBridgeThinkingDisplayCompatibility(t *testing.T) {
|
|
for _, display := range []string{"omitted", "summarized"} {
|
|
t.Run(display, 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(`{"id":"chat_display","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}`)),
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "claude-route", Providers: map[string]string{"chat": "served-chat"}}})
|
|
body := fmt.Sprintf(`{"model":"claude-route","max_tokens":16,"thinking":{"type":"adaptive","display":%q},"messages":[{"role":"user","content":"hello"}]}`, display)
|
|
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)
|
|
}
|
|
for _, key := range []string{"thinking", "display"} {
|
|
if _, ok := chat[key]; ok {
|
|
t.Fatalf("Claude thinking display compatibility leaked field %q: %+v", key, chat)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|