Merge pull request #6067 from alfadb/fix/ollama-cloud-cc-reasoning-content
fix(ollama): 补齐 Ollama Cloud Chat Completions 兼容(思维字段对齐 + max_tokens 上限 clamp)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
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
|
||||
}
|
||||
body = normalizeOllamaCloudChatCompletionsRequest(body)
|
||||
return clampOllamaCloudMaxTokens(account, 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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}))
|
||||
}
|
||||
Reference in New Issue
Block a user