diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 6713913939..70e623d2bf 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -157,6 +157,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( return nil, fmt.Errorf("normalize Grok chat reasoning effort: %w", err) } } + upstreamBody = applyOllamaCloudRawChatCompletionsRequest(account, upstreamBody) logger.L().Debug("openai chat_completions raw: forwarding without protocol conversion", zap.Int64("account_id", account.ID), @@ -334,6 +335,7 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( } } } + line = applyOllamaCloudRawChatCompletionsSSELine(account, line) writeLine(line) if line == "" { @@ -465,6 +467,7 @@ func (s *OpenAIGatewayService) bufferRawChatCompletions( upstreamRequestID := firstNonEmpty(requestID, resp.Header.Get("xai-request-id")) return nil, newGrokMissingUsageFailoverError(c, account, upstreamRequestID) } + respBody = applyOllamaCloudRawChatCompletionsResponse(account, respBody) if s.responseHeaderFilter != nil { responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) diff --git a/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go b/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go new file mode 100644 index 0000000000..659ea4f5d2 --- /dev/null +++ b/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go @@ -0,0 +1,173 @@ +package service + +import ( + "strconv" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// Ollama Cloud 的 OpenAI 兼容 /v1/chat/completions 把思维放在 reasoning / thinking, +// 而 DeepSeek/OpenAI 客户端只认 reasoning_content。仅在 raw CC 直转路径上做 wire JSON +// 双向补齐,不改 CC↔Responses / Anthropic / Grok 桥。 + +func isOllamaCloudRawChatCompletionsAccount(account *Account) bool { + if account == nil || account.Platform != PlatformOpenAI || account.Type != AccountTypeAPIKey { + return false + } + mode, _ := account.Extra[openai_compat.ExtraKeyResponsesMode].(string) + if openai_compat.NormalizeResponsesSupportMode(mode) != openai_compat.ResponsesSupportModeForceChatCompletions { + return false + } + if accountHasOllamaCloudUsageExtra(account) { + return true + } + if account.Credentials == nil { + return false + } + baseURL, _ := account.Credentials["base_url"].(string) + return isOllamaCloudBaseURL(baseURL) +} + +func accountHasOllamaCloudUsageExtra(account *Account) bool { + if account == nil || account.Extra == nil { + return false + } + for _, key := range []string{ + OllamaCloudUsageSessionExtraKey, + OllamaCloudUsageAutoRefreshExtraKey, + OllamaCloudUsageSnapshotExtraKey, + } { + if _, ok := account.Extra[key]; ok { + return true + } + } + return false +} + +func applyOllamaCloudRawChatCompletionsRequest(account *Account, body []byte) []byte { + if !isOllamaCloudRawChatCompletionsAccount(account) || len(body) == 0 { + return body + } + return normalizeOllamaCloudChatCompletionsRequest(body) +} + +func applyOllamaCloudRawChatCompletionsResponse(account *Account, body []byte) []byte { + if !isOllamaCloudRawChatCompletionsAccount(account) || len(body) == 0 { + return body + } + return normalizeOllamaCloudChatCompletionsResponseJSON(body) +} + +func applyOllamaCloudRawChatCompletionsSSELine(account *Account, line string) string { + if !isOllamaCloudRawChatCompletionsAccount(account) || line == "" { + return line + } + return normalizeOllamaCloudChatCompletionsSSELine(line) +} + +func normalizeOllamaCloudChatCompletionsRequest(body []byte) []byte { + if !gjson.ValidBytes(body) { + return body + } + messages := gjson.GetBytes(body, "messages") + if !messages.IsArray() { + return body + } + updated := body + changed := false + for i, msg := range messages.Array() { + if msg.Get("role").String() != "assistant" { + continue + } + reasoningContent, ok := jsonNonEmptyString(msg.Get("reasoning_content")) + if !ok { + continue + } + if _, has := jsonNonEmptyString(msg.Get("reasoning")); has { + continue + } + if _, has := jsonNonEmptyString(msg.Get("thinking")); has { + continue + } + next, err := sjson.SetBytes(updated, "messages."+strconv.Itoa(i)+".reasoning", reasoningContent) + if err != nil { + return body + } + updated = next + changed = true + } + if !changed { + return body + } + return updated +} + +func normalizeOllamaCloudChatCompletionsResponseJSON(body []byte) []byte { + if !gjson.ValidBytes(body) { + return body + } + choices := gjson.GetBytes(body, "choices") + if !choices.IsArray() { + return body + } + updated := body + changed := false + for i, choice := range choices.Array() { + for _, container := range []string{"message", "delta"} { + obj := choice.Get(container) + if !obj.Exists() || !obj.IsObject() { + continue + } + if obj.Get("reasoning_content").Exists() { + continue + } + src, ok := jsonNonEmptyString(obj.Get("reasoning")) + if !ok { + src, ok = jsonNonEmptyString(obj.Get("thinking")) + } + if !ok { + continue + } + next, err := sjson.SetBytes(updated, "choices."+strconv.Itoa(i)+"."+container+".reasoning_content", src) + if err != nil { + return body + } + updated = next + changed = true + } + } + if !changed { + return body + } + return updated +} + +func normalizeOllamaCloudChatCompletionsSSELine(line string) string { + payload, ok := extractOpenAISSEDataLine(line) + if !ok { + return line + } + trimmed := strings.TrimSpace(payload) + if trimmed == "" || trimmed == "[DONE]" { + return line + } + rewritten := normalizeOllamaCloudChatCompletionsResponseJSON([]byte(payload)) + if string(rewritten) == payload { + return line + } + prefixLen := len(line) - len(payload) + if prefixLen < 0 { + return line + } + return line[:prefixLen] + string(rewritten) +} + +func jsonNonEmptyString(v gjson.Result) (string, bool) { + if v.Type != gjson.String || v.Str == "" { + return "", false + } + return v.Str, true +} diff --git a/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning_test.go b/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning_test.go new file mode 100644 index 0000000000..ba8bdf714c --- /dev/null +++ b/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning_test.go @@ -0,0 +1,269 @@ +//go:build unit + +package service + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func ollamaCloudRawChatCompletionsTestAccount() *Account { + return &Account{ + ID: 143, + Name: "DeepSeek Ollama", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://ollama.com", + }, + Extra: map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions), + }, + } +} + +func TestIsOllamaCloudRawChatCompletionsAccount(t *testing.T) { + t.Parallel() + + t.Run("ollama.com + force_chat_completions", func(t *testing.T) { + t.Parallel() + require.True(t, isOllamaCloudRawChatCompletionsAccount(ollamaCloudRawChatCompletionsTestAccount())) + }) + + t.Run("extra usage signal without ollama host", func(t *testing.T) { + t.Parallel() + account := rawChatCompletionsTestAccount() + account.Credentials["base_url"] = "https://example.invalid/v1" + account.Extra = map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions), + OllamaCloudUsageSnapshotExtraKey: map[string]any{"status": "ok"}, + } + require.True(t, isOllamaCloudRawChatCompletionsAccount(account)) + }) + + t.Run("official DeepSeek", func(t *testing.T) { + t.Parallel() + account := rawChatCompletionsTestAccount() + account.Name = "DeepSeek" + account.Credentials["base_url"] = "https://api.deepseek.com" + account.Extra = map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions), + } + require.False(t, isOllamaCloudRawChatCompletionsAccount(account)) + }) + + t.Run("OpenCode Go extra", func(t *testing.T) { + t.Parallel() + account := rawChatCompletionsTestAccount() + account.Credentials["base_url"] = "https://opencode.ai/zen/go/v1" + account.Extra = map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions), + "opencode_go_usage_auto_refresh": true, + } + require.False(t, isOllamaCloudRawChatCompletionsAccount(account)) + }) + + t.Run("ollama.com without force_chat_completions", func(t *testing.T) { + t.Parallel() + account := ollamaCloudRawChatCompletionsTestAccount() + account.Extra = nil + require.False(t, isOllamaCloudRawChatCompletionsAccount(account)) + }) + + t.Run("anthropic ollama.com", func(t *testing.T) { + t.Parallel() + account := ollamaCloudRawChatCompletionsTestAccount() + account.Platform = PlatformAnthropic + require.False(t, isOllamaCloudRawChatCompletionsAccount(account)) + }) +} + +func TestNormalizeOllamaCloudChatCompletionsResponseJSON(t *testing.T) { + t.Parallel() + + t.Run("copies delta.reasoning to reasoning_content", func(t *testing.T) { + t.Parallel() + in := []byte(`{"choices":[{"delta":{"reasoning":"abc"}}]}`) + out := normalizeOllamaCloudChatCompletionsResponseJSON(in) + require.Equal(t, "abc", gjson.GetBytes(out, "choices.0.delta.reasoning").String()) + require.Equal(t, "abc", gjson.GetBytes(out, "choices.0.delta.reasoning_content").String()) + }) + + t.Run("copies message.thinking to reasoning_content", func(t *testing.T) { + t.Parallel() + in := []byte(`{"choices":[{"message":{"thinking":"abc"}}]}`) + out := normalizeOllamaCloudChatCompletionsResponseJSON(in) + require.Equal(t, "abc", gjson.GetBytes(out, "choices.0.message.thinking").String()) + require.Equal(t, "abc", gjson.GetBytes(out, "choices.0.message.reasoning_content").String()) + }) + + t.Run("does not overwrite existing reasoning_content", func(t *testing.T) { + t.Parallel() + in := []byte(`{"choices":[{"delta":{"reasoning":"new","reasoning_content":"old"}}]}`) + out := normalizeOllamaCloudChatCompletionsResponseJSON(in) + require.Equal(t, string(in), string(out)) + require.Equal(t, "old", gjson.GetBytes(out, "choices.0.delta.reasoning_content").String()) + }) + + t.Run("empty reasoning does not open reasoning_content", func(t *testing.T) { + t.Parallel() + in := []byte(`{"choices":[{"delta":{"reasoning":""}}]}`) + out := normalizeOllamaCloudChatCompletionsResponseJSON(in) + require.Equal(t, string(in), string(out)) + require.False(t, gjson.GetBytes(out, "choices.0.delta.reasoning_content").Exists()) + }) + + t.Run("empty thinking does not open reasoning_content", func(t *testing.T) { + t.Parallel() + in := []byte(`{"choices":[{"message":{"thinking":""}}]}`) + out := normalizeOllamaCloudChatCompletionsResponseJSON(in) + require.Equal(t, string(in), string(out)) + require.False(t, gjson.GetBytes(out, "choices.0.message.reasoning_content").Exists()) + }) + + t.Run("tool call chunk is unchanged", func(t *testing.T) { + t.Parallel() + in := []byte(`{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{}"}}]}}]}`) + out := normalizeOllamaCloudChatCompletionsResponseJSON(in) + require.Equal(t, string(in), string(out)) + }) +} + +func TestNormalizeOllamaCloudChatCompletionsRequest(t *testing.T) { + t.Parallel() + + 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":"{}"}}]}]}`) + out := normalizeOllamaCloudChatCompletionsRequest(in) + require.Equal(t, "prev", gjson.GetBytes(out, "messages.1.reasoning").String()) + require.Equal(t, "prev", gjson.GetBytes(out, "messages.1.reasoning_content").String()) + require.Equal(t, "", gjson.GetBytes(out, "messages.1.content").String()) + require.Equal(t, "get_weather", gjson.GetBytes(out, "messages.1.tool_calls.0.function.name").String()) + require.False(t, gjson.GetBytes(out, "messages.0.reasoning").Exists()) +} + +func TestApplyOllamaCloudRawChatCompletionsLeavesForeignAccountsUnchanged(t *testing.T) { + t.Parallel() + + reqBody := []byte(`{"messages":[{"role":"assistant","reasoning_content":"prev","content":""}]}`) + respBody := []byte(`{"choices":[{"delta":{"reasoning":"abc"}}]}`) + sseLine := `data: {"choices":[{"delta":{"reasoning":"abc"}}]}` + + official := rawChatCompletionsTestAccount() + official.Name = "DeepSeek" + official.Credentials["base_url"] = "https://api.deepseek.com" + official.Extra = map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions), + } + + opencode := rawChatCompletionsTestAccount() + opencode.Credentials["base_url"] = "https://opencode.ai/zen/go/v1" + opencode.Extra = map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions), + "opencode_go_usage_auto_refresh": true, + } + + for _, account := range []*Account{official, opencode} { + require.Equal(t, reqBody, applyOllamaCloudRawChatCompletionsRequest(account, reqBody)) + require.Equal(t, respBody, applyOllamaCloudRawChatCompletionsResponse(account, respBody)) + require.Equal(t, sseLine, applyOllamaCloudRawChatCompletionsSSELine(account, sseLine)) + } +} + +func TestNormalizeOllamaCloudChatCompletionsSSELine(t *testing.T) { + t.Parallel() + + before := `data: {"choices":[{"delta":{"reasoning":"abc"}}]}` + after := normalizeOllamaCloudChatCompletionsSSELine(before) + require.True(t, strings.HasPrefix(after, "data: ")) + payload := strings.TrimPrefix(after, "data: ") + require.Equal(t, "abc", gjson.Get(payload, "choices.0.delta.reasoning").String()) + require.Equal(t, "abc", gjson.Get(payload, "choices.0.delta.reasoning_content").String()) + require.Equal(t, "data: [DONE]", normalizeOllamaCloudChatCompletionsSSELine("data: [DONE]")) +} + +func TestForwardAsRawChatCompletions_OllamaCloudReasoningAliasStreaming(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"hello"}],"stream":true}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstreamBody := strings.Join([]string{ + `data: {"id":"chatcmpl_ollama","object":"chat.completion.chunk","model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}`, + "", + `data: {"id":"chatcmpl_ollama","object":"chat.completion.chunk","model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning":"abc"},"finish_reason":null}]}`, + "", + `data: {"id":"chatcmpl_ollama","object":"chat.completion.chunk","model":"deepseek-v4-pro","choices":[{"index":0,"delta":{"content":"final answer"},"finish_reason":null}]}`, + "", + `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}}}`, + "", + "data: [DONE]", + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_ollama_reasoning_stream"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + + result, err := svc.forwardAsRawChatCompletions(context.Background(), c, ollamaCloudRawChatCompletionsTestAccount(), body, "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 3, result.Usage.InputTokens) + require.Equal(t, 5, result.Usage.OutputTokens) + require.Contains(t, rec.Body.String(), `"reasoning":"abc"`) + require.Contains(t, rec.Body.String(), `"reasoning_content":"abc"`) + require.Contains(t, rec.Body.String(), `"content":"final answer"`) + require.Contains(t, rec.Body.String(), `"reasoning_tokens":4`) + require.Contains(t, rec.Body.String(), "data: [DONE]") +} + +func TestForwardAsRawChatCompletions_OllamaCloudThinkingAliasNonStreaming(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"hello"},{"role":"assistant","reasoning_content":"prev","content":""}],"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + 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}}}` + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_ollama_thinking_json"}}, + Body: io.NopCloser(strings.NewReader(upstreamJSON)), + }} + + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + + result, err := svc.forwardAsRawChatCompletions(context.Background(), c, ollamaCloudRawChatCompletionsTestAccount(), body, "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "prev", gjson.GetBytes(upstream.lastBody, "messages.1.reasoning").String()) + require.Equal(t, "prev", gjson.GetBytes(upstream.lastBody, "messages.1.reasoning_content").String()) + require.Equal(t, "abc", gjson.Get(rec.Body.String(), "choices.0.message.thinking").String()) + require.Equal(t, "abc", gjson.Get(rec.Body.String(), "choices.0.message.reasoning_content").String()) + require.Equal(t, "final answer", gjson.Get(rec.Body.String(), "choices.0.message.content").String()) + require.Equal(t, int64(4), gjson.Get(rec.Body.String(), "usage.completion_tokens_details.reasoning_tokens").Int()) +}