openai_gateway_ollama_cloud_cc_reasoning_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. //go:build unit
  2. package service
  3. import (
  4. "bytes"
  5. "context"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "testing"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. "github.com/tidwall/gjson"
  15. )
  16. func ollamaCloudRawChatCompletionsTestAccount() *Account {
  17. return &Account{
  18. ID: 143,
  19. Name: "DeepSeek Ollama",
  20. Platform: PlatformOpenAI,
  21. Type: AccountTypeAPIKey,
  22. Credentials: map[string]any{
  23. "api_key": "sk-test",
  24. "base_url": "https://ollama.com",
  25. },
  26. Extra: map[string]any{
  27. openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions),
  28. },
  29. }
  30. }
  31. func TestIsOllamaCloudRawChatCompletionsAccount(t *testing.T) {
  32. t.Parallel()
  33. t.Run("ollama.com + force_chat_completions", func(t *testing.T) {
  34. t.Parallel()
  35. require.True(t, isOllamaCloudRawChatCompletionsAccount(ollamaCloudRawChatCompletionsTestAccount()))
  36. })
  37. t.Run("extra usage signal without ollama host", func(t *testing.T) {
  38. t.Parallel()
  39. account := rawChatCompletionsTestAccount()
  40. account.Credentials["base_url"] = "https://example.invalid/v1"
  41. account.Extra = map[string]any{
  42. openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions),
  43. OllamaCloudUsageSnapshotExtraKey: map[string]any{"status": "ok"},
  44. }
  45. require.True(t, isOllamaCloudRawChatCompletionsAccount(account))
  46. })
  47. t.Run("official DeepSeek", func(t *testing.T) {
  48. t.Parallel()
  49. account := rawChatCompletionsTestAccount()
  50. account.Name = "DeepSeek"
  51. account.Credentials["base_url"] = "https://api.deepseek.com"
  52. account.Extra = map[string]any{
  53. openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions),
  54. }
  55. require.False(t, isOllamaCloudRawChatCompletionsAccount(account))
  56. })
  57. t.Run("OpenCode Go extra", func(t *testing.T) {
  58. t.Parallel()
  59. account := rawChatCompletionsTestAccount()
  60. account.Credentials["base_url"] = "https://opencode.ai/zen/go/v1"
  61. account.Extra = map[string]any{
  62. openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions),
  63. "opencode_go_usage_auto_refresh": true,
  64. }
  65. require.False(t, isOllamaCloudRawChatCompletionsAccount(account))
  66. })
  67. t.Run("ollama.com without force_chat_completions", func(t *testing.T) {
  68. t.Parallel()
  69. account := ollamaCloudRawChatCompletionsTestAccount()
  70. account.Extra = nil
  71. require.False(t, isOllamaCloudRawChatCompletionsAccount(account))
  72. })
  73. t.Run("anthropic ollama.com", func(t *testing.T) {
  74. t.Parallel()
  75. account := ollamaCloudRawChatCompletionsTestAccount()
  76. account.Platform = PlatformAnthropic
  77. require.False(t, isOllamaCloudRawChatCompletionsAccount(account))
  78. })
  79. }
  80. func TestNormalizeOllamaCloudChatCompletionsResponseJSON(t *testing.T) {
  81. t.Parallel()
  82. t.Run("copies delta.reasoning to reasoning_content", func(t *testing.T) {
  83. t.Parallel()
  84. in := []byte(`{"choices":[{"delta":{"reasoning":"abc"}}]}`)
  85. out := normalizeOllamaCloudChatCompletionsResponseJSON(in)
  86. require.Equal(t, "abc", gjson.GetBytes(out, "choices.0.delta.reasoning").String())
  87. require.Equal(t, "abc", gjson.GetBytes(out, "choices.0.delta.reasoning_content").String())
  88. })
  89. t.Run("copies message.thinking to reasoning_content", func(t *testing.T) {
  90. t.Parallel()
  91. in := []byte(`{"choices":[{"message":{"thinking":"abc"}}]}`)
  92. out := normalizeOllamaCloudChatCompletionsResponseJSON(in)
  93. require.Equal(t, "abc", gjson.GetBytes(out, "choices.0.message.thinking").String())
  94. require.Equal(t, "abc", gjson.GetBytes(out, "choices.0.message.reasoning_content").String())
  95. })
  96. t.Run("does not overwrite existing reasoning_content", func(t *testing.T) {
  97. t.Parallel()
  98. in := []byte(`{"choices":[{"delta":{"reasoning":"new","reasoning_content":"old"}}]}`)
  99. out := normalizeOllamaCloudChatCompletionsResponseJSON(in)
  100. require.Equal(t, string(in), string(out))
  101. require.Equal(t, "old", gjson.GetBytes(out, "choices.0.delta.reasoning_content").String())
  102. })
  103. t.Run("empty reasoning does not open reasoning_content", func(t *testing.T) {
  104. t.Parallel()
  105. in := []byte(`{"choices":[{"delta":{"reasoning":""}}]}`)
  106. out := normalizeOllamaCloudChatCompletionsResponseJSON(in)
  107. require.Equal(t, string(in), string(out))
  108. require.False(t, gjson.GetBytes(out, "choices.0.delta.reasoning_content").Exists())
  109. })
  110. t.Run("empty thinking does not open reasoning_content", func(t *testing.T) {
  111. t.Parallel()
  112. in := []byte(`{"choices":[{"message":{"thinking":""}}]}`)
  113. out := normalizeOllamaCloudChatCompletionsResponseJSON(in)
  114. require.Equal(t, string(in), string(out))
  115. require.False(t, gjson.GetBytes(out, "choices.0.message.reasoning_content").Exists())
  116. })
  117. t.Run("tool call chunk is unchanged", func(t *testing.T) {
  118. t.Parallel()
  119. in := []byte(`{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{}"}}]}}]}`)
  120. out := normalizeOllamaCloudChatCompletionsResponseJSON(in)
  121. require.Equal(t, string(in), string(out))
  122. })
  123. }
  124. func TestNormalizeOllamaCloudChatCompletionsRequest(t *testing.T) {
  125. t.Parallel()
  126. in := []byte(`{"messages":[{"role":"user","content":"weather"},{"role":"assistant","reasoning_content":"prev","content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{}"}}]}]}`)
  127. out := normalizeOllamaCloudChatCompletionsRequest(in)
  128. require.Equal(t, "prev", gjson.GetBytes(out, "messages.1.reasoning").String())
  129. require.Equal(t, "prev", gjson.GetBytes(out, "messages.1.reasoning_content").String())
  130. require.Equal(t, "", gjson.GetBytes(out, "messages.1.content").String())
  131. require.Equal(t, "get_weather", gjson.GetBytes(out, "messages.1.tool_calls.0.function.name").String())
  132. require.False(t, gjson.GetBytes(out, "messages.0.reasoning").Exists())
  133. }
  134. func TestApplyOllamaCloudRawChatCompletionsLeavesForeignAccountsUnchanged(t *testing.T) {
  135. t.Parallel()
  136. reqBody := []byte(`{"messages":[{"role":"assistant","reasoning_content":"prev","content":""}]}`)
  137. respBody := []byte(`{"choices":[{"delta":{"reasoning":"abc"}}]}`)
  138. sseLine := `data: {"choices":[{"delta":{"reasoning":"abc"}}]}`
  139. official := rawChatCompletionsTestAccount()
  140. official.Name = "DeepSeek"
  141. official.Credentials["base_url"] = "https://api.deepseek.com"
  142. official.Extra = map[string]any{
  143. openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions),
  144. }
  145. opencode := rawChatCompletionsTestAccount()
  146. opencode.Credentials["base_url"] = "https://opencode.ai/zen/go/v1"
  147. opencode.Extra = map[string]any{
  148. openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions),
  149. "opencode_go_usage_auto_refresh": true,
  150. }
  151. for _, account := range []*Account{official, opencode} {
  152. require.Equal(t, reqBody, applyOllamaCloudRawChatCompletionsRequest(account, reqBody))
  153. require.Equal(t, respBody, applyOllamaCloudRawChatCompletionsResponse(account, respBody))
  154. require.Equal(t, sseLine, applyOllamaCloudRawChatCompletionsSSELine(account, sseLine))
  155. }
  156. }
  157. func TestNormalizeOllamaCloudChatCompletionsSSELine(t *testing.T) {
  158. t.Parallel()
  159. before := `data: {"choices":[{"delta":{"reasoning":"abc"}}]}`
  160. after := normalizeOllamaCloudChatCompletionsSSELine(before)
  161. require.True(t, strings.HasPrefix(after, "data: "))
  162. payload := strings.TrimPrefix(after, "data: ")
  163. require.Equal(t, "abc", gjson.Get(payload, "choices.0.delta.reasoning").String())
  164. require.Equal(t, "abc", gjson.Get(payload, "choices.0.delta.reasoning_content").String())
  165. require.Equal(t, "data: [DONE]", normalizeOllamaCloudChatCompletionsSSELine("data: [DONE]"))
  166. }
  167. func TestForwardAsRawChatCompletions_OllamaCloudReasoningAliasStreaming(t *testing.T) {
  168. gin.SetMode(gin.TestMode)
  169. body := []byte(`{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"hello"}],"stream":true}`)
  170. rec := httptest.NewRecorder()
  171. c, _ := gin.CreateTestContext(rec)
  172. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  173. c.Request.Header.Set("Content-Type", "application/json")
  174. upstreamBody := strings.Join([]string{
  175. `data: {"id":"chatcmpl_ollama","object":"chat.completion.chunk","model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}`,
  176. "",
  177. `data: {"id":"chatcmpl_ollama","object":"chat.completion.chunk","model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning":"abc"},"finish_reason":null}]}`,
  178. "",
  179. `data: {"id":"chatcmpl_ollama","object":"chat.completion.chunk","model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"content":"final answer"},"finish_reason":null}]}`,
  180. "",
  181. `data: {"id":"chatcmpl_ollama","object":"chat.completion.chunk","model":"deepseek-v4-pro","choices":[],"usage":{"prompt_tokens":3,"completion_tokens":5,"total_tokens":8,"completion_tokens_details":{"reasoning_tokens":4}}}`,
  182. "",
  183. "data: [DONE]",
  184. "",
  185. }, "\n")
  186. upstream := &httpUpstreamRecorder{resp: &http.Response{
  187. StatusCode: http.StatusOK,
  188. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_ollama_reasoning_stream"}},
  189. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  190. }}
  191. svc := &OpenAIGatewayService{
  192. cfg: rawChatCompletionsTestConfig(),
  193. httpUpstream: upstream,
  194. }
  195. result, err := svc.forwardAsRawChatCompletions(context.Background(), c, ollamaCloudRawChatCompletionsTestAccount(), body, "")
  196. require.NoError(t, err)
  197. require.NotNil(t, result)
  198. require.Equal(t, 3, result.Usage.InputTokens)
  199. require.Equal(t, 5, result.Usage.OutputTokens)
  200. require.Contains(t, rec.Body.String(), `"reasoning":"abc"`)
  201. require.Contains(t, rec.Body.String(), `"reasoning_content":"abc"`)
  202. require.Contains(t, rec.Body.String(), `"content":"final answer"`)
  203. require.Contains(t, rec.Body.String(), `"reasoning_tokens":4`)
  204. require.Contains(t, rec.Body.String(), "data: [DONE]")
  205. }
  206. func TestForwardAsRawChatCompletions_OllamaCloudThinkingAliasNonStreaming(t *testing.T) {
  207. gin.SetMode(gin.TestMode)
  208. body := []byte(`{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"hello"},{"role":"assistant","reasoning_content":"prev","content":""}],"stream":false}`)
  209. rec := httptest.NewRecorder()
  210. c, _ := gin.CreateTestContext(rec)
  211. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  212. c.Request.Header.Set("Content-Type", "application/json")
  213. upstreamJSON := `{"id":"chatcmpl_ollama","object":"chat.completion","model":"deepseek-v4-pro","choices":[{"index":0,"message":{"role":"assistant","thinking":"abc","content":"final answer"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":5,"total_tokens":8,"completion_tokens_details":{"reasoning_tokens":4}}}`
  214. upstream := &httpUpstreamRecorder{resp: &http.Response{
  215. StatusCode: http.StatusOK,
  216. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_ollama_thinking_json"}},
  217. Body: io.NopCloser(strings.NewReader(upstreamJSON)),
  218. }}
  219. svc := &OpenAIGatewayService{
  220. cfg: rawChatCompletionsTestConfig(),
  221. httpUpstream: upstream,
  222. }
  223. result, err := svc.forwardAsRawChatCompletions(context.Background(), c, ollamaCloudRawChatCompletionsTestAccount(), body, "")
  224. require.NoError(t, err)
  225. require.NotNil(t, result)
  226. require.Equal(t, "prev", gjson.GetBytes(upstream.lastBody, "messages.1.reasoning").String())
  227. require.Equal(t, "prev", gjson.GetBytes(upstream.lastBody, "messages.1.reasoning_content").String())
  228. require.Equal(t, "abc", gjson.Get(rec.Body.String(), "choices.0.message.thinking").String())
  229. require.Equal(t, "abc", gjson.Get(rec.Body.String(), "choices.0.message.reasoning_content").String())
  230. require.Equal(t, "final answer", gjson.Get(rec.Body.String(), "choices.0.message.content").String())
  231. require.Equal(t, int64(4), gjson.Get(rec.Body.String(), "usage.completion_tokens_details.reasoning_tokens").Int())
  232. }