chatcompletions_anthropic_bridge_test.go 42 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093
  1. package apicompat
  2. import (
  3. "encoding/json"
  4. "strings"
  5. "testing"
  6. "github.com/stretchr/testify/require"
  7. )
  8. // ---------------------------------------------------------------------------
  9. // Helpers
  10. // ---------------------------------------------------------------------------
  11. // collectAnthropicStreamEvents feeds CC chunks through the direct bridge and
  12. // appends finalize events, returning the full Anthropic event sequence.
  13. func collectAnthropicStreamEvents(t *testing.T, chunks []string) []AnthropicStreamEvent {
  14. t.Helper()
  15. state := NewChatCompletionsToAnthropicStreamState("deepseek-v4-pro")
  16. var events []AnthropicStreamEvent
  17. for _, payload := range chunks {
  18. var chunk ChatCompletionsChunk
  19. require.NoError(t, json.Unmarshal([]byte(payload), &chunk))
  20. events = append(events, ChatCompletionsChunkToAnthropicEvents(&chunk, state)...)
  21. }
  22. events = append(events, FinalizeChatCompletionsAnthropicStream(state)...)
  23. return events
  24. }
  25. // anthropicEventTypes extracts the sequence of event types for concise assertions.
  26. func anthropicEventTypes(events []AnthropicStreamEvent) []string {
  27. out := make([]string, 0, len(events))
  28. for _, e := range events {
  29. out = append(out, e.Type)
  30. }
  31. return out
  32. }
  33. // ---------------------------------------------------------------------------
  34. // Request: AnthropicToChatCompletionsRequest
  35. // ---------------------------------------------------------------------------
  36. func TestAnthropicToChatCompletionsRequest_BasicText(t *testing.T) {
  37. req := &AnthropicRequest{
  38. Model: "claude-sonnet-4-20250514",
  39. MaxTokens: 1024,
  40. Messages: []AnthropicMessage{
  41. {Role: "user", Content: json.RawMessage(`"hello"`)},
  42. },
  43. }
  44. out, err := AnthropicToChatCompletionsRequest(req)
  45. require.NoError(t, err)
  46. require.Equal(t, "claude-sonnet-4-20250514", out.Model)
  47. require.Len(t, out.Messages, 1)
  48. require.Equal(t, "user", out.Messages[0].Role)
  49. require.Equal(t, `"hello"`, string(out.Messages[0].Content))
  50. require.NotNil(t, out.MaxCompletionTokens)
  51. require.Equal(t, 1024, *out.MaxCompletionTokens)
  52. }
  53. func TestAnthropicToChatCompletionsRequest_SystemPrompt(t *testing.T) {
  54. req := &AnthropicRequest{
  55. Model: "claude-sonnet-4-20250514",
  56. MaxTokens: 100,
  57. System: json.RawMessage(`"You are helpful"`),
  58. Messages: []AnthropicMessage{
  59. {Role: "user", Content: json.RawMessage(`"hi"`)},
  60. },
  61. }
  62. out, err := AnthropicToChatCompletionsRequest(req)
  63. require.NoError(t, err)
  64. require.Len(t, out.Messages, 2)
  65. require.Equal(t, "system", out.Messages[0].Role)
  66. require.Equal(t, `"You are helpful"`, string(out.Messages[0].Content))
  67. }
  68. func TestAnthropicToChatCompletionsRequest_ToolUseInAssistant(t *testing.T) {
  69. req := &AnthropicRequest{
  70. Model: "claude-sonnet-4-20250514",
  71. MaxTokens: 100,
  72. Messages: []AnthropicMessage{
  73. {Role: "user", Content: json.RawMessage(`"check weather"`)},
  74. {Role: "assistant", Content: json.RawMessage(`[{"type":"text","text":"Let me check."},{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"SF"}}]`)},
  75. {Role: "user", Content: json.RawMessage(`[{"type":"tool_result","tool_use_id":"toolu_1","content":"sunny"}]`)},
  76. },
  77. }
  78. out, err := AnthropicToChatCompletionsRequest(req)
  79. require.NoError(t, err)
  80. // user + assistant(with tool_calls) + tool reply
  81. require.GreaterOrEqual(t, len(out.Messages), 2)
  82. // Find the assistant message with tool_calls
  83. var assistant *ChatMessage
  84. for i := range out.Messages {
  85. if out.Messages[i].Role == "assistant" && len(out.Messages[i].ToolCalls) > 0 {
  86. assistant = &out.Messages[i]
  87. }
  88. }
  89. require.NotNil(t, assistant, "assistant message with tool_calls should survive normalization")
  90. require.Len(t, assistant.ToolCalls, 1)
  91. require.Equal(t, "toolu_1", assistant.ToolCalls[0].ID)
  92. require.Equal(t, "function", assistant.ToolCalls[0].Type)
  93. require.Equal(t, "get_weather", assistant.ToolCalls[0].Function.Name)
  94. require.Equal(t, `{"city":"SF"}`, assistant.ToolCalls[0].Function.Arguments)
  95. }
  96. func TestAnthropicToChatCompletionsRequest_ToolResultBecomesToolMessage(t *testing.T) {
  97. req := &AnthropicRequest{
  98. Model: "claude-sonnet-4-20250514",
  99. MaxTokens: 100,
  100. Messages: []AnthropicMessage{
  101. {Role: "user", Content: json.RawMessage(`"check weather"`)},
  102. {Role: "assistant", Content: json.RawMessage(`[{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"SF"}}]`)},
  103. {Role: "user", Content: json.RawMessage(`[{"type":"tool_result","tool_use_id":"toolu_1","content":"sunny, 72F"}]`)},
  104. },
  105. }
  106. out, err := AnthropicToChatCompletionsRequest(req)
  107. require.NoError(t, err)
  108. // Find the tool reply message
  109. var toolMsg *ChatMessage
  110. for i := range out.Messages {
  111. if out.Messages[i].Role == "tool" {
  112. toolMsg = &out.Messages[i]
  113. }
  114. }
  115. require.NotNil(t, toolMsg, "tool_result should become a tool role message")
  116. require.Equal(t, "toolu_1", toolMsg.ToolCallID)
  117. require.Equal(t, `"sunny, 72F"`, string(toolMsg.Content))
  118. }
  119. func TestAnthropicToChatCompletionsRequest_ThinkingDropped(t *testing.T) {
  120. req := &AnthropicRequest{
  121. Model: "claude-sonnet-4-20250514",
  122. MaxTokens: 100,
  123. Messages: []AnthropicMessage{
  124. {Role: "assistant", Content: json.RawMessage(`[{"type":"thinking","thinking":"secret thoughts"},{"type":"text","text":"answer"}]`)},
  125. },
  126. }
  127. out, err := AnthropicToChatCompletionsRequest(req)
  128. require.NoError(t, err)
  129. require.Len(t, out.Messages, 1)
  130. // Only text survives. Thinking is dropped because this turn carries no tool
  131. // calls — reasoning rides along with tool calls only, matching the
  132. // Responses→Chat bridge (see anthropicThinkingToReasoningContent).
  133. require.Equal(t, `"answer"`, string(out.Messages[0].Content))
  134. require.Empty(t, out.Messages[0].ReasoningContent)
  135. }
  136. func TestAnthropicToChatCompletionsRequest_ToolChoiceAuto(t *testing.T) {
  137. req := &AnthropicRequest{
  138. Model: "claude-sonnet-4-20250514",
  139. MaxTokens: 100,
  140. Tools: []AnthropicTool{
  141. {Name: "get_weather", InputSchema: json.RawMessage(`{"type":"object","properties":{}}`)},
  142. },
  143. ToolChoice: json.RawMessage(`{"type":"auto"}`),
  144. Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}},
  145. }
  146. out, err := AnthropicToChatCompletionsRequest(req)
  147. require.NoError(t, err)
  148. require.Len(t, out.Tools, 1)
  149. require.Equal(t, `"auto"`, string(out.ToolChoice))
  150. }
  151. func TestAnthropicToChatCompletionsRequest_ToolChoiceAny(t *testing.T) {
  152. req := &AnthropicRequest{
  153. Model: "claude-sonnet-4-20250514",
  154. MaxTokens: 100,
  155. Tools: []AnthropicTool{
  156. {Name: "get_weather", InputSchema: json.RawMessage(`{"type":"object","properties":{}}`)},
  157. },
  158. ToolChoice: json.RawMessage(`{"type":"any"}`),
  159. Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}},
  160. }
  161. out, err := AnthropicToChatCompletionsRequest(req)
  162. require.NoError(t, err)
  163. require.Equal(t, `"required"`, string(out.ToolChoice))
  164. }
  165. func TestAnthropicToChatCompletionsRequest_ToolChoiceSpecificTool(t *testing.T) {
  166. req := &AnthropicRequest{
  167. Model: "claude-sonnet-4-20250514",
  168. MaxTokens: 100,
  169. Tools: []AnthropicTool{
  170. {Name: "get_weather", InputSchema: json.RawMessage(`{"type":"object","properties":{}}`)},
  171. },
  172. ToolChoice: json.RawMessage(`{"type":"tool","name":"get_weather"}`),
  173. Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}},
  174. }
  175. out, err := AnthropicToChatCompletionsRequest(req)
  176. require.NoError(t, err)
  177. var tc map[string]any
  178. require.NoError(t, json.Unmarshal(out.ToolChoice, &tc))
  179. require.Equal(t, "function", tc["type"])
  180. fn, ok := tc["function"].(map[string]any)
  181. require.True(t, ok, "tool_choice function should be a map")
  182. require.Equal(t, "get_weather", fn["name"])
  183. }
  184. func TestAnthropicToChatCompletionsRequest_TemperatureStrippedForReasoningModel(t *testing.T) {
  185. temp := 0.7
  186. topP := 0.9
  187. req := &AnthropicRequest{
  188. Model: "gpt-5.4",
  189. MaxTokens: 100,
  190. Temperature: &temp,
  191. TopP: &topP,
  192. Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}},
  193. }
  194. out, err := AnthropicToChatCompletionsRequest(req)
  195. require.NoError(t, err)
  196. require.Nil(t, out.Temperature, "temperature should be stripped for reasoning models")
  197. require.Nil(t, out.TopP, "top_p should be stripped for reasoning models")
  198. }
  199. func TestAnthropicToChatCompletionsRequest_TemperaturePreservedForNonReasoningModel(t *testing.T) {
  200. temp := 0.7
  201. topP := 0.9
  202. req := &AnthropicRequest{
  203. Model: "deepseek-v4-pro",
  204. MaxTokens: 100,
  205. Temperature: &temp,
  206. TopP: &topP,
  207. Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}},
  208. }
  209. out, err := AnthropicToChatCompletionsRequest(req)
  210. require.NoError(t, err)
  211. require.NotNil(t, out.Temperature)
  212. require.Equal(t, 0.7, *out.Temperature)
  213. require.NotNil(t, out.TopP)
  214. require.Equal(t, 0.9, *out.TopP)
  215. }
  216. func TestAnthropicToChatCompletionsRequest_MaxTokensFloor(t *testing.T) {
  217. req := &AnthropicRequest{
  218. Model: "claude-sonnet-4-20250514",
  219. MaxTokens: 10, // below minMaxOutputTokens (128)
  220. Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}},
  221. }
  222. out, err := AnthropicToChatCompletionsRequest(req)
  223. require.NoError(t, err)
  224. require.NotNil(t, out.MaxCompletionTokens)
  225. require.Equal(t, minMaxOutputTokens, *out.MaxCompletionTokens)
  226. }
  227. func TestAnthropicToChatCompletionsRequest_ReasoningEffortMapping(t *testing.T) {
  228. req := &AnthropicRequest{
  229. Model: "gpt-5.4",
  230. MaxTokens: 100,
  231. OutputConfig: &AnthropicOutputConfig{Effort: "max"},
  232. Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}},
  233. }
  234. out, err := AnthropicToChatCompletionsRequest(req)
  235. require.NoError(t, err)
  236. require.Equal(t, "xhigh", out.ReasoningEffort)
  237. }
  238. func TestAnthropicToChatCompletionsRequest_ReasoningEffortDefaultMedium(t *testing.T) {
  239. req := &AnthropicRequest{
  240. Model: "gpt-5.4",
  241. MaxTokens: 100,
  242. Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}},
  243. }
  244. out, err := AnthropicToChatCompletionsRequest(req)
  245. require.NoError(t, err)
  246. require.Equal(t, "medium", out.ReasoningEffort)
  247. }
  248. func TestAnthropicToChatCompletionsRequest_ServerToolDropped(t *testing.T) {
  249. req := &AnthropicRequest{
  250. Model: "claude-sonnet-4-20250514",
  251. MaxTokens: 100,
  252. Tools: []AnthropicTool{
  253. {Type: "web_search_20250305", Name: "web_search"},
  254. {Name: "get_weather", InputSchema: json.RawMessage(`{"type":"object","properties":{}}`)},
  255. },
  256. Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}},
  257. }
  258. out, err := AnthropicToChatCompletionsRequest(req)
  259. require.NoError(t, err)
  260. require.Len(t, out.Tools, 1, "web_search server tool should be dropped")
  261. require.Equal(t, "get_weather", out.Tools[0].Function.Name)
  262. }
  263. // ---------------------------------------------------------------------------
  264. // Non-streaming response: ChatCompletionsResponseToAnthropic
  265. // ---------------------------------------------------------------------------
  266. func TestChatCompletionsResponseToAnthropic_TextOnly(t *testing.T) {
  267. resp := &ChatCompletionsResponse{
  268. ID: "chatcmpl-1",
  269. Model: "deepseek-v4-pro",
  270. Choices: []ChatChoice{{
  271. Index: 0,
  272. Message: ChatMessage{Role: "assistant", Content: json.RawMessage(`"hello world"`)},
  273. FinishReason: "stop",
  274. }},
  275. Usage: &ChatUsage{PromptTokens: 5, CompletionTokens: 2, TotalTokens: 7},
  276. }
  277. out := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514")
  278. require.Equal(t, "chatcmpl-1", out.ID)
  279. require.Equal(t, "claude-sonnet-4-20250514", out.Model)
  280. require.Len(t, out.Content, 1)
  281. require.Equal(t, "text", out.Content[0].Type)
  282. require.Equal(t, "hello world", out.Content[0].Text)
  283. require.Equal(t, "end_turn", AnthropicStopReasonString(out.StopReason))
  284. require.Equal(t, 5, out.Usage.InputTokens)
  285. require.Equal(t, 2, out.Usage.OutputTokens)
  286. }
  287. func TestChatCompletionsResponseToAnthropic_ToolUse(t *testing.T) {
  288. resp := &ChatCompletionsResponse{
  289. ID: "chatcmpl-2",
  290. Model: "deepseek-v4-pro",
  291. Choices: []ChatChoice{{
  292. Index: 0,
  293. Message: ChatMessage{
  294. Role: "assistant",
  295. ToolCalls: []ChatToolCall{{
  296. ID: "call_1",
  297. Type: "function",
  298. Function: ChatFunctionCall{
  299. Name: "get_weather",
  300. Arguments: `{"city":"SF"}`,
  301. },
  302. }},
  303. },
  304. FinishReason: "tool_calls",
  305. }},
  306. }
  307. out := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514")
  308. require.Len(t, out.Content, 1)
  309. require.Equal(t, "tool_use", out.Content[0].Type)
  310. require.Equal(t, "call_1", out.Content[0].ID)
  311. require.Equal(t, "get_weather", out.Content[0].Name)
  312. require.Equal(t, `{"city":"SF"}`, string(out.Content[0].Input))
  313. require.Equal(t, "tool_use", AnthropicStopReasonString(out.StopReason))
  314. }
  315. func TestChatCompletionsResponseToAnthropic_ReasoningOnlyFallback(t *testing.T) {
  316. resp := &ChatCompletionsResponse{
  317. ID: "chatcmpl-3",
  318. Model: "deepseek-v4-pro",
  319. Choices: []ChatChoice{{
  320. Index: 0,
  321. Message: ChatMessage{
  322. Role: "assistant",
  323. ReasoningContent: "I should think about this",
  324. },
  325. FinishReason: "stop",
  326. }},
  327. }
  328. out := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514")
  329. // thinking block + text block (fallback uses reasoning as visible text)
  330. require.Len(t, out.Content, 2)
  331. require.Equal(t, "thinking", out.Content[0].Type)
  332. require.Equal(t, "I should think about this", out.Content[0].Thinking)
  333. require.Equal(t, "text", out.Content[1].Type)
  334. require.Equal(t, "I should think about this", out.Content[1].Text)
  335. }
  336. func TestChatCompletionsResponseToAnthropic_FinishReasonLength(t *testing.T) {
  337. resp := &ChatCompletionsResponse{
  338. ID: "chatcmpl-4",
  339. Model: "deepseek-v4-pro",
  340. Choices: []ChatChoice{{
  341. Index: 0,
  342. Message: ChatMessage{Role: "assistant", Content: json.RawMessage(`"truncated"`)},
  343. FinishReason: "length",
  344. }},
  345. }
  346. out := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514")
  347. require.Equal(t, "max_tokens", AnthropicStopReasonString(out.StopReason))
  348. }
  349. func TestChatCompletionsResponseToAnthropic_EmptyChoices(t *testing.T) {
  350. resp := &ChatCompletionsResponse{
  351. ID: "chatcmpl-5",
  352. Model: "deepseek-v4-pro",
  353. Choices: []ChatChoice{},
  354. }
  355. out := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514")
  356. require.Len(t, out.Content, 1)
  357. require.Equal(t, "text", out.Content[0].Type)
  358. require.Equal(t, "", out.Content[0].Text)
  359. require.Equal(t, "end_turn", AnthropicStopReasonString(out.StopReason), "empty choices must not produce an empty stop_reason")
  360. }
  361. func TestChatCompletionsResponseToAnthropic_CacheTokens(t *testing.T) {
  362. resp := &ChatCompletionsResponse{
  363. ID: "chatcmpl-6",
  364. Model: "deepseek-v4-pro",
  365. Choices: []ChatChoice{{
  366. Index: 0,
  367. Message: ChatMessage{Role: "assistant", Content: json.RawMessage(`"hi"`)},
  368. FinishReason: "stop",
  369. }},
  370. Usage: &ChatUsage{
  371. PromptTokens: 100,
  372. CompletionTokens: 5,
  373. TotalTokens: 105,
  374. PromptTokensDetails: &ChatTokenDetails{
  375. CachedTokens: 30,
  376. CacheCreationTokens: 10,
  377. },
  378. },
  379. }
  380. out := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514")
  381. // input = prompt(100) - cached(30) - cacheCreation(10) = 60
  382. require.Equal(t, 60, out.Usage.InputTokens)
  383. require.Equal(t, 5, out.Usage.OutputTokens)
  384. require.Equal(t, 30, out.Usage.CacheReadInputTokens)
  385. require.Equal(t, 10, out.Usage.CacheCreationInputTokens)
  386. }
  387. func TestChatCompletionsResponseToAnthropic_NilResponse(t *testing.T) {
  388. out := ChatCompletionsResponseToAnthropic(nil, "claude-sonnet-4-20250514")
  389. require.Len(t, out.Content, 1)
  390. require.Equal(t, "text", out.Content[0].Type)
  391. require.Equal(t, "end_turn", AnthropicStopReasonString(out.StopReason), "nil response must not produce an empty stop_reason")
  392. require.NotEmpty(t, out.ID)
  393. }
  394. // ---------------------------------------------------------------------------
  395. // Streaming: ChatCompletionsChunkToAnthropicEvents
  396. // ---------------------------------------------------------------------------
  397. func TestChatCompletionsChunkToAnthropicEvents_TextOnly(t *testing.T) {
  398. events := collectAnthropicStreamEvents(t, []string{
  399. `{"choices":[{"index":0,"delta":{"role":"assistant","content":"hello"}}]}`,
  400. `{"choices":[{"index":0,"delta":{"content":" world"}}]}`,
  401. `{"choices":[{"index":0,"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}`,
  402. })
  403. types := anthropicEventTypes(events)
  404. // message_start → content_block_start(text) → 2× content_block_delta → content_block_stop → message_delta → message_stop
  405. require.Equal(t, []string{
  406. "message_start",
  407. "content_block_start",
  408. "content_block_delta",
  409. "content_block_delta",
  410. "content_block_stop",
  411. "message_delta",
  412. "message_stop",
  413. }, types)
  414. // Verify deltas
  415. var texts []string
  416. for _, e := range events {
  417. if e.Type == "content_block_delta" && e.Delta != nil {
  418. texts = append(texts, e.Delta.Text)
  419. }
  420. }
  421. require.Equal(t, []string{"hello", " world"}, texts)
  422. // Verify stop reason
  423. for _, e := range events {
  424. if e.Type == "message_delta" {
  425. require.Equal(t, "end_turn", e.Delta.StopReason)
  426. require.Equal(t, 5, e.Usage.InputTokens)
  427. require.Equal(t, 2, e.Usage.OutputTokens)
  428. }
  429. }
  430. }
  431. func TestChatCompletionsChunkToAnthropicEvents_ReasoningThenContent(t *testing.T) {
  432. events := collectAnthropicStreamEvents(t, []string{
  433. `{"choices":[{"index":0,"delta":{"reasoning_content":"thinking..."}}]}`,
  434. `{"choices":[{"index":0,"delta":{"content":"answer"}}]}`,
  435. `{"choices":[{"index":0,"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`,
  436. })
  437. types := anthropicEventTypes(events)
  438. // message_start → thinking block start → thinking_delta → thinking block stop
  439. // → text block start → text_delta → text block stop → message_delta → message_stop
  440. require.Equal(t, []string{
  441. "message_start",
  442. "content_block_start",
  443. "content_block_delta",
  444. "content_block_stop",
  445. "content_block_start",
  446. "content_block_delta",
  447. "content_block_stop",
  448. "message_delta",
  449. "message_stop",
  450. }, types)
  451. // First content_block_start should be thinking, second text
  452. var blockTypes []string
  453. for _, e := range events {
  454. if e.Type == "content_block_start" && e.ContentBlock != nil {
  455. blockTypes = append(blockTypes, e.ContentBlock.Type)
  456. }
  457. }
  458. require.Equal(t, []string{"thinking", "text"}, blockTypes)
  459. }
  460. func TestChatCompletionsChunkToAnthropicEvents_ToolCallAggregation(t *testing.T) {
  461. events := collectAnthropicStreamEvents(t, []string{
  462. `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]}}]}`,
  463. `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":"}}]}}]}`,
  464. `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"SF\"}"}}]}}]}`,
  465. `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}`,
  466. })
  467. types := anthropicEventTypes(events)
  468. // message_start → content_block_start(tool_use) → 2× input_json_delta (empty first arg skipped) → content_block_stop → message_delta(tool_use) → message_stop
  469. require.Equal(t, []string{
  470. "message_start",
  471. "content_block_start",
  472. "content_block_delta",
  473. "content_block_delta",
  474. "content_block_stop",
  475. "message_delta",
  476. "message_stop",
  477. }, types)
  478. // Verify tool_use block
  479. for _, e := range events {
  480. if e.Type == "content_block_start" && e.ContentBlock != nil {
  481. require.Equal(t, "tool_use", e.ContentBlock.Type)
  482. require.Equal(t, "call_1", e.ContentBlock.ID)
  483. require.Equal(t, "get_weather", e.ContentBlock.Name)
  484. }
  485. if e.Type == "message_delta" {
  486. require.Equal(t, "tool_use", e.Delta.StopReason)
  487. }
  488. }
  489. // Verify arguments assembled (empty first fragment skipped)
  490. var partials []string
  491. for _, e := range events {
  492. if e.Type == "content_block_delta" && e.Delta != nil {
  493. partials = append(partials, e.Delta.PartialJSON)
  494. }
  495. }
  496. require.Equal(t, []string{`{"city":`, `"SF"}`}, partials)
  497. }
  498. func TestChatCompletionsChunkToAnthropicEvents_LengthMapsToMaxTokens(t *testing.T) {
  499. events := collectAnthropicStreamEvents(t, []string{
  500. `{"choices":[{"index":0,"delta":{"content":"partial"}}]}`,
  501. `{"choices":[{"index":0,"delta":{"content":""},"finish_reason":"length"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`,
  502. })
  503. for _, e := range events {
  504. if e.Type == "message_delta" {
  505. require.Equal(t, "max_tokens", e.Delta.StopReason)
  506. }
  507. }
  508. }
  509. func TestChatCompletionsChunkToAnthropicEvents_EmptyStream(t *testing.T) {
  510. events := collectAnthropicStreamEvents(t, []string{
  511. `{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":0,"total_tokens":1}}`,
  512. })
  513. types := anthropicEventTypes(events)
  514. // Even with no content, message_start + message_delta + message_stop should fire.
  515. require.Contains(t, types, "message_start")
  516. require.Contains(t, types, "message_stop")
  517. }
  518. func TestChatCompletionsChunkToAnthropicEvents_MessageStartEmittedOnce(t *testing.T) {
  519. events := collectAnthropicStreamEvents(t, []string{
  520. `{"choices":[{"index":0,"delta":{"content":"a"}}]}`,
  521. `{"choices":[{"index":0,"delta":{"content":"b"}}]}`,
  522. `{"choices":[{"index":0,"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`,
  523. })
  524. count := 0
  525. for _, e := range events {
  526. if e.Type == "message_start" {
  527. count++
  528. }
  529. }
  530. require.Equal(t, 1, count, "message_start should only be emitted once")
  531. }
  532. func TestChatCompletionsChunkToAnthropicEvents_ParallelToolCalls(t *testing.T) {
  533. events := collectAnthropicStreamEvents(t, []string{
  534. `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"tool_a","arguments":"{}"}}]}}]}`,
  535. `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_2","type":"function","function":{"name":"tool_b","arguments":"{}"}}]}}]}`,
  536. `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`,
  537. })
  538. // Two tool_use blocks should be opened
  539. var toolBlocks []string
  540. for _, e := range events {
  541. if e.Type == "content_block_start" && e.ContentBlock != nil && e.ContentBlock.Type == "tool_use" {
  542. toolBlocks = append(toolBlocks, e.ContentBlock.Name)
  543. }
  544. }
  545. require.Equal(t, []string{"tool_a", "tool_b"}, toolBlocks)
  546. for _, e := range events {
  547. if e.Type == "message_delta" {
  548. require.Equal(t, "tool_use", e.Delta.StopReason)
  549. }
  550. }
  551. }
  552. func TestFinalizeChatCompletionsAnthropicStream_NoOpAfterStop(t *testing.T) {
  553. state := NewChatCompletionsToAnthropicStreamState("test")
  554. state.MessageStopSent = true
  555. events := FinalizeChatCompletionsAnthropicStream(state)
  556. require.Nil(t, events, "finalize should be a no-op after message_stop")
  557. }
  558. func TestFinalizeChatCompletionsAnthropicStream_EmitsMessageStartIfMissing(t *testing.T) {
  559. state := NewChatCompletionsToAnthropicStreamState("test")
  560. // Never fed any chunks — message_start not yet sent
  561. events := FinalizeChatCompletionsAnthropicStream(state)
  562. types := anthropicEventTypes(events)
  563. require.Contains(t, types, "message_start")
  564. require.Contains(t, types, "message_stop")
  565. }
  566. // ---------------------------------------------------------------------------
  567. // Equivalence: direct bridge matches the double-conversion bridge
  568. // ---------------------------------------------------------------------------
  569. // TestDirectBridge_NonStreamingMatchesDoubleConversion verifies that
  570. // ChatCompletionsResponseToAnthropic produces the same Anthropic response as the
  571. // existing ChatCompletionsResponseToResponses + ResponsesToAnthropic chain.
  572. func TestDirectBridge_NonStreamingMatchesDoubleConversion(t *testing.T) {
  573. resp := &ChatCompletionsResponse{
  574. ID: "chatcmpl-eq",
  575. Model: "deepseek-v4-pro",
  576. Choices: []ChatChoice{{
  577. Index: 0,
  578. Message: ChatMessage{
  579. Role: "assistant",
  580. Content: json.RawMessage(`"hello"`),
  581. ReasoningContent: "reasoning text",
  582. ToolCalls: []ChatToolCall{{
  583. ID: "call_eq",
  584. Type: "function",
  585. Function: ChatFunctionCall{
  586. Name: "search",
  587. Arguments: `{"q":"test"}`,
  588. },
  589. }},
  590. },
  591. FinishReason: "tool_calls",
  592. }},
  593. Usage: &ChatUsage{
  594. PromptTokens: 50,
  595. CompletionTokens: 10,
  596. TotalTokens: 60,
  597. PromptTokensDetails: &ChatTokenDetails{CachedTokens: 5},
  598. },
  599. }
  600. // Direct bridge
  601. direct := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514")
  602. // Double-conversion bridge
  603. responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, nil, false, nil)
  604. double := ResponsesToAnthropic(responsesResp, "claude-sonnet-4-20250514")
  605. // Compare key fields
  606. require.Equal(t, AnthropicStopReasonString(direct.StopReason), AnthropicStopReasonString(double.StopReason))
  607. require.Equal(t, direct.Model, double.Model)
  608. require.Len(t, direct.Content, len(double.Content))
  609. for i := range direct.Content {
  610. require.Equal(t, double.Content[i].Type, direct.Content[i].Type, "block %d type mismatch", i)
  611. require.Equal(t, double.Content[i].Text, direct.Content[i].Text, "block %d text mismatch", i)
  612. require.Equal(t, double.Content[i].Thinking, direct.Content[i].Thinking, "block %d thinking mismatch", i)
  613. require.Equal(t, double.Content[i].Name, direct.Content[i].Name, "block %d name mismatch", i)
  614. require.Equal(t, double.Content[i].ID, direct.Content[i].ID, "block %d id mismatch", i)
  615. require.Equal(t, string(double.Content[i].Input), string(direct.Content[i].Input), "block %d input mismatch", i)
  616. }
  617. require.Equal(t, double.Usage.InputTokens, direct.Usage.InputTokens)
  618. require.Equal(t, double.Usage.OutputTokens, direct.Usage.OutputTokens)
  619. require.Equal(t, double.Usage.CacheReadInputTokens, direct.Usage.CacheReadInputTokens)
  620. require.Equal(t, double.Usage.CacheCreationInputTokens, direct.Usage.CacheCreationInputTokens)
  621. }
  622. // TestDirectBridge_RequestMatchesDoubleConversion verifies that
  623. // AnthropicToChatCompletionsRequest produces an equivalent Chat Completions
  624. // request as the AnthropicToResponses + ResponsesToChatCompletionsRequest chain.
  625. func TestDirectBridge_RequestMatchesDoubleConversion(t *testing.T) {
  626. temp := 0.5
  627. req := &AnthropicRequest{
  628. Model: "deepseek-v4-pro",
  629. MaxTokens: 500,
  630. Temperature: &temp,
  631. System: json.RawMessage(`"be helpful"`),
  632. Tools: []AnthropicTool{
  633. {Name: "get_weather", InputSchema: json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}}}`)},
  634. },
  635. ToolChoice: json.RawMessage(`{"type":"auto"}`),
  636. Messages: []AnthropicMessage{
  637. {Role: "user", Content: json.RawMessage(`"what's the weather?"`)},
  638. {Role: "assistant", Content: json.RawMessage(`[{"type":"text","text":"checking"},{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"SF"}}]`)},
  639. {Role: "user", Content: json.RawMessage(`[{"type":"tool_result","tool_use_id":"toolu_1","content":"sunny"}]`)},
  640. },
  641. }
  642. // Direct bridge
  643. direct, err := AnthropicToChatCompletionsRequest(req)
  644. require.NoError(t, err)
  645. // Double-conversion bridge
  646. responsesReq, err := AnthropicToResponses(req)
  647. require.NoError(t, err)
  648. double, err := ResponsesToChatCompletionsRequest(responsesReq)
  649. require.NoError(t, err)
  650. // Compare key fields
  651. require.Equal(t, double.Model, direct.Model)
  652. require.Equal(t, double.Temperature, direct.Temperature)
  653. require.Equal(t, double.MaxCompletionTokens, direct.MaxCompletionTokens)
  654. require.Equal(t, double.ReasoningEffort, direct.ReasoningEffort)
  655. require.Equal(t, string(double.ToolChoice), string(direct.ToolChoice))
  656. require.Len(t, direct.Tools, len(double.Tools))
  657. // Compare messages — same count, same roles, same content
  658. require.Len(t, direct.Messages, len(double.Messages), "message count mismatch")
  659. for i := range direct.Messages {
  660. require.Equal(t, double.Messages[i].Role, direct.Messages[i].Role, "msg %d role mismatch", i)
  661. // Normalize content for comparison (both should be valid JSON)
  662. var dContent, dblContent any
  663. _ = json.Unmarshal(double.Messages[i].Content, &dblContent)
  664. _ = json.Unmarshal(direct.Messages[i].Content, &dContent)
  665. require.Equal(t, dblContent, dContent, "msg %d content mismatch", i)
  666. require.Equal(t, double.Messages[i].ToolCallID, direct.Messages[i].ToolCallID, "msg %d tool_call_id mismatch", i)
  667. require.Len(t, direct.Messages[i].ToolCalls, len(double.Messages[i].ToolCalls), "msg %d tool_calls count mismatch", i)
  668. for j := range direct.Messages[i].ToolCalls {
  669. require.Equal(t, double.Messages[i].ToolCalls[j].ID, direct.Messages[i].ToolCalls[j].ID, "msg %d tool %d id mismatch", i, j)
  670. require.Equal(t, double.Messages[i].ToolCalls[j].Function.Name, direct.Messages[i].ToolCalls[j].Function.Name, "msg %d tool %d name mismatch", i, j)
  671. require.Equal(t, double.Messages[i].ToolCalls[j].Function.Arguments, direct.Messages[i].ToolCalls[j].Function.Arguments, "msg %d tool %d arguments mismatch", i, j)
  672. }
  673. }
  674. }
  675. // ---------------------------------------------------------------------------
  676. // Edge cases
  677. // ---------------------------------------------------------------------------
  678. func TestChatCompletionsChunkToAnthropicEvents_ImageInToolResult(t *testing.T) {
  679. // A multi-turn conversation: assistant calls a tool, user replies with a
  680. // tool_result containing text + an image. The image should be lifted into
  681. // a follow-up user message as an image_url part.
  682. req := &AnthropicRequest{
  683. Model: "claude-sonnet-4-20250514",
  684. MaxTokens: 100,
  685. Messages: []AnthropicMessage{
  686. {Role: "user", Content: json.RawMessage(`"check this image"`)},
  687. {Role: "assistant", Content: json.RawMessage(`[{"type":"text","text":"let me look"},{"type":"tool_use","id":"toolu_1","name":"analyze","input":{"x":1}}]`)},
  688. {Role: "user", Content: json.RawMessage(`[{"type":"tool_result","tool_use_id":"toolu_1","content":[{"type":"text","text":"result"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"aGVsbG8="}}]}]`)},
  689. },
  690. }
  691. out, err := AnthropicToChatCompletionsRequest(req)
  692. require.NoError(t, err)
  693. // user + assistant(tool_use) + tool + user(image)
  694. require.GreaterOrEqual(t, len(out.Messages), 3)
  695. // Find the user message with image content
  696. var foundImage bool
  697. for _, m := range out.Messages {
  698. if m.Role != "user" {
  699. continue
  700. }
  701. var parts []ChatContentPart
  702. if err := json.Unmarshal(m.Content, &parts); err == nil {
  703. for _, p := range parts {
  704. if p.Type == "image_url" && p.ImageURL != nil {
  705. foundImage = true
  706. require.True(t, strings.HasPrefix(p.ImageURL.URL, "data:image/png;base64,"))
  707. }
  708. }
  709. }
  710. }
  711. require.True(t, foundImage, "image from tool_result should appear in user message")
  712. }
  713. func TestChatCompletionsToAnthropicStreamState_ToolCallNameArrivesLate(t *testing.T) {
  714. // Some upstreams send the tool_call index + arguments before the name.
  715. events := collectAnthropicStreamEvents(t, []string{
  716. `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_late"}]}}]}`,
  717. `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"late_tool"}}]}}]}`,
  718. `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]}}]}`,
  719. `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`,
  720. })
  721. // The tool_use block should still be opened with the correct name.
  722. var toolName string
  723. for _, e := range events {
  724. if e.Type == "content_block_start" && e.ContentBlock != nil && e.ContentBlock.Type == "tool_use" {
  725. toolName = e.ContentBlock.Name
  726. }
  727. }
  728. require.Equal(t, "late_tool", toolName)
  729. }
  730. // assembleToolUseBlocks rebuilds tool_use blocks from a stream the way an
  731. // Anthropic client does: content_block_start announces id/name, input_json_delta
  732. // fragments concatenate into the input JSON.
  733. type assembledToolUse struct {
  734. ID string
  735. Name string
  736. Input string
  737. }
  738. func assembleToolUseBlocks(events []AnthropicStreamEvent) []assembledToolUse {
  739. blockByIdx := map[int]int{} // anthropic block index → position in out
  740. var out []assembledToolUse
  741. for _, e := range events {
  742. switch e.Type {
  743. case "content_block_start":
  744. if e.ContentBlock != nil && e.ContentBlock.Type == "tool_use" && e.Index != nil {
  745. blockByIdx[*e.Index] = len(out)
  746. out = append(out, assembledToolUse{ID: e.ContentBlock.ID, Name: e.ContentBlock.Name})
  747. }
  748. case "content_block_delta":
  749. if e.Delta != nil && e.Delta.Type == "input_json_delta" && e.Index != nil {
  750. if pos, ok := blockByIdx[*e.Index]; ok {
  751. out[pos].Input += e.Delta.PartialJSON
  752. }
  753. }
  754. }
  755. }
  756. return out
  757. }
  758. func TestChatCompletionsToAnthropicStreamState_ToolCallArgsArriveBeforeName(t *testing.T) {
  759. // Some upstreams stream argument fragments before the tool name. The
  760. // fragments buffered while the announcement is deferred must be flushed
  761. // with the content_block_start, so the client rebuilds complete JSON.
  762. events := collectAnthropicStreamEvents(t, []string{
  763. `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_early","function":{"arguments":"{\"city\":"}}]}}]}`,
  764. `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"SF\""}}]}}]}`,
  765. `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"get_weather","arguments":"}"}}]}}]}`,
  766. `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`,
  767. })
  768. tools := assembleToolUseBlocks(events)
  769. require.Len(t, tools, 1)
  770. require.Equal(t, "call_early", tools[0].ID)
  771. require.Equal(t, "get_weather", tools[0].Name)
  772. require.JSONEq(t, `{"city":"SF"}`, tools[0].Input)
  773. // No delta may precede the block's content_block_start.
  774. started := map[int]bool{}
  775. for _, e := range events {
  776. switch e.Type {
  777. case "content_block_start":
  778. started[*e.Index] = true
  779. case "content_block_delta":
  780. require.True(t, started[*e.Index], "delta before content_block_start on index %d", *e.Index)
  781. }
  782. }
  783. }
  784. func TestChatCompletionsToAnthropicStreamState_ToolCallNameNeverArrives(t *testing.T) {
  785. // If the name never arrives, the tool is announced at finalize with an
  786. // empty name (like the double-conversion path) so its arguments are not
  787. // silently dropped — stop_reason still reports tool_use.
  788. events := collectAnthropicStreamEvents(t, []string{
  789. `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_anon","function":{"arguments":"{\"a\":1}"}}]}}]}`,
  790. `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`,
  791. })
  792. tools := assembleToolUseBlocks(events)
  793. require.Len(t, tools, 1)
  794. require.Equal(t, "call_anon", tools[0].ID)
  795. require.Equal(t, "", tools[0].Name)
  796. require.JSONEq(t, `{"a":1}`, tools[0].Input)
  797. // Block lifecycle must stay balanced and terminate before message_stop.
  798. types := anthropicEventTypes(events)
  799. require.Equal(t, []string{
  800. "message_start",
  801. "content_block_start",
  802. "content_block_delta",
  803. "content_block_stop",
  804. "message_delta",
  805. "message_stop",
  806. }, types)
  807. }
  808. func TestChatCompletionsToAnthropicStreamState_EmptyArgsToolEmitsPlaceholderDelta(t *testing.T) {
  809. // A tool call whose arguments never arrive gets a final input_json_delta
  810. // "{}" before its stop — the double-conversion path normalizes empty
  811. // arguments to "{}", and some clients assemble input only from deltas.
  812. events := collectAnthropicStreamEvents(t, []string{
  813. `{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_empty","type":"function","function":{"name":"noop","arguments":""}}]}}]}`,
  814. `{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`,
  815. })
  816. tools := assembleToolUseBlocks(events)
  817. require.Len(t, tools, 1)
  818. require.Equal(t, "noop", tools[0].Name)
  819. require.JSONEq(t, `{}`, tools[0].Input)
  820. }
  821. func TestAnthropicToChatCompletionsRequest_UserArrayContentFoldsToString(t *testing.T) {
  822. // Text-only array content folds into a single string joined with "\n\n",
  823. // like the double-conversion path — strict chat upstreams reject array
  824. // content when no image forces the parts form.
  825. req := &AnthropicRequest{
  826. Model: "deepseek-v4-pro",
  827. MaxTokens: 100,
  828. Messages: []AnthropicMessage{
  829. {Role: "user", Content: json.RawMessage(`[{"type":"text","text":"first"},{"type":"text","text":"second"}]`)},
  830. },
  831. }
  832. out, err := AnthropicToChatCompletionsRequest(req)
  833. require.NoError(t, err)
  834. require.Len(t, out.Messages, 1)
  835. require.Equal(t, `"first\n\nsecond"`, string(out.Messages[0].Content))
  836. }
  837. func TestDirectBridge_RequestMatchesDoubleConversion_ArrayUserContent(t *testing.T) {
  838. // Array-form user content: text-only folds to a string, image-bearing stays
  839. // in parts form — both must match the double-conversion chain exactly.
  840. req := &AnthropicRequest{
  841. Model: "deepseek-v4-pro",
  842. MaxTokens: 100,
  843. Messages: []AnthropicMessage{
  844. {Role: "user", Content: json.RawMessage(`[{"type":"text","text":"first"},{"type":"text","text":"second"}]`)},
  845. {Role: "assistant", Content: json.RawMessage(`"ok"`)},
  846. {Role: "user", Content: json.RawMessage(`[{"type":"text","text":"look"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"aGVsbG8="}}]`)},
  847. },
  848. }
  849. direct, err := AnthropicToChatCompletionsRequest(req)
  850. require.NoError(t, err)
  851. responsesReq, err := AnthropicToResponses(req)
  852. require.NoError(t, err)
  853. double, err := ResponsesToChatCompletionsRequest(responsesReq)
  854. require.NoError(t, err)
  855. require.Len(t, direct.Messages, len(double.Messages), "message count mismatch")
  856. for i := range direct.Messages {
  857. require.Equal(t, double.Messages[i].Role, direct.Messages[i].Role, "msg %d role mismatch", i)
  858. var dContent, dblContent any
  859. require.NoError(t, json.Unmarshal(double.Messages[i].Content, &dblContent))
  860. require.NoError(t, json.Unmarshal(direct.Messages[i].Content, &dContent))
  861. require.Equal(t, dblContent, dContent, "msg %d content mismatch", i)
  862. }
  863. }
  864. func TestDirectBridge_NonStreamingMatchesDoubleConversion_CacheWriteTokens(t *testing.T) {
  865. // cache_write_tokens and cache_creation_tokens are alternate spellings, not
  866. // additive — when both are set, the double-conversion path prefers write.
  867. resp := &ChatCompletionsResponse{
  868. ID: "chatcmpl-cache",
  869. Model: "deepseek-v4-pro",
  870. Choices: []ChatChoice{{
  871. Message: ChatMessage{Role: "assistant", Content: json.RawMessage(`"hi"`)},
  872. FinishReason: "stop",
  873. }},
  874. Usage: &ChatUsage{
  875. PromptTokens: 100,
  876. CompletionTokens: 10,
  877. TotalTokens: 110,
  878. PromptTokensDetails: &ChatTokenDetails{
  879. CachedTokens: 20,
  880. CacheCreationTokens: 7,
  881. CacheWriteTokens: 9,
  882. },
  883. },
  884. }
  885. direct := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514")
  886. responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, nil, false, nil)
  887. double := ResponsesToAnthropic(responsesResp, "claude-sonnet-4-20250514")
  888. require.Equal(t, double.Usage.InputTokens, direct.Usage.InputTokens)
  889. require.Equal(t, double.Usage.OutputTokens, direct.Usage.OutputTokens)
  890. require.Equal(t, double.Usage.CacheReadInputTokens, direct.Usage.CacheReadInputTokens)
  891. require.Equal(t, double.Usage.CacheCreationInputTokens, direct.Usage.CacheCreationInputTokens)
  892. require.Equal(t, 9, direct.Usage.CacheCreationInputTokens)
  893. }
  894. func TestChatCompletionsResponseToAnthropic_GeneratesIDWhenMissing(t *testing.T) {
  895. resp := &ChatCompletionsResponse{
  896. Model: "deepseek-v4-pro",
  897. Choices: []ChatChoice{{
  898. Message: ChatMessage{Role: "assistant", Content: json.RawMessage(`"hi"`)},
  899. FinishReason: "stop",
  900. }},
  901. }
  902. out := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514")
  903. require.NotEmpty(t, out.ID, "response id must be generated when the upstream omits one")
  904. }
  905. func TestAnthropicToChatCompletionsRequest_ToolChoiceUndeclaredDropped(t *testing.T) {
  906. // A named tool_choice pointing at a dropped/unknown tool is not forwarded —
  907. // chat upstreams 400 on tool_choice referencing an undeclared tool.
  908. base := AnthropicRequest{
  909. Model: "deepseek-v4-pro",
  910. MaxTokens: 100,
  911. Tools: []AnthropicTool{
  912. {Name: "get_weather", InputSchema: json.RawMessage(`{"type":"object"}`)},
  913. {Name: "web_search_20250305", Type: "web_search_20250305", InputSchema: json.RawMessage(`{"type":"object"}`)},
  914. },
  915. Messages: []AnthropicMessage{
  916. {Role: "user", Content: json.RawMessage(`"hi"`)},
  917. },
  918. }
  919. undeclared := base
  920. undeclared.ToolChoice = json.RawMessage(`{"type":"tool","name":"nonexistent"}`)
  921. out, err := AnthropicToChatCompletionsRequest(&undeclared)
  922. require.NoError(t, err)
  923. require.Empty(t, out.ToolChoice, "tool_choice for an undeclared tool must be dropped")
  924. droppedServerTool := base
  925. droppedServerTool.ToolChoice = json.RawMessage(`{"type":"tool","name":"web_search_20250305"}`)
  926. out, err = AnthropicToChatCompletionsRequest(&droppedServerTool)
  927. require.NoError(t, err)
  928. require.Empty(t, out.ToolChoice, "tool_choice for a dropped server tool must be dropped")
  929. unknownType := base
  930. unknownType.ToolChoice = json.RawMessage(`{"type":"mystery"}`)
  931. out, err = AnthropicToChatCompletionsRequest(&unknownType)
  932. require.NoError(t, err)
  933. require.Empty(t, out.ToolChoice, "unknown tool_choice types must be dropped")
  934. declared := base
  935. declared.ToolChoice = json.RawMessage(`{"type":"tool","name":"get_weather"}`)
  936. out, err = AnthropicToChatCompletionsRequest(&declared)
  937. require.NoError(t, err)
  938. require.JSONEq(t, `{"type":"function","function":{"name":"get_weather"}}`, string(out.ToolChoice))
  939. }
  940. func TestDirectBridge_NonStreamingMatchesDoubleConversion_EmptyChoices(t *testing.T) {
  941. // An upstream 200 with empty choices must still report a valid stop_reason,
  942. // matching the double-conversion chain ("end_turn").
  943. resp := &ChatCompletionsResponse{ID: "chatcmpl-empty", Model: "deepseek-v4-pro"}
  944. direct := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514")
  945. responsesResp := ChatCompletionsResponseToResponses(resp, "claude-sonnet-4-20250514", nil, nil, false, nil)
  946. double := ResponsesToAnthropic(responsesResp, "claude-sonnet-4-20250514")
  947. require.Equal(t, AnthropicStopReasonString(double.StopReason), AnthropicStopReasonString(direct.StopReason))
  948. require.Equal(t, "end_turn", AnthropicStopReasonString(direct.StopReason))
  949. }
  950. func TestChatCompletionsResponseToAnthropic_ContentFilterWithToolUse(t *testing.T) {
  951. // content_filter (and unknown finish reasons) derive stop_reason from the
  952. // blocks, like the double-conversion path.
  953. resp := &ChatCompletionsResponse{
  954. ID: "chatcmpl-cf",
  955. Model: "deepseek-v4-pro",
  956. Choices: []ChatChoice{{
  957. Message: ChatMessage{
  958. Role: "assistant",
  959. Content: json.RawMessage(`"partial"`),
  960. ToolCalls: []ChatToolCall{{
  961. ID: "call_cf",
  962. Type: "function",
  963. Function: ChatFunctionCall{Name: "search", Arguments: `{"q":"x"}`},
  964. }},
  965. },
  966. FinishReason: "content_filter",
  967. }},
  968. }
  969. out := ChatCompletionsResponseToAnthropic(resp, "claude-sonnet-4-20250514")
  970. require.Equal(t, "tool_use", AnthropicStopReasonString(out.StopReason))
  971. }