openai_gateway_ollama_cloud_cc_reasoning.go 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. package service
  2. import (
  3. "strconv"
  4. "strings"
  5. "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
  6. "github.com/tidwall/gjson"
  7. "github.com/tidwall/sjson"
  8. )
  9. // Ollama Cloud 的 OpenAI 兼容 /v1/chat/completions 把思维放在 reasoning / thinking,
  10. // 而 DeepSeek/OpenAI 客户端只认 reasoning_content。仅在 raw CC 直转路径上做 wire JSON
  11. // 双向补齐,不改 CC↔Responses / Anthropic / Grok 桥。
  12. func isOllamaCloudRawChatCompletionsAccount(account *Account) bool {
  13. if account == nil || account.Platform != PlatformOpenAI || account.Type != AccountTypeAPIKey {
  14. return false
  15. }
  16. mode, _ := account.Extra[openai_compat.ExtraKeyResponsesMode].(string)
  17. if openai_compat.NormalizeResponsesSupportMode(mode) != openai_compat.ResponsesSupportModeForceChatCompletions {
  18. return false
  19. }
  20. if accountHasOllamaCloudUsageExtra(account) {
  21. return true
  22. }
  23. if account.Credentials == nil {
  24. return false
  25. }
  26. baseURL, _ := account.Credentials["base_url"].(string)
  27. return isOllamaCloudBaseURL(baseURL)
  28. }
  29. func accountHasOllamaCloudUsageExtra(account *Account) bool {
  30. if account == nil || account.Extra == nil {
  31. return false
  32. }
  33. for _, key := range []string{
  34. OllamaCloudUsageSessionExtraKey,
  35. OllamaCloudUsageAutoRefreshExtraKey,
  36. OllamaCloudUsageSnapshotExtraKey,
  37. } {
  38. if _, ok := account.Extra[key]; ok {
  39. return true
  40. }
  41. }
  42. return false
  43. }
  44. // applyOllamaCloudRawChatCompletionsRequest 只做 Ollama Cloud reasoning 归一化;
  45. // max_tokens clamp 已解耦到独立钩子 clampOllamaCloudUpstreamMaxTokens,由出站方依次调用。
  46. func applyOllamaCloudRawChatCompletionsRequest(account *Account, body []byte) []byte {
  47. if !isOllamaCloudRawChatCompletionsAccount(account) || len(body) == 0 {
  48. return body
  49. }
  50. return normalizeOllamaCloudChatCompletionsRequest(body)
  51. }
  52. func applyOllamaCloudRawChatCompletionsResponse(account *Account, body []byte) []byte {
  53. if !isOllamaCloudRawChatCompletionsAccount(account) || len(body) == 0 {
  54. return body
  55. }
  56. return normalizeOllamaCloudChatCompletionsResponseJSON(body)
  57. }
  58. func applyOllamaCloudRawChatCompletionsSSELine(account *Account, line string) string {
  59. if !isOllamaCloudRawChatCompletionsAccount(account) || line == "" {
  60. return line
  61. }
  62. return normalizeOllamaCloudChatCompletionsSSELine(line)
  63. }
  64. func normalizeOllamaCloudChatCompletionsRequest(body []byte) []byte {
  65. if !gjson.ValidBytes(body) {
  66. return body
  67. }
  68. messages := gjson.GetBytes(body, "messages")
  69. if !messages.IsArray() {
  70. return body
  71. }
  72. updated := body
  73. changed := false
  74. for i, msg := range messages.Array() {
  75. if msg.Get("role").String() != "assistant" {
  76. continue
  77. }
  78. reasoningContent, ok := jsonNonEmptyString(msg.Get("reasoning_content"))
  79. if !ok {
  80. continue
  81. }
  82. if _, has := jsonNonEmptyString(msg.Get("reasoning")); has {
  83. continue
  84. }
  85. if _, has := jsonNonEmptyString(msg.Get("thinking")); has {
  86. continue
  87. }
  88. next, err := sjson.SetBytes(updated, "messages."+strconv.Itoa(i)+".reasoning", reasoningContent)
  89. if err != nil {
  90. return body
  91. }
  92. updated = next
  93. changed = true
  94. }
  95. if !changed {
  96. return body
  97. }
  98. return updated
  99. }
  100. func normalizeOllamaCloudChatCompletionsResponseJSON(body []byte) []byte {
  101. if !gjson.ValidBytes(body) {
  102. return body
  103. }
  104. choices := gjson.GetBytes(body, "choices")
  105. if !choices.IsArray() {
  106. return body
  107. }
  108. updated := body
  109. changed := false
  110. for i, choice := range choices.Array() {
  111. for _, container := range []string{"message", "delta"} {
  112. obj := choice.Get(container)
  113. if !obj.Exists() || !obj.IsObject() {
  114. continue
  115. }
  116. if obj.Get("reasoning_content").Exists() {
  117. continue
  118. }
  119. src, ok := jsonNonEmptyString(obj.Get("reasoning"))
  120. if !ok {
  121. src, ok = jsonNonEmptyString(obj.Get("thinking"))
  122. }
  123. if !ok {
  124. continue
  125. }
  126. next, err := sjson.SetBytes(updated, "choices."+strconv.Itoa(i)+"."+container+".reasoning_content", src)
  127. if err != nil {
  128. return body
  129. }
  130. updated = next
  131. changed = true
  132. }
  133. }
  134. if !changed {
  135. return body
  136. }
  137. return updated
  138. }
  139. func normalizeOllamaCloudChatCompletionsSSELine(line string) string {
  140. payload, ok := extractOpenAISSEDataLine(line)
  141. if !ok {
  142. return line
  143. }
  144. trimmed := strings.TrimSpace(payload)
  145. if trimmed == "" || trimmed == "[DONE]" {
  146. return line
  147. }
  148. rewritten := normalizeOllamaCloudChatCompletionsResponseJSON([]byte(payload))
  149. if string(rewritten) == payload {
  150. return line
  151. }
  152. prefixLen := len(line) - len(payload)
  153. if prefixLen < 0 {
  154. return line
  155. }
  156. return line[:prefixLen] + string(rewritten)
  157. }
  158. func jsonNonEmptyString(v gjson.Result) (string, bool) {
  159. if v.Type != gjson.String || v.Str == "" {
  160. return "", false
  161. }
  162. return v.Str, true
  163. }