From 8ad159a3bbc2da9f7432848a58c99bc2dafee227 Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:49:26 +0800 Subject: [PATCH] fix(ollama): preserve reasoning and tool-call context (#6605) --- relay/channel/ollama/dto.go | 18 ++-- relay/channel/ollama/relay-ollama.go | 137 +++++++++++++++++++-------- relay/channel/ollama/stream.go | 6 +- relay/channel/ollama/stream_test.go | 13 ++- 4 files changed, 118 insertions(+), 56 deletions(-) diff --git a/relay/channel/ollama/dto.go b/relay/channel/ollama/dto.go index 07aeb17a..80e1ecfb 100644 --- a/relay/channel/ollama/dto.go +++ b/relay/channel/ollama/dto.go @@ -5,12 +5,13 @@ import ( ) type OllamaChatMessage struct { - Role string `json:"role"` - Content string `json:"content,omitempty"` - Images []string `json:"images,omitempty"` - ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"` - ToolName string `json:"tool_name,omitempty"` - Thinking json.RawMessage `json:"thinking,omitempty"` + Role string `json:"role"` + Content string `json:"content,omitempty"` + Images []string `json:"images,omitempty"` + ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"` + ToolName string `json:"tool_name,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Thinking json.RawMessage `json:"thinking,omitempty"` } type OllamaToolFunction struct { @@ -25,6 +26,7 @@ type OllamaTool struct { } type OllamaToolCall struct { + ID string `json:"id,omitempty"` Function struct { Name string `json:"name"` Arguments interface{} `json:"arguments"` @@ -36,7 +38,7 @@ type OllamaChatRequest struct { Messages []OllamaChatMessage `json:"messages"` Tools interface{} `json:"tools,omitempty"` Format interface{} `json:"format,omitempty"` - Stream bool `json:"stream,omitempty"` + Stream bool `json:"stream"` Options map[string]any `json:"options,omitempty"` KeepAlive interface{} `json:"keep_alive,omitempty"` Think json.RawMessage `json:"think,omitempty"` @@ -48,7 +50,7 @@ type OllamaGenerateRequest struct { Suffix string `json:"suffix,omitempty"` Images []string `json:"images,omitempty"` Format interface{} `json:"format,omitempty"` - Stream bool `json:"stream,omitempty"` + Stream bool `json:"stream"` Options map[string]any `json:"options,omitempty"` KeepAlive interface{} `json:"keep_alive,omitempty"` Think json.RawMessage `json:"think,omitempty"` diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go index 874d2e9b..e517a1e6 100644 --- a/relay/channel/ollama/relay-ollama.go +++ b/relay/channel/ollama/relay-ollama.go @@ -1,7 +1,6 @@ package ollama import ( - "encoding/json" "fmt" "io" "net/http" @@ -19,24 +18,67 @@ import ( "github.com/samber/lo" ) +func toOllamaResponseFormat(responseFormat *dto.ResponseFormat) (any, error) { + if responseFormat == nil { + return nil, nil + } + switch responseFormat.Type { + case "json", "json_object": + return "json", nil + case "json_schema": + if len(responseFormat.JsonSchema) == 0 { + return nil, nil + } + var jsonSchema dto.FormatJsonSchema + if err := common.Unmarshal(responseFormat.JsonSchema, &jsonSchema); err != nil { + return nil, fmt.Errorf("invalid ollama response format: %w", err) + } + return jsonSchema.Schema, nil + default: + return nil, nil + } +} + func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaChatRequest, error) { + think := r.Think + if len(think) == 0 { + effort := r.ReasoningEffort + if len(r.Reasoning) > 0 { + var reasoning dto.Reasoning + if err := common.Unmarshal(r.Reasoning, &reasoning); err != nil { + return nil, fmt.Errorf("invalid ollama reasoning: %w", err) + } + effort = lo.CoalesceOrEmpty(reasoning.Effort, effort) + } + if effort != "" { + var thinkValue any + switch effort { + case "none": + thinkValue = false + case "low", "medium", "high", "max": + thinkValue = effort + default: + return nil, fmt.Errorf("unsupported ollama reasoning effort %q", effort) + } + var err error + think, err = common.Marshal(thinkValue) + if err != nil { + return nil, fmt.Errorf("marshal ollama think: %w", err) + } + } + } + chatReq := &OllamaChatRequest{ Model: r.Model, Stream: lo.FromPtrOr(r.Stream, false), Options: map[string]any{}, - Think: r.Think, + Think: think, } - if r.ResponseFormat != nil { - if r.ResponseFormat.Type == "json" { - chatReq.Format = "json" - } else if r.ResponseFormat.Type == "json_schema" { - if len(r.ResponseFormat.JsonSchema) > 0 { - var schema any - _ = json.Unmarshal(r.ResponseFormat.JsonSchema, &schema) - chatReq.Format = schema - } - } + format, err := toOllamaResponseFormat(r.ResponseFormat) + if err != nil { + return nil, err } + chatReq.Format = format // options mapping if r.Temperature != nil { @@ -68,12 +110,10 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam case []string: chatReq.Options["stop"] = v case []any: - arr := make([]string, 0, len(v)) - for _, i := range v { - if s, ok := i.(string); ok { - arr = append(arr, s) - } - } + arr := lo.FilterMap(v, func(item any, _ int) (string, bool) { + value, ok := item.(string) + return value, ok + }) if len(arr) > 0 { chatReq.Options["stop"] = arr } @@ -81,14 +121,20 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam } if len(r.Tools) > 0 { - tools := make([]OllamaTool, 0, len(r.Tools)) - for _, t := range r.Tools { - tools = append(tools, OllamaTool{Type: "function", Function: OllamaToolFunction{Name: t.Function.Name, Description: t.Function.Description, Parameters: t.Function.Parameters}}) - } - chatReq.Tools = tools + chatReq.Tools = lo.Map(r.Tools, func(tool dto.ToolCallRequest, _ int) OllamaTool { + return OllamaTool{ + Type: "function", + Function: OllamaToolFunction{ + Name: tool.Function.Name, + Description: tool.Function.Description, + Parameters: tool.Function.Parameters, + }, + } + }) } chatReq.Messages = make([]OllamaChatMessage, 0, len(r.Messages)) + toolNamesByCallID := make(map[string]string) for _, m := range r.Messages { var textBuilder strings.Builder var images []string @@ -117,8 +163,18 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam if len(images) > 0 { cm.Images = images } - if m.Role == "tool" && m.Name != nil { - cm.ToolName = *m.Name + if m.Role == "assistant" { + if reasoning, ok := lo.Coalesce(m.ReasoningContent, m.Reasoning); ok { + thinking, err := common.Marshal(*reasoning) + if err != nil { + return nil, fmt.Errorf("marshal ollama thinking: %w", err) + } + cm.Thinking = thinking + } + } + if m.Role == "tool" { + cm.ToolCallID = m.ToolCallId + cm.ToolName = lo.CoalesceOrEmpty(lo.FromPtr(m.Name), toolNamesByCallID[m.ToolCallId]) } if m.ToolCalls != nil && len(m.ToolCalls) > 0 { parsed := m.ParseToolCalls() @@ -127,15 +183,18 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam for _, tc := range parsed { var args interface{} if tc.Function.Arguments != "" { - _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) + _ = common.Unmarshal([]byte(tc.Function.Arguments), &args) } if args == nil { args = map[string]any{} } - oc := OllamaToolCall{} + oc := OllamaToolCall{ID: tc.ID} oc.Function.Name = tc.Function.Name oc.Function.Arguments = args calls = append(calls, oc) + if tc.ID != "" { + toolNamesByCallID[tc.ID] = tc.Function.Name + } } cm.ToolCalls = calls } @@ -175,15 +234,11 @@ func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGener gen.Suffix = s } } - if r.ResponseFormat != nil { - if r.ResponseFormat.Type == "json" { - gen.Format = "json" - } else if r.ResponseFormat.Type == "json_schema" { - var schema any - _ = json.Unmarshal(r.ResponseFormat.JsonSchema, &schema) - gen.Format = schema - } + format, err := toOllamaResponseFormat(r.ResponseFormat) + if err != nil { + return nil, err } + gen.Format = format if r.Temperature != nil { gen.Options["temperature"] = r.Temperature } @@ -212,12 +267,10 @@ func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGener case []string: gen.Options["stop"] = v case []any: - arr := make([]string, 0, len(v)) - for _, i := range v { - if s, ok := i.(string); ok { - arr = append(arr, s) - } - } + arr := lo.FilterMap(v, func(item any, _ int) (string, bool) { + value, ok := item.(string) + return value, ok + }) if len(arr) > 0 { gen.Options["stop"] = arr } @@ -510,7 +563,7 @@ func FetchOllamaVersion(baseURL, apiKey string) (string, error) { Version string `json:"version"` } - if err := json.Unmarshal(body, &versionResp); err != nil { + if err := common.Unmarshal(body, &versionResp); err != nil { return "", fmt.Errorf("解析响应失败: %v", err) } diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go index 20e36015..a0d7839f 100644 --- a/relay/channel/ollama/stream.go +++ b/relay/channel/ollama/stream.go @@ -58,8 +58,12 @@ func ollamaToolCallsToOpenAI(toolCalls []OllamaToolCall, startIndex int, include argBytes = []byte("{}") } } + toolCallID := tc.ID + if toolCallID == "" { + toolCallID = fmt.Sprintf("call_%d", startIndex) + } tr := dto.ToolCallResponse{ - ID: fmt.Sprintf("call_%d", startIndex), + ID: toolCallID, Type: "function", Function: dto.FunctionResponse{ Name: tc.Function.Name, diff --git a/relay/channel/ollama/stream_test.go b/relay/channel/ollama/stream_test.go index 8ba58b19..69aff1ce 100644 --- a/relay/channel/ollama/stream_test.go +++ b/relay/channel/ollama/stream_test.go @@ -21,12 +21,14 @@ func TestOllamaChatHandlerNonStreamToolCalls(t *testing.T) { gin.SetMode(gin.TestMode) tests := []struct { - name string - raw string + name string + raw string + wantID string }{ { - name: "compact json per-line parse path", - raw: `{"model":"llama3.1","created_at":"2026-05-27T12:00:00Z","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"get_weather","arguments":{"city":"Paris","days":0}}}]},"done":true,"done_reason":"stop","prompt_eval_count":5,"eval_count":7}`, + name: "compact json per-line parse path", + raw: `{"model":"llama3.1","created_at":"2026-05-27T12:00:00Z","message":{"role":"assistant","content":"","tool_calls":[{"id":"call_upstream","function":{"name":"get_weather","arguments":{"city":"Paris","days":0}}}]},"done":true,"done_reason":"stop","prompt_eval_count":5,"eval_count":7}`, + wantID: "call_upstream", }, { name: "pretty json fallback parse path", @@ -53,6 +55,7 @@ func TestOllamaChatHandlerNonStreamToolCalls(t *testing.T) { "prompt_eval_count": 5, "eval_count": 7 }`, + wantID: "call_0", }, } @@ -82,7 +85,7 @@ func TestOllamaChatHandlerNonStreamToolCalls(t *testing.T) { var toolCalls []dto.ToolCallResponse require.NoError(t, common.Unmarshal(out.Choices[0].Message.ToolCalls, &toolCalls)) require.Len(t, toolCalls, 1) - assert.NotEmpty(t, toolCalls[0].ID) + assert.Equal(t, tt.wantID, toolCalls[0].ID) assert.Equal(t, "function", toolCalls[0].Type) assert.Equal(t, "get_weather", toolCalls[0].Function.Name) assert.Nil(t, toolCalls[0].Index)