From b30651a0ad285ed428cde0821d324300350ca809 Mon Sep 17 00:00:00 2001 From: alfadb Date: Sat, 22 Aug 2026 09:26:23 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(ollama):=20=E5=AF=B9=E9=BD=90=20Cloud?= =?UTF-8?q?=20Chat=20Completions=20=E6=80=9D=E7=BB=B4=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E4=B8=BA=20reasoning=5Fcontent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ollama Cloud 的 raw /v1/chat/completions 把思维放在 reasoning/thinking, DeepSeek 客户端只认 reasoning_content。仅对 openai+apikey+force_chat_completions 且具备 Ollama Cloud 信号的账号,在 raw CC 直转路径做 wire JSON 双向补齐。 --- .../openai_gateway_chat_completions_raw.go | 3 + ...penai_gateway_ollama_cloud_cc_reasoning.go | 173 +++++++++++ ..._gateway_ollama_cloud_cc_reasoning_test.go | 269 ++++++++++++++++++ 3 files changed, 445 insertions(+) create mode 100644 backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go create mode 100644 backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning_test.go 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()) +} From 86470628df5c30d6cec5ee176595b0d43a2c505a Mon Sep 17 00:00:00 2001 From: alfadb Date: Sat, 22 Aug 2026 12:38:05 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(ollama):=20=E5=AF=B9=20Ollama=20Cloud?= =?UTF-8?q?=20=E8=B4=A6=E5=8F=B7=20clamp=20max=5Ftokens=20=E4=B8=8A?= =?UTF-8?q?=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...penai_gateway_ollama_cloud_cc_reasoning.go | 3 +- .../openai_gateway_ollama_cloud_max_tokens.go | 83 +++++++++ ...ai_gateway_ollama_cloud_max_tokens_test.go | 160 ++++++++++++++++++ 3 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 backend/internal/service/openai_gateway_ollama_cloud_max_tokens.go create mode 100644 backend/internal/service/openai_gateway_ollama_cloud_max_tokens_test.go diff --git a/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go b/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go index 659ea4f5d2..f78a375860 100644 --- a/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go +++ b/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go @@ -51,7 +51,8 @@ func applyOllamaCloudRawChatCompletionsRequest(account *Account, body []byte) [] if !isOllamaCloudRawChatCompletionsAccount(account) || len(body) == 0 { return body } - return normalizeOllamaCloudChatCompletionsRequest(body) + body = normalizeOllamaCloudChatCompletionsRequest(body) + return clampOllamaCloudMaxTokens(account, body) } func applyOllamaCloudRawChatCompletionsResponse(account *Account, body []byte) []byte { diff --git a/backend/internal/service/openai_gateway_ollama_cloud_max_tokens.go b/backend/internal/service/openai_gateway_ollama_cloud_max_tokens.go new file mode 100644 index 0000000000..32a508481a --- /dev/null +++ b/backend/internal/service/openai_gateway_ollama_cloud_max_tokens.go @@ -0,0 +1,83 @@ +package service + +import ( + "encoding/json" + + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "go.uber.org/zap" +) + +// OllamaCloudMaxTokensCapExtraKey 是账号 extra 中的可选配置键,表示该 Ollama Cloud +// 账号输出 token 的 provider 级硬上限。用户可通过 admin 账号更新 API 的 extra 字段 +// 设置,覆盖默认值 ollamaCloudDefaultMaxTokensCap;0 或负数表示显式禁用 clamp。 +const OllamaCloudMaxTokensCapExtraKey = "ollama_max_tokens_cap" + +// ollamaCloudDefaultMaxTokensCap 是 Ollama Cloud 对输出 token 数的 provider 级硬上限 +// (约 65535),max_tokens 超过该值会被上游直接 400 拒绝;该上限与模型无关,不做模型过滤。 +const ollamaCloudDefaultMaxTokensCap = 65535 + +// 本文件的 clampOllamaCloudMaxTokens 被 +// applyOllamaCloudRawChatCompletionsRequest(openai_gateway_ollama_cloud_cc_reasoning.go) +// 调用,账号检测(isOllamaCloudRawChatCompletionsAccount)由调用方完成,此处不再重复判断。 + +// ollamaCloudMaxTokensCap 返回账号配置的 max_tokens 上限。账号为 nil 或 extra 中 +// 无该键时返回默认值;键值为数值类型(float64/int64/int/json.Number)时返回其整数 +// 值(0 或负数表示显式禁用 clamp);其它类型回退默认值。 +func ollamaCloudMaxTokensCap(account *Account) int64 { + if account == nil || account.Extra == nil { + return ollamaCloudDefaultMaxTokensCap + } + value, ok := account.Extra[OllamaCloudMaxTokensCapExtraKey] + if !ok { + return ollamaCloudDefaultMaxTokensCap + } + switch number := value.(type) { + case float64: + return int64(number) + case int64: + return number + case int: + return int64(number) + case json.Number: + parsed, err := number.Int64() + if err != nil { + return ollamaCloudDefaultMaxTokensCap + } + return parsed + default: + return ollamaCloudDefaultMaxTokensCap + } +} + +// clampOllamaCloudMaxTokens 把 body 中超过 cap 的 max_tokens / max_completion_tokens +// 单向压到 cap。cap <= 0 或 body 不是合法 JSON 时原样返回;sjson 出错时返回原始 body。 +// 有任一字段被 clamp 时记录一条 Debug 日志。 +func clampOllamaCloudMaxTokens(account *Account, body []byte) []byte { + cap := ollamaCloudMaxTokensCap(account) + if cap <= 0 || !gjson.ValidBytes(body) { + return body + } + clamped := false + out := body + for _, key := range []string{"max_tokens", "max_completion_tokens"} { + result := gjson.GetBytes(out, key) + if !result.Exists() || result.Type != gjson.Number || result.Int() <= cap { + continue + } + updated, err := sjson.SetBytes(out, key, cap) + if err != nil { + return body + } + out = updated + clamped = true + } + if clamped && account != nil { + logger.L().Debug("openai chat_completions raw: clamped max_tokens for ollama cloud account", + zap.Int64("account_id", account.ID), + zap.Int64("cap", cap), + ) + } + return out +} diff --git a/backend/internal/service/openai_gateway_ollama_cloud_max_tokens_test.go b/backend/internal/service/openai_gateway_ollama_cloud_max_tokens_test.go new file mode 100644 index 0000000000..6bbc2d7e5f --- /dev/null +++ b/backend/internal/service/openai_gateway_ollama_cloud_max_tokens_test.go @@ -0,0 +1,160 @@ +//go:build unit + +package service + +import ( + "encoding/json" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/stretchr/testify/require" +) + +// ollamaMaxTokensCapTestAccount 构造带自定义 cap 的 Ollama Cloud usage 账号。 +func ollamaMaxTokensCapTestAccount(id int64, cap any) *Account { + account := ollamaUsageAccount(id) + account.Extra[OllamaCloudMaxTokensCapExtraKey] = cap + return account +} + +func TestOllamaCloudMaxTokensClamp(t *testing.T) { + ollama := ollamaUsageAccount(101) + + tests := []struct { + name string + account *Account + body string + want string + raw bool // want 非法 JSON 时按原始字节比较 + }{ + { + name: "max_tokens above default cap is clamped", + account: ollama, + body: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`, + want: `{"model":"gpt-oss:120b-cloud","max_tokens":65535}`, + }, + { + name: "max_completion_tokens above default cap is clamped", + account: ollama, + body: `{"model":"gpt-oss:120b-cloud","max_completion_tokens":131072}`, + want: `{"model":"gpt-oss:120b-cloud","max_completion_tokens":65535}`, + }, + { + name: "both fields above cap are clamped", + account: ollama, + body: `{"model":"m","max_tokens":80000,"max_completion_tokens":90000}`, + want: `{"model":"m","max_tokens":65535,"max_completion_tokens":65535}`, + }, + { + name: "values at or below default cap are kept", + account: ollama, + body: `{"model":"m","max_tokens":65535,"max_completion_tokens":4096}`, + want: `{"model":"m","max_tokens":65535,"max_completion_tokens":4096}`, + }, + { + name: "custom extra cap is applied", + account: ollamaMaxTokensCapTestAccount(102, 32768), + body: `{"model":"m","max_tokens":50000}`, + want: `{"model":"m","max_tokens":32768}`, + }, + { + name: "extra cap zero disables clamping", + account: ollamaMaxTokensCapTestAccount(103, 0), + body: `{"model":"m","max_tokens":50000}`, + want: `{"model":"m","max_tokens":50000}`, + }, + { + name: "non-numeric extra cap falls back to default", + account: ollamaMaxTokensCapTestAccount(104, "abc"), + body: `{"model":"m","max_tokens":100000}`, + want: `{"model":"m","max_tokens":65535}`, + }, + { + name: "invalid json is left untouched", + account: ollama, + body: `{"model":"m","max_tokens":`, + want: `{"model":"m","max_tokens":`, + raw: true, + }, + { + name: "non-integer max_tokens is left untouched", + account: ollama, + body: `{"model":"m","max_tokens":1.5}`, + want: `{"model":"m","max_tokens":1.5}`, + }, + { + name: "missing max_tokens is left untouched", + account: ollama, + body: `{"model":"m"}`, + want: `{"model":"m"}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := clampOllamaCloudMaxTokens(test.account, []byte(test.body)) + if test.raw { + require.Equal(t, test.want, string(got)) + return + } + require.JSONEq(t, test.want, string(got)) + }) + } +} + +func TestOllamaCloudMaxTokensCap(t *testing.T) { + require.Equal(t, int64(65535), ollamaCloudMaxTokensCap(nil)) + require.Equal(t, int64(65535), ollamaCloudMaxTokensCap(ollamaUsageAccount(201))) + + tests := []struct { + name string + cap any + want int64 + }{ + {"float64", float64(32768), 32768}, + {"int", 40000, 40000}, + {"int64", int64(50000), 50000}, + {"json.Number", json.Number("60000"), 60000}, + {"json.Number invalid", json.Number("abc"), 65535}, + {"zero disables", 0, 0}, + {"negative disables", int64(-1), -1}, + {"string falls back", "abc", 65535}, + {"bool falls back", true, 65535}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + account := ollamaMaxTokensCapTestAccount(202, test.cap) + require.Equal(t, test.want, ollamaCloudMaxTokensCap(account)) + }) + } +} + +// TestApplyOllamaCloudRawChatCompletionsRequestClampsMaxTokens 验证 max_tokens clamp +// 已接入组合钩子 applyOllamaCloudRawChatCompletionsRequest,并遵循该钩子的账号判定门槛 +// (isOllamaCloudRawChatCompletionsAccount:platform openai + type apikey + +// force_chat_completions + ollama.com 或 Ollama usage extra)。 +func TestApplyOllamaCloudRawChatCompletionsRequestClampsMaxTokens(t *testing.T) { + body := []byte(`{"model":"deepseek-chat","max_tokens":100000}`) + + // Ollama Cloud 账号(ollama.com + force_chat_completions)→ clamp 到 65535。 + ollama := ollamaCloudRawChatCompletionsTestAccount() + require.JSONEq(t, `{"model":"deepseek-chat","max_tokens":65535}`, + string(applyOllamaCloudRawChatCompletionsRequest(ollama, body))) + + // 官方 DeepSeek(api.deepseek.com + force_chat_completions)→ 字节级不变。 + official := rawChatCompletionsTestAccount() + official.Credentials["base_url"] = "https://api.deepseek.com" + official.Extra = map[string]any{ + openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions), + } + require.Equal(t, body, applyOllamaCloudRawChatCompletionsRequest(official, body)) + + // ollama.com 但无 force_chat_completions(Extra 缺键)→ 不通过钩子判定门槛,字节级不变。 + noForce := ollamaCloudRawChatCompletionsTestAccount() + noForce.Extra = nil + require.Equal(t, body, applyOllamaCloudRawChatCompletionsRequest(noForce, body)) + + // 空 body → 原样返回。 + require.Equal(t, []byte(nil), applyOllamaCloudRawChatCompletionsRequest(ollama, nil)) + require.Equal(t, []byte{}, applyOllamaCloudRawChatCompletionsRequest(ollama, []byte{})) +}