388 lines
17 KiB
Go
388 lines
17 KiB
Go
package openai
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"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 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: "beta", body: `{"model":"claude-route","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`, beta: "prompt-caching-2024-07-31"},
|
|
} {
|
|
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 TestAnthropicChatBridgeProviderError(t *testing.T) {
|
|
candidate := anthropicTestCandidate(t, "openai")
|
|
candidate.ActualModel = "served-chat"
|
|
providerError := []byte(`{"error":{"type":"rate_limit_error","message":"slow down","code":429}}`)
|
|
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 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
|
|
}
|