WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+580
View File
@@ -0,0 +1,580 @@
package chat
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/Tencent/WeKnora/internal/models/provider"
"github.com/Tencent/WeKnora/internal/models/requestbudget"
"github.com/Tencent/WeKnora/internal/types"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
const anthropicVersion = "2023-06-01"
type AnthropicChat struct {
modelName string
modelID string
baseURL string
apiKey string
customHeaders map[string]string
}
type anthropicCacheControl struct {
Type string `json:"type"`
TTL string `json:"ttl,omitempty"`
}
type anthropicContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
CacheControl *anthropicCacheControl `json:"cache_control,omitempty"`
}
type anthropicMessage struct {
Role string `json:"role"`
Content any `json:"content"`
}
type anthropicRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
Stream bool `json:"stream,omitempty"`
System any `json:"system,omitempty"`
Messages []anthropicMessage `json:"messages"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
}
type anthropicResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
StopReason string `json:"stop_reason"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreationInputTokens *int `json:"cache_creation_input_tokens"`
CacheReadInputTokens *int `json:"cache_read_input_tokens"`
} `json:"usage"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
type anthropicStreamEvent struct {
Type string `json:"type"`
Message *struct {
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreationInputTokens *int `json:"cache_creation_input_tokens"`
CacheReadInputTokens *int `json:"cache_read_input_tokens"`
} `json:"usage"`
} `json:"message,omitempty"`
Delta *struct {
Type string `json:"type"`
Text string `json:"text"`
StopReason string `json:"stop_reason"`
} `json:"delta,omitempty"`
Usage *struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreationInputTokens *int `json:"cache_creation_input_tokens"`
CacheReadInputTokens *int `json:"cache_read_input_tokens"`
} `json:"usage,omitempty"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
func NewAnthropicChat(config *ChatConfig) (*AnthropicChat, error) {
if config.BaseURL != "" {
if err := secutils.ValidateURLForSSRF(config.BaseURL); err != nil {
return nil, fmt.Errorf("baseURL SSRF check failed: %w", err)
}
}
if strings.TrimSpace(config.APIKey) == "" {
return nil, fmt.Errorf("Anthropic provider: API key is required")
}
baseURL := strings.TrimRight(config.BaseURL, "/")
if baseURL == "" {
baseURL = provider.AnthropicBaseURL
}
return &AnthropicChat{
modelName: config.ModelName,
modelID: config.ModelID,
baseURL: baseURL,
apiKey: config.APIKey,
customHeaders: config.CustomHeaders,
}, nil
}
func (c *AnthropicChat) Chat(ctx context.Context, messages []Message, opts *ChatOptions) (*types.ChatResponse, error) {
ctx = requestbudget.WithModel(ctx, c.modelID)
if err := requestbudget.Check(ctx); err != nil {
return nil, err
}
reqBody := c.buildRequest(ctx, messages, opts)
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
ctx, cancel := withLLMTimeout(ctx, defaultChatTimeout)
defer cancel()
endpoint := c.endpoint()
if err := secutils.ValidateURLForSSRF(endpoint); err != nil {
return nil, fmt.Errorf("endpoint SSRF check failed: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("x-api-key", c.apiKey)
httpReq.Header.Set("anthropic-version", anthropicVersion)
secutils.ApplyCustomHeaders(httpReq, c.customHeaders)
resp, err := rawHTTPClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/event-stream") {
chatResp, err := parseAnthropicSSE(bytes.NewReader(body))
if err != nil {
return nil, err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, chatResp.Content)
}
logUsage(ctx, c.modelName, &chatResp.Usage)
return chatResp, nil
}
var chatResp anthropicResponse
if err := json.Unmarshal(body, &chatResp); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
if chatResp.Error != nil && chatResp.Error.Message != "" {
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, chatResp.Error.Message)
}
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
}
result := c.parseResponse(&chatResp)
logUsage(ctx, c.modelName, &result.Usage)
return result, nil
}
func (c *AnthropicChat) ChatStream(ctx context.Context, messages []Message, opts *ChatOptions) (<-chan types.StreamResponse, error) {
ctx = requestbudget.WithModel(ctx, c.modelID)
if err := requestbudget.Check(ctx); err != nil {
return nil, err
}
reqBody := c.buildRequest(ctx, messages, opts)
reqBody.Stream = true
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
endpoint := c.endpoint()
if err := secutils.ValidateURLForSSRF(endpoint); err != nil {
return nil, fmt.Errorf("endpoint SSRF check failed: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "text/event-stream")
httpReq.Header.Set("x-api-key", c.apiKey)
httpReq.Header.Set("anthropic-version", anthropicVersion)
secutils.ApplyCustomHeaders(httpReq, c.customHeaders)
resp, err := rawHTTPClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
}
streamChan := make(chan types.StreamResponse)
go processAnthropicStream(ctx, c.modelName, resp, streamChan)
return streamChan, nil
}
func (c *AnthropicChat) GetModelName() string {
return c.modelName
}
func (c *AnthropicChat) GetModelID() string {
return c.modelID
}
func (c *AnthropicChat) endpoint() string {
baseURL := strings.TrimRight(c.baseURL, "/")
if isAnthropicMessagesEndpoint(baseURL) {
return baseURL
}
if isAnthropicVersionedBaseURL(baseURL) {
return baseURL + "/messages"
}
return baseURL + "/v1/messages"
}
func isAnthropicMessagesEndpoint(baseURL string) bool {
u, err := url.Parse(baseURL)
if err != nil {
return false
}
path := strings.TrimRight(u.Path, "/")
return strings.HasSuffix(path, "/messages")
}
func isAnthropicVersionedBaseURL(baseURL string) bool {
u, err := url.Parse(baseURL)
if err != nil {
return false
}
path := strings.TrimRight(u.Path, "/")
return strings.HasSuffix(path, "/v1") || strings.HasSuffix(path, "/v1beta")
}
func (c *AnthropicChat) buildRequest(_ context.Context, messages []Message, opts *ChatOptions) anthropicRequest {
req := anthropicRequest{
Model: c.modelName,
MaxTokens: 1024,
Messages: make([]anthropicMessage, 0, len(messages)),
}
if opts != nil {
if opts.MaxTokens > 0 {
req.MaxTokens = opts.MaxTokens
} else if opts.MaxCompletionTokens > 0 {
req.MaxTokens = opts.MaxCompletionTokens
}
if opts.Temperature > 0 {
temperature := opts.Temperature
req.Temperature = &temperature
}
if opts.TopP > 0 {
topP := opts.TopP
req.TopP = &topP
}
}
var systemParts []string
for _, msg := range messages {
content := strings.TrimSpace(msg.Content)
if content == "" {
content = textFromMultiContent(msg.MultiContent)
}
if content == "" {
continue
}
switch msg.Role {
case "system":
systemParts = append(systemParts, content)
case "assistant":
req.Messages = append(req.Messages, anthropicMessage{Role: "assistant", Content: content})
case "user":
req.Messages = append(req.Messages, anthropicMessage{Role: "user", Content: content})
default:
req.Messages = append(req.Messages, anthropicMessage{Role: "user", Content: content})
}
}
systemText := strings.Join(systemParts, "\n\n")
retention := resolveCacheRetention(opts)
marker := cacheControlFor(retention, "1h")
if marker == nil {
req.System = systemText
return req
}
if systemText != "" {
req.System = []anthropicContentBlock{{
Type: "text",
Text: systemText,
CacheControl: &anthropicCacheControl{Type: marker.Type, TTL: marker.TTL},
}}
}
if len(req.Messages) > 0 {
last := &req.Messages[len(req.Messages)-1]
if text, ok := last.Content.(string); ok && text != "" {
last.Content = []anthropicContentBlock{{
Type: "text",
Text: text,
CacheControl: &anthropicCacheControl{Type: marker.Type, TTL: marker.TTL},
}}
}
}
return req
}
func textFromMultiContent(parts []MessageContentPart) string {
if len(parts) == 0 {
return ""
}
textParts := make([]string, 0, len(parts))
for _, part := range parts {
if part.Type == "text" && strings.TrimSpace(part.Text) != "" {
textParts = append(textParts, strings.TrimSpace(part.Text))
}
}
return strings.Join(textParts, "\n")
}
func (c *AnthropicChat) parseResponse(resp *anthropicResponse) *types.ChatResponse {
parts := make([]string, 0, len(resp.Content))
for _, part := range resp.Content {
if part.Type == "text" && part.Text != "" {
parts = append(parts, part.Text)
}
}
inputTokens := resp.Usage.InputTokens
outputTokens := resp.Usage.OutputTokens
cacheRead := valueOrZero(resp.Usage.CacheReadInputTokens)
cacheWrite := valueOrZero(resp.Usage.CacheCreationInputTokens)
promptTokens := inputTokens + cacheRead + cacheWrite
usage := types.TokenUsage{
PromptTokens: promptTokens,
CompletionTokens: outputTokens,
TotalTokens: promptTokens + outputTokens,
}
usage.SetPromptCacheUsage(cacheRead, cacheWrite, max(0, promptTokens-cacheRead),
resp.Usage.CacheReadInputTokens != nil || resp.Usage.CacheCreationInputTokens != nil)
return &types.ChatResponse{
Content: strings.Join(parts, ""),
FinishReason: resp.StopReason,
Usage: usage,
}
}
func parseAnthropicSSE(reader io.Reader) (*types.ChatResponse, error) {
sseReader := NewSSEReader(reader)
var contentParts []string
var finishReason string
var inputTokens int
var outputTokens int
var cacheReadTokens int
var cacheWriteTokens int
var cacheReported bool
for {
event, err := sseReader.ReadEvent()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("read SSE response: %w", err)
}
if event.Done {
break
}
if len(event.Data) == 0 {
continue
}
var streamEvent anthropicStreamEvent
if err := json.Unmarshal(event.Data, &streamEvent); err != nil {
return nil, fmt.Errorf("decode SSE response: %w", err)
}
if streamEvent.Error != nil && streamEvent.Error.Message != "" {
return nil, fmt.Errorf("API stream error: %s", streamEvent.Error.Message)
}
if streamEvent.Message != nil {
inputTokens = max(inputTokens, streamEvent.Message.Usage.InputTokens)
outputTokens = max(outputTokens, streamEvent.Message.Usage.OutputTokens)
cacheReadTokens, cacheWriteTokens, cacheReported = mergeAnthropicCacheCounters(
cacheReadTokens, cacheWriteTokens, cacheReported,
streamEvent.Message.Usage.CacheReadInputTokens,
streamEvent.Message.Usage.CacheCreationInputTokens,
)
}
if streamEvent.Delta != nil {
if streamEvent.Delta.Type == "text_delta" && streamEvent.Delta.Text != "" {
contentParts = append(contentParts, streamEvent.Delta.Text)
}
if streamEvent.Delta.StopReason != "" {
finishReason = streamEvent.Delta.StopReason
}
}
if streamEvent.Usage != nil {
inputTokens = max(inputTokens, streamEvent.Usage.InputTokens)
outputTokens = max(outputTokens, streamEvent.Usage.OutputTokens)
cacheReadTokens, cacheWriteTokens, cacheReported = mergeAnthropicCacheCounters(
cacheReadTokens, cacheWriteTokens, cacheReported,
streamEvent.Usage.CacheReadInputTokens,
streamEvent.Usage.CacheCreationInputTokens,
)
}
}
promptTokens := inputTokens + cacheReadTokens + cacheWriteTokens
usage := types.TokenUsage{
PromptTokens: promptTokens,
CompletionTokens: outputTokens,
TotalTokens: promptTokens + outputTokens,
}
usage.SetPromptCacheUsage(cacheReadTokens, cacheWriteTokens,
max(0, promptTokens-cacheReadTokens), cacheReported)
return &types.ChatResponse{
Content: strings.Join(contentParts, ""),
FinishReason: finishReason,
Usage: usage,
}, nil
}
func processAnthropicStream(ctx context.Context, model string, resp *http.Response, streamChan chan types.StreamResponse) {
defer close(streamChan)
defer resp.Body.Close()
sseReader := NewSSEReader(resp.Body)
var usage *types.TokenUsage
var finishReason string
for {
event, err := sseReader.ReadEvent()
if err != nil {
if err == io.EOF {
logUsage(ctx, model, usage)
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeAnswer,
Content: "",
Done: true,
Usage: usage,
FinishReason: finishReason,
}
} else {
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeError,
Content: err.Error(),
Done: true,
}
}
return
}
if event.Done {
logUsage(ctx, model, usage)
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeAnswer,
Content: "",
Done: true,
Usage: usage,
FinishReason: finishReason,
}
return
}
if len(event.Data) == 0 {
continue
}
var streamEvent anthropicStreamEvent
if err := json.Unmarshal(event.Data, &streamEvent); err != nil {
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeError,
Content: fmt.Sprintf("decode SSE response: %v", err),
Done: true,
}
return
}
if streamEvent.Error != nil && streamEvent.Error.Message != "" {
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeError,
Content: streamEvent.Error.Message,
Done: true,
}
return
}
if streamEvent.Message != nil {
usage = mergeAnthropicUsage(usage, streamEvent.Message.Usage.InputTokens,
streamEvent.Message.Usage.OutputTokens,
streamEvent.Message.Usage.CacheReadInputTokens,
streamEvent.Message.Usage.CacheCreationInputTokens)
}
if streamEvent.Delta != nil {
if streamEvent.Delta.StopReason != "" {
finishReason = streamEvent.Delta.StopReason
}
if streamEvent.Delta.Type == "text_delta" && streamEvent.Delta.Text != "" {
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeAnswer,
Content: streamEvent.Delta.Text,
Done: false,
}
}
}
if streamEvent.Usage != nil {
usage = mergeAnthropicUsage(usage, streamEvent.Usage.InputTokens,
streamEvent.Usage.OutputTokens,
streamEvent.Usage.CacheReadInputTokens,
streamEvent.Usage.CacheCreationInputTokens)
}
}
}
func mergeAnthropicUsage(
current *types.TokenUsage,
inputTokens, outputTokens int,
cacheRead, cacheWrite *int,
) *types.TokenUsage {
if current == nil {
current = &types.TokenUsage{}
}
read, write, reported := mergeAnthropicCacheCounters(
current.CacheReadTokens, current.CacheWriteTokens, current.CacheReported,
cacheRead, cacheWrite,
)
uncachedInput := max(0, current.PromptTokens-current.CacheReadTokens-current.CacheWriteTokens)
uncachedInput = max(uncachedInput, inputTokens)
current.PromptTokens = uncachedInput + read + write
current.CompletionTokens = max(current.CompletionTokens, outputTokens)
current.TotalTokens = current.PromptTokens + current.CompletionTokens
current.SetPromptCacheUsage(read, write, max(0, current.PromptTokens-read), reported)
return current
}
func mergeAnthropicCacheCounters(
currentRead, currentWrite int,
currentReported bool,
cacheRead, cacheWrite *int,
) (read, write int, reported bool) {
read = currentRead
write = currentWrite
reported = currentReported || cacheRead != nil || cacheWrite != nil
if cacheRead != nil {
read = max(read, *cacheRead)
}
if cacheWrite != nil {
write = max(write, *cacheWrite)
}
return read, write, reported
}
+331
View File
@@ -0,0 +1,331 @@
package chat
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Tencent/WeKnora/internal/models/provider"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAnthropicChat(t *testing.T) {
t.Setenv("SSRF_WHITELIST", "127.0.0.1")
var capturedHeaders http.Header
var capturedRequest anthropicRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/v1/messages", r.URL.Path)
capturedHeaders = r.Header.Clone()
require.NoError(t, json.NewDecoder(r.Body).Decode(&capturedRequest))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id":"msg_123",
"type":"message",
"role":"assistant",
"content":[{"type":"text","text":"hello"}],
"stop_reason":"end_turn",
"usage":{"input_tokens":3,"output_tokens":2}
}`))
}))
defer server.Close()
chat, err := NewAnthropicChat(&ChatConfig{
Source: types.ModelSourceRemote,
BaseURL: server.URL,
ModelName: "claude-sonnet-4-5",
APIKey: "test-key",
Provider: string(provider.ProviderAnthropic),
CustomHeaders: map[string]string{
"anthropic-beta": "test-beta",
},
})
require.NoError(t, err)
resp, err := chat.Chat(context.Background(), []Message{
{Role: "system", Content: "You are helpful."},
{Role: "user", Content: "Hi"},
}, &ChatOptions{MaxTokens: 7, Temperature: 0.2})
require.NoError(t, err)
assert.Equal(t, "test-key", capturedHeaders.Get("x-api-key"))
assert.Equal(t, anthropicVersion, capturedHeaders.Get("anthropic-version"))
assert.Equal(t, "test-beta", capturedHeaders.Get("anthropic-beta"))
assert.Equal(t, "claude-sonnet-4-5", capturedRequest.Model)
assert.Equal(t, 7, capturedRequest.MaxTokens)
systemText, systemCache := firstTextAndCache(t, capturedRequest.System)
assert.Equal(t, "You are helpful.", systemText)
require.Equal(t, "ephemeral", systemCache["type"])
require.Len(t, capturedRequest.Messages, 1)
assert.Equal(t, "user", capturedRequest.Messages[0].Role)
userText, userCache := firstTextAndCache(t, capturedRequest.Messages[0].Content)
assert.Equal(t, "Hi", userText)
require.Equal(t, "ephemeral", userCache["type"])
assert.Equal(t, "hello", resp.Content)
assert.Equal(t, "end_turn", resp.FinishReason)
assert.Equal(t, 3, resp.Usage.PromptTokens)
assert.Equal(t, 2, resp.Usage.CompletionTokens)
assert.Equal(t, 5, resp.Usage.TotalTokens)
}
func TestAnthropicChat_CacheUsage(t *testing.T) {
t.Setenv("SSRF_WHITELIST", "127.0.0.1")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id":"msg_cached","type":"message","role":"assistant",
"content":[{"type":"text","text":"hello"}],"stop_reason":"end_turn",
"usage":{"input_tokens":24,"output_tokens":2,"cache_creation_input_tokens":100,"cache_read_input_tokens":900}
}`))
}))
defer server.Close()
chat, err := NewAnthropicChat(&ChatConfig{
Source: types.ModelSourceRemote, BaseURL: server.URL, ModelName: "claude-sonnet-4-5",
APIKey: "test-key", Provider: string(provider.ProviderAnthropic),
})
require.NoError(t, err)
resp, err := chat.Chat(context.Background(), []Message{{Role: "user", Content: "Hi"}}, nil)
require.NoError(t, err)
assert.Equal(t, 1024, resp.Usage.PromptTokens)
assert.Equal(t, 900, resp.Usage.CacheReadTokens)
assert.Equal(t, 100, resp.Usage.CacheWriteTokens)
assert.Equal(t, 124, resp.Usage.CacheMissTokens)
assert.True(t, resp.Usage.CacheReported)
assert.Equal(t, types.PromptCacheStatusHit, resp.Usage.CacheStatus)
}
func TestAnthropicChat_FullEndpoint(t *testing.T) {
t.Setenv("SSRF_WHITELIST", "127.0.0.1")
var capturedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id":"msg_123",
"type":"message",
"role":"assistant",
"content":[{"type":"text","text":"hello"}],
"stop_reason":"end_turn",
"usage":{"input_tokens":3,"output_tokens":2}
}`))
}))
defer server.Close()
chat, err := NewAnthropicChat(&ChatConfig{
Source: types.ModelSourceRemote,
BaseURL: server.URL + "/api/proxy/forward",
ModelName: "gpt-5.5",
APIKey: "test-key",
Provider: string(provider.ProviderAnthropic),
})
require.NoError(t, err)
resp, err := chat.Chat(context.Background(), []Message{{Role: "user", Content: "Hi"}}, nil)
require.NoError(t, err)
assert.Equal(t, "/api/proxy/forward/v1/messages", capturedPath)
assert.Equal(t, "hello", resp.Content)
}
func TestAnthropicChat_MessagesEndpoint(t *testing.T) {
t.Setenv("SSRF_WHITELIST", "127.0.0.1")
var capturedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id":"msg_123",
"type":"message",
"role":"assistant",
"content":[{"type":"text","text":"hello"}],
"stop_reason":"end_turn",
"usage":{"input_tokens":3,"output_tokens":2}
}`))
}))
defer server.Close()
chat, err := NewAnthropicChat(&ChatConfig{
Source: types.ModelSourceRemote,
BaseURL: server.URL + "/api/v1/messages",
ModelName: "gpt-5.5",
APIKey: "test-key",
Provider: string(provider.ProviderAnthropic),
})
require.NoError(t, err)
resp, err := chat.Chat(context.Background(), []Message{{Role: "user", Content: "Hi"}}, nil)
require.NoError(t, err)
assert.Equal(t, "/api/v1/messages", capturedPath)
assert.Equal(t, "hello", resp.Content)
}
func TestAnthropicChat_SSEResponse(t *testing.T) {
t.Setenv("SSRF_WHITELIST", "127.0.0.1")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/v1/messages", r.URL.Path)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(`event: message_start
data: {"type":"message_start","message":{"usage":{"input_tokens":14,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":100}}}
event: content_block_delta
data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"pong"}}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}
event: message_stop
data: {"type":"message_stop"}
`))
}))
defer server.Close()
chat, err := NewAnthropicChat(&ChatConfig{
Source: types.ModelSourceRemote,
BaseURL: server.URL,
ModelName: "gpt-5.5",
APIKey: "test-key",
Provider: string(provider.ProviderAnthropic),
})
require.NoError(t, err)
resp, err := chat.Chat(context.Background(), []Message{{Role: "user", Content: "ping"}}, nil)
require.NoError(t, err)
assert.Equal(t, "pong", resp.Content)
assert.Equal(t, "end_turn", resp.FinishReason)
assert.Equal(t, 114, resp.Usage.PromptTokens)
assert.Equal(t, 5, resp.Usage.CompletionTokens)
assert.Equal(t, 119, resp.Usage.TotalTokens)
assert.Equal(t, 100, resp.Usage.CacheReadTokens)
assert.Equal(t, 14, resp.Usage.CacheMissTokens)
}
func TestAnthropicChat_ChatStream(t *testing.T) {
t.Setenv("SSRF_WHITELIST", "127.0.0.1")
var capturedRequest anthropicRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/v1/messages", r.URL.Path)
assert.Equal(t, "text/event-stream", r.Header.Get("Accept"))
require.NoError(t, json.NewDecoder(r.Body).Decode(&capturedRequest))
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(`event: message_start
data: {"type":"message_start","message":{"usage":{"input_tokens":14,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":100}}}
event: content_block_delta
data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"pong"}}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}
event: message_stop
data: {"type":"message_stop"}
`))
}))
defer server.Close()
chat, err := NewAnthropicChat(&ChatConfig{
Source: types.ModelSourceRemote,
BaseURL: server.URL,
ModelName: "gpt-5.5",
APIKey: "test-key",
Provider: string(provider.ProviderAnthropic),
})
require.NoError(t, err)
ch, err := chat.ChatStream(context.Background(), []Message{{Role: "user", Content: "ping"}}, nil)
require.NoError(t, err)
var chunks []types.StreamResponse
for chunk := range ch {
chunks = append(chunks, chunk)
}
require.Len(t, chunks, 2)
assert.True(t, capturedRequest.Stream)
assert.Equal(t, "pong", chunks[0].Content)
assert.False(t, chunks[0].Done)
assert.True(t, chunks[1].Done)
assert.Equal(t, "end_turn", chunks[1].FinishReason)
require.NotNil(t, chunks[1].Usage)
assert.Equal(t, 114, chunks[1].Usage.PromptTokens)
assert.Equal(t, 5, chunks[1].Usage.CompletionTokens)
assert.Equal(t, 119, chunks[1].Usage.TotalTokens)
assert.Equal(t, 100, chunks[1].Usage.CacheReadTokens)
assert.Equal(t, 14, chunks[1].Usage.CacheMissTokens)
}
func TestNewRemoteChat_AnthropicProvider(t *testing.T) {
chat, err := NewRemoteChat(&ChatConfig{
Source: types.ModelSourceRemote,
ModelName: "claude-sonnet-4-5",
APIKey: "test-key",
Provider: string(provider.ProviderAnthropic),
})
require.NoError(t, err)
_, ok := chat.(*AnthropicChat)
assert.True(t, ok)
}
func TestAnthropicChat_CacheRetentionNoneKeepsPlainStrings(t *testing.T) {
t.Setenv("SSRF_WHITELIST", "127.0.0.1")
var capturedRequest anthropicRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, json.NewDecoder(r.Body).Decode(&capturedRequest))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id":"msg_123","type":"message","role":"assistant",
"content":[{"type":"text","text":"hello"}],"stop_reason":"end_turn",
"usage":{"input_tokens":3,"output_tokens":2}
}`))
}))
defer server.Close()
chat, err := NewAnthropicChat(&ChatConfig{
Source: types.ModelSourceRemote, BaseURL: server.URL, ModelName: "claude-sonnet-4-5",
APIKey: "test-key", Provider: string(provider.ProviderAnthropic),
})
require.NoError(t, err)
_, err = chat.Chat(context.Background(), []Message{
{Role: "system", Content: "You are helpful."},
{Role: "user", Content: "Hi"},
}, &ChatOptions{CacheRetention: CacheRetentionNone})
require.NoError(t, err)
assert.Equal(t, "You are helpful.", capturedRequest.System)
assert.Equal(t, "Hi", capturedRequest.Messages[0].Content)
}
func firstTextAndCache(t *testing.T, v any) (string, map[string]any) {
t.Helper()
switch x := v.(type) {
case string:
t.Fatalf("expected cache_control content blocks, got plain string %q", x)
return "", nil
case []any:
require.NotEmpty(t, x)
block, ok := x[0].(map[string]any)
require.True(t, ok)
text, _ := block["text"].(string)
cache, _ := block["cache_control"].(map[string]any)
require.NotNil(t, cache)
return text, cache
default:
t.Fatalf("unexpected content type %T", v)
return "", nil
}
}
+195
View File
@@ -0,0 +1,195 @@
package chat
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/Tencent/WeKnora/internal/models/provider"
"github.com/Tencent/WeKnora/internal/models/utils/ollama"
"github.com/Tencent/WeKnora/internal/types"
)
// Tool represents a function/tool definition
type Tool struct {
Type string `json:"type"` // "function"
Function FunctionDef `json:"function"`
}
// FunctionDef represents a function definition
type FunctionDef struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
}
// ChatOptions 聊天选项
type ChatOptions struct {
Temperature float64 `json:"temperature"` // 温度参数
TopP float64 `json:"top_p"` // Top P 参数
Seed int `json:"seed"` // 随机种子
MaxTokens int `json:"max_tokens"` // 最大 token 数
MaxCompletionTokens int `json:"max_completion_tokens"` // 最大完成 token 数
FrequencyPenalty float64 `json:"frequency_penalty"` // 频率惩罚
PresencePenalty float64 `json:"presence_penalty"` // 存在惩罚
Thinking *bool `json:"thinking"` // 是否启用思考
Tools []Tool `json:"tools,omitempty"` // 可用工具列表
ToolChoice string `json:"tool_choice,omitempty"` // "auto", "required", "none", or specific tool
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` // 是否允许并行工具调用(默认 nil 表示由模型决定)
Format json.RawMessage `json:"format,omitempty"` // 响应格式定义
// PromptCacheKey is the provider routing key (OpenAI prompt_cache_key).
// Empty falls back to the session ID on the call context.
PromptCacheKey string `json:"-"`
// CacheRetention controls provider prompt-cache TTL. none disables cache
// markers; empty/short is the default 5-minute cache; long requests 1h/24h
// where the provider accepts it.
CacheRetention CacheRetention `json:"-"`
}
// MessageContentPart represents a part of multi-content message
type MessageContentPart struct {
Type string `json:"type"` // "text" or "image_url"
Text string `json:"text,omitempty"` // For type="text"
ImageURL *ImageURL `json:"image_url,omitempty"` // For type="image_url"
}
// ImageURL represents the image URL structure
type ImageURL struct {
URL string `json:"url"` // URL or base64 data URI
Detail string `json:"detail,omitempty"` // "auto", "low", "high"
}
// MessageKind marks messages the engine synthesized rather than received from
// the user or the model. Compaction needs to tell its own summary apart from a
// real user turn: a summary that looks like ordinary history gets fed back into
// the next summarization pass and degrades into a summary of a summary.
type MessageKind string
// MessageKindCompactionSummary marks the message that replaces compacted
// history. It carries the `user` role because that is where providers expect
// conversation history, so the role alone cannot identify it.
const MessageKindCompactionSummary MessageKind = "compaction_summary"
// Message 表示聊天消息
type Message struct {
Role string `json:"role"` // 角色:system, user, assistant, tool
Content string `json:"content"` // 消息内容
MultiContent []MessageContentPart `json:"multi_content,omitempty"` // 多内容消息(文本+图片)
Name string `json:"name,omitempty"` // Function/tool name (for tool role)
ToolCallID string `json:"tool_call_id,omitempty"` // Tool call ID (for tool role)
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // Tool calls (for assistant role)
Images []string `json:"images,omitempty"` // Image URLs for multimodal (only for current user message)
// ReasoningContent 是 assistant 推理类模型(DeepSeek thinking、小米 MiMo、vLLM reasoning 等)
// 上一轮输出的思考内容。部分供应商(MiMo、DeepSeek V3.2/V4 thinking 模式)要求多轮对话中
// 把 assistant 的 reasoning_content 原样回传,否则会以 400 拒绝请求;其他不要求的供应商
// 会忽略未知字段,无副作用。
ReasoningContent string `json:"reasoning_content,omitempty"`
// Kind is engine-internal bookkeeping. `json:"-"` keeps it off the wire:
// providers reject unknown message fields on some endpoints, and this one
// means nothing to them anyway.
Kind MessageKind `json:"-"`
}
// ToolCall represents a tool call in a message
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"` // "function"
Function FunctionCall `json:"function"`
ProviderMetadata types.ToolCallMetadata `json:"provider_metadata,omitempty"`
}
// FunctionCall represents a function call
type FunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"` // JSON string
}
// Chat 定义了聊天接口
type Chat interface {
// Chat 进行非流式聊天
Chat(ctx context.Context, messages []Message, opts *ChatOptions) (*types.ChatResponse, error)
// ChatStream 进行流式聊天
ChatStream(ctx context.Context, messages []Message, opts *ChatOptions) (<-chan types.StreamResponse, error)
// GetModelName 获取模型名称
GetModelName() string
// GetModelID 获取模型ID
GetModelID() string
}
type ChatConfig struct {
Source types.ModelSource
BaseURL string
ModelName string
APIKey string
ModelID string
Provider string
// MaxConcurrency caps concurrent background calls to this model; 0 falls
// back to the process-wide default (see limiter.GateN).
MaxConcurrency int
ExtraConfig map[string]string
// CustomHeaders 允许在调用远程 OpenAI 兼容 API 时附加自定义 HTTP 请求头(类似 OpenAI Python SDK 的 extra_headers)。
CustomHeaders map[string]string
AppID string
AppSecret string // 加密值,由工厂函数调用方传入,在 NewWeKnoraCloudChat 中使用前已解密
}
// ConfigFromModel 根据 types.Model 构造 ChatConfig。
// 保证生产路径(service 层根据 DB 中的模型配置拉起实例)和测试路径
// (handler 层根据前端表单临时拉起实例)走完全相同的字段映射,避免重复样板。
// appID / appSecret 是已经解密/解析好的 WeKnoraCloud 凭证,调用方负责传入。
func ConfigFromModel(m *types.Model, appID, appSecret string) *ChatConfig {
if m == nil {
return nil
}
return &ChatConfig{
ModelID: m.ID,
APIKey: m.Parameters.APIKey,
BaseURL: m.Parameters.BaseURL,
ModelName: m.Name,
Source: m.Source,
Provider: m.Parameters.Provider,
MaxConcurrency: m.Parameters.MaxConcurrency,
ExtraConfig: m.Parameters.ExtraConfig,
CustomHeaders: m.Parameters.CustomHeaders,
AppID: appID,
AppSecret: appSecret,
}
}
// NewChat 创建聊天实例
func NewChat(config *ChatConfig, ollamaService *ollama.OllamaService) (Chat, error) {
var c Chat
var err error
switch strings.ToLower(string(config.Source)) {
case string(types.ModelSourceLocal):
c, err = NewOllamaChat(config, ollamaService)
case string(types.ModelSourceRemote):
c, err = NewRemoteChat(config)
default:
return nil, fmt.Errorf("unsupported chat model source: %s", config.Source)
}
c, err = wrapChatRequestBudget(c, err)
c, err = wrapChatDebug(c, err)
c, err = wrapChatLangfuse(c, err)
// Outermost: hold the per-model concurrency slot only around the real
// provider round-trip, so the wait is excluded from debug/langfuse timing.
return wrapChatConcurrency(c, config.MaxConcurrency, err)
}
// NewRemoteChat 根据 provider 创建远程聊天实例。
// Anthropic 走独立的 Messages 协议实现;其余 OpenAI 兼容供应商统一由
// RemoteAPIChat 处理,provider 特定行为在构造时通过 providerAdapter 解析。
func NewRemoteChat(config *ChatConfig) (Chat, error) {
providerName := provider.ProviderName(config.Provider)
if providerName == "" {
providerName = provider.DetectProvider(config.BaseURL)
}
if providerName == provider.ProviderAnthropic {
return NewAnthropicChat(config)
}
return NewRemoteAPIChat(config)
}
@@ -0,0 +1,82 @@
package chat
import (
"context"
"github.com/Tencent/WeKnora/internal/models/limiter"
"github.com/Tencent/WeKnora/internal/types"
)
// Model provider budgets are the real bottleneck shared by every LLM-backed
// background stage (summary / question / graph / multimodal enrichment), which
// all target the same model. This governor caps concurrent calls per model at
// the client layer — the one place that sees all task types — instead of at the
// asynq queue layer, whose weights are scheduling priority rather than
// throttling.
//
// Only background (asynq worker) calls are throttled; interactive chat is left
// untouched (see types.IsBackgroundTask), so a document-ingestion storm cannot
// exhaust the provider yet user-facing latency is never gated behind the
// semaphore. The governor singleton itself lives in the limiter package so chat
// and vlm share the same limiter and per-model budget.
// concurrencyChat throttles background LLM calls through a per-model
// distributed semaphore. It is the outermost wrapper so the slot is held only
// around the actual provider round-trip and the wait time is excluded from the
// inner debug/langfuse timing.
type concurrencyChat struct {
inner Chat
// limit is this model's configured per-model background cap; 0 falls back
// to the process-wide default (see limiter.GateN).
limit int
}
func (w *concurrencyChat) GetModelName() string { return w.inner.GetModelName() }
func (w *concurrencyChat) GetModelID() string { return w.inner.GetModelID() }
func (w *concurrencyChat) Chat(ctx context.Context, messages []Message, opts *ChatOptions) (*types.ChatResponse, error) {
release := limiter.GateNamedN(ctx, w.inner.GetModelID(), w.inner.GetModelName(), w.limit)
defer release()
return w.inner.Chat(ctx, messages, opts)
}
func (w *concurrencyChat) ChatStream(ctx context.Context, messages []Message, opts *ChatOptions) (<-chan types.StreamResponse, error) {
release := limiter.GateNamedN(ctx, w.inner.GetModelID(), w.inner.GetModelName(), w.limit)
ch, err := w.inner.ChatStream(ctx, messages, opts)
if err != nil || ch == nil {
release()
return ch, err
}
// Hold the slot until the stream fully drains, then release. If the
// consumer abandons the stream (stops reading out) we would otherwise
// block forever on the send and never release the slot; select on
// ctx.Done() so a cancelled call frees its slot promptly, and drain the
// inner channel in the background so the upstream producer can exit.
out := make(chan types.StreamResponse)
go func() {
defer close(out)
defer release()
for resp := range ch {
select {
case out <- resp:
case <-ctx.Done():
go func() {
for range ch {
}
}()
return
}
}
}()
return out, nil
}
// wrapChatConcurrency installs the background concurrency governor as the
// outermost Chat decorator. It is always applied; when no limiter is installed
// or the call is interactive, the wrapper is a cheap passthrough.
func wrapChatConcurrency(c Chat, limit int, err error) (Chat, error) {
if err != nil || c == nil {
return c, err
}
return &concurrencyChat{inner: c, limit: limit}, nil
}
@@ -0,0 +1,93 @@
package chat
import (
"context"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/models/limiter"
"github.com/Tencent/WeKnora/internal/types"
)
// fakeChat is a minimal Chat whose stream emits continuously until ctx is done,
// so we can exercise the concurrency wrapper's slot lifecycle.
type fakeChat struct{ id string }
func (f *fakeChat) GetModelName() string { return f.id }
func (f *fakeChat) GetModelID() string { return f.id }
func (f *fakeChat) Chat(ctx context.Context, _ []Message, _ *ChatOptions) (*types.ChatResponse, error) {
return &types.ChatResponse{}, nil
}
func (f *fakeChat) ChatStream(ctx context.Context, _ []Message, _ *ChatOptions) (<-chan types.StreamResponse, error) {
ch := make(chan types.StreamResponse)
go func() {
defer close(ch)
for {
select {
case <-ctx.Done():
return
case ch <- types.StreamResponse{}:
}
}
}()
return ch, nil
}
// TestConcurrencyChatInteractiveNotGated verifies interactive calls bypass the
// governor entirely even at limit 1.
func TestConcurrencyChatInteractiveNotGated(t *testing.T) {
t.Cleanup(func() { limiter.SetGovernor(nil, 0) })
limiter.SetGovernor(limiter.NewLocalLimiter(), 1)
w := &concurrencyChat{inner: &fakeChat{id: "model-x"}}
// No background marker: neither call should be throttled.
if _, err := w.Chat(context.Background(), nil, nil); err != nil {
t.Fatalf("interactive chat: %v", err)
}
if _, err := w.Chat(context.Background(), nil, nil); err != nil {
t.Fatalf("interactive chat 2: %v", err)
}
}
// TestConcurrencyChatStreamReleasesOnAbandon verifies that when the consumer
// stops reading and cancels the context, the held slot is released rather than
// leaked (the #9 fix).
func TestConcurrencyChatStreamReleasesOnAbandon(t *testing.T) {
t.Cleanup(func() { limiter.SetGovernor(nil, 0) })
limiter.SetGovernor(limiter.NewLocalLimiter(), 1)
const id = "model-y"
w := &concurrencyChat{inner: &fakeChat{id: id}}
streamCtx, cancel := context.WithCancel(types.WithBackgroundTask(context.Background()))
out, err := w.ChatStream(streamCtx, nil, nil)
if err != nil {
t.Fatalf("stream: %v", err)
}
// Consume one item so the relay goroutine is running and holding the slot.
<-out
// The single slot is now held: a background acquire must block.
blocked := make(chan struct{})
go func() {
rel := limiter.Gate(types.WithBackgroundTask(context.Background()), id)
rel()
close(blocked)
}()
select {
case <-blocked:
t.Fatal("slot should be held by the live stream")
case <-time.After(50 * time.Millisecond):
}
// Abandon the stream: stop reading and cancel. The relay must release.
cancel()
select {
case <-blocked:
// released and reacquired successfully
case <-time.After(2 * time.Second):
t.Fatal("stream slot leaked: not released after consumer abandoned + cancelled")
}
}
@@ -0,0 +1,59 @@
package chat
import (
"testing"
"github.com/Tencent/WeKnora/internal/types"
)
// TestConfigFromModel 验证 ConfigFromModel 能把 types.Model 完整映射到 ChatConfig,
// 避免后续新加字段时只在生产路径或测试路径其中一侧更新导致功能漏配。
func TestConfigFromModel(t *testing.T) {
m := &types.Model{
ID: "model-123",
Name: "gpt-4o-mini",
Source: types.ModelSourceRemote,
Parameters: types.ModelParameters{
BaseURL: "https://api.example.com/v1",
APIKey: "sk-xxx",
Provider: "openai",
ExtraConfig: map[string]string{
"deployment": "prod",
},
CustomHeaders: map[string]string{
"X-Gateway-Token": "abc",
"X-Trace-ID": "t-1",
},
},
}
cfg := ConfigFromModel(m, "app-id", "app-secret")
if cfg == nil {
t.Fatal("ConfigFromModel returned nil")
}
if cfg.ModelID != "model-123" || cfg.ModelName != "gpt-4o-mini" {
t.Errorf("model identity not propagated: %+v", cfg)
}
if cfg.BaseURL != "https://api.example.com/v1" || cfg.APIKey != "sk-xxx" {
t.Errorf("connection fields not propagated: %+v", cfg)
}
if cfg.Provider != "openai" {
t.Errorf("provider not propagated: %q", cfg.Provider)
}
if cfg.ExtraConfig["deployment"] != "prod" {
t.Errorf("ExtraConfig not propagated: %+v", cfg.ExtraConfig)
}
if cfg.CustomHeaders["X-Gateway-Token"] != "abc" ||
cfg.CustomHeaders["X-Trace-ID"] != "t-1" {
t.Errorf("CustomHeaders not propagated: %+v", cfg.CustomHeaders)
}
if cfg.AppID != "app-id" || cfg.AppSecret != "app-secret" {
t.Errorf("cloud credentials not propagated: %+v", cfg)
}
}
func TestConfigFromModel_Nil(t *testing.T) {
if got := ConfigFromModel(nil, "", ""); got != nil {
t.Fatalf("expected nil for nil model, got %+v", got)
}
}
+105
View File
@@ -0,0 +1,105 @@
package chat
import (
"encoding/base64"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
)
// resolveImageURLForLLM converts stored image paths to a format that LLM APIs can consume.
// - data: URIs and http(s):// URLs are returned as-is.
// - resource:// and provider-backed local paths are read through the
// application resolver and converted to base64 data URIs.
func resolveImageURLForLLM(imageURL string) string {
if strings.HasPrefix(imageURL, "data:") || strings.HasPrefix(imageURL, "http://") || strings.HasPrefix(imageURL, "https://") {
return imageURL
}
if isApplicationStoredImage(imageURL) {
data := readLocalStorageBytes(imageURL)
if data != nil {
mime := http.DetectContentType(data)
return fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(data))
}
}
return imageURL
}
// resolveImageURLForOllama converts stored image paths to raw bytes for the Ollama API.
func resolveImageURLForOllama(imageURL string) []byte {
if strings.HasPrefix(imageURL, "data:") {
idx := strings.Index(imageURL, ";base64,")
if idx < 0 {
return nil
}
decoded, err := base64.StdEncoding.DecodeString(imageURL[idx+8:])
if err != nil {
return nil
}
return decoded
}
if isApplicationStoredImage(imageURL) {
return readLocalStorageBytes(imageURL)
}
return nil
}
func isApplicationStoredImage(imageURL string) bool {
return strings.HasPrefix(imageURL, "resource://") ||
strings.HasPrefix(imageURL, "local://") ||
strings.HasPrefix(imageURL, "storage://")
}
// LocalImageResolver, when set by the application layer at startup, resolves a
// resource:// or provider storage URL to bytes using the owning tenant's
// storage config.
// Stored local:// URLs are relative to the storage base dir and do NOT encode
// the tenant's configured PathPrefix, so a plain env-based join would miss the
// prefix. When nil (e.g. in tests), callers fall back to the env-based
// LOCAL_STORAGE_BASE_DIR resolution below.
var LocalImageResolver func(storageURL string) ([]byte, bool)
// readLocalStorageBytes resolves a local:// storage path to disk bytes.
func readLocalStorageBytes(storagePath string) []byte {
if LocalImageResolver != nil {
if data, ok := LocalImageResolver(storagePath); ok {
return data
}
}
relPath := strings.TrimPrefix(storagePath, "local://")
baseDir := os.Getenv("LOCAL_STORAGE_BASE_DIR")
if baseDir == "" {
baseDir = "/data/files"
}
localPath := filepath.Join(baseDir, filepath.FromSlash(relPath))
data, err := os.ReadFile(localPath)
if err != nil {
log.Printf("[image-resolve] failed to read local file %s: %v", localPath, err)
return nil
}
return data
}
// isMultimodalNotSupportedError checks if an error indicates the model does not
// support multimodal/image input.
func isMultimodalNotSupportedError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return (strings.Contains(msg, "multimodal") || strings.Contains(msg, "image") || strings.Contains(msg, "vision")) &&
(strings.Contains(msg, "not support") || strings.Contains(msg, "unsupported") || strings.Contains(msg, "400"))
}
// stripImagesFromMessages returns a copy of messages with all image data removed.
func stripImagesFromMessages(messages []Message) []Message {
cleaned := make([]Message, len(messages))
for i, msg := range messages {
cleaned[i] = msg
cleaned[i].Images = nil
}
return cleaned
}
@@ -0,0 +1,224 @@
package chat
import (
"strings"
"unicode/utf8"
)
// jsonFieldExtractor extracts a specific string field value from streaming JSON fragments.
// It processes incremental JSON argument chunks from LLM tool calls.
//
// Example: for fieldName="answer", expected JSON format: {"answer":"...content..."}
// The extractor uses a simple state machine to skip the JSON prefix and extract the string value.
type jsonFieldExtractor struct {
fieldName string // the JSON field name to extract (e.g. "answer", "thought")
buffer string // accumulated full arguments string
valueStart int // byte offset where the field value starts (-1 if not found yet)
lastEmit int // byte offset of the last emitted position within the value
done bool // whether we've seen the closing quote
}
// newJSONFieldExtractor creates a new extractor instance for the given field name
func newJSONFieldExtractor(fieldName string) *jsonFieldExtractor {
return &jsonFieldExtractor{
fieldName: fieldName,
valueStart: -1,
lastEmit: 0,
}
}
// Feed processes a new argument delta and returns any new content to emit.
// Returns empty string if no new content is available yet.
func (e *jsonFieldExtractor) Feed(argsDelta string) string {
if e.done {
return ""
}
e.buffer += argsDelta
// If we haven't found the value start yet, try to find it
if e.valueStart < 0 {
idx := findFieldValueStart(e.buffer, e.fieldName)
if idx < 0 {
return "" // Haven't seen the value start yet
}
e.valueStart = idx
e.lastEmit = 0
}
// Extract new content from the value portion
valueContent := e.buffer[e.valueStart:]
// Find how far we can safely emit (stop before potential incomplete escape at the end)
safeEnd, finished := findSafeEnd(valueContent, e.lastEmit)
if safeEnd <= e.lastEmit {
if finished {
e.done = true
}
return ""
}
// Extract the new chunk and unescape JSON string escapes
rawChunk := valueContent[e.lastEmit:safeEnd]
unescaped := unescapeJSONString(rawChunk)
e.lastEmit = safeEnd
if finished {
e.done = true
}
return unescaped
}
// IsDone returns whether the extractor has finished (closing quote found)
func (e *jsonFieldExtractor) IsDone() bool {
return e.done
}
// findFieldValueStart finds the byte offset where the field's string value content begins
// (after the opening quote of the value). Returns -1 if not found.
func findFieldValueStart(buf string, fieldName string) int {
// Look for "fieldName" key followed by colon and opening quote
key := `"` + fieldName + `"`
idx := strings.Index(buf, key)
if idx < 0 {
return -1
}
// Skip past the key
pos := idx + len(key)
// Skip whitespace and colon
for pos < len(buf) {
ch := buf[pos]
if ch == ':' {
pos++
continue
}
if ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' {
pos++
continue
}
if ch == '"' {
// Found the opening quote of the value
return pos + 1
}
// Unexpected character
return -1
}
return -1 // Haven't seen the opening quote yet
}
// findSafeEnd finds the safe end position for emission within the value content.
// It scans from lastEmit forward, handling escape sequences.
// Returns (safeEnd, finished) where finished=true if the closing quote was found.
func findSafeEnd(value string, from int) (int, bool) {
i := from
for i < len(value) {
ch := value[i]
if ch == '\\' {
// Escape sequence - need at least 2 bytes
if i+1 >= len(value) {
// Incomplete escape at end, stop before it
return i, false
}
nextCh := value[i+1]
if nextCh == 'u' {
// Unicode escape \uXXXX - need 6 bytes total
if i+5 >= len(value) {
return i, false
}
i += 6
} else {
// Simple escape: \", \\, \n, \t, \r, \/, \b, \f
i += 2
}
} else if ch == '"' {
// Closing quote of the JSON string value
return i, true
} else {
// Regular character - handle multi-byte UTF-8
_, size := utf8.DecodeRuneInString(value[i:])
if size == 0 {
size = 1
}
i += size
}
}
return i, false
}
// unescapeJSONString converts JSON string escape sequences to their actual characters
func unescapeJSONString(s string) string {
if !strings.ContainsRune(s, '\\') {
return s
}
var b strings.Builder
b.Grow(len(s))
i := 0
for i < len(s) {
if s[i] == '\\' && i+1 < len(s) {
switch s[i+1] {
case '"':
b.WriteByte('"')
i += 2
case '\\':
b.WriteByte('\\')
i += 2
case '/':
b.WriteByte('/')
i += 2
case 'n':
b.WriteByte('\n')
i += 2
case 'r':
b.WriteByte('\r')
i += 2
case 't':
b.WriteByte('\t')
i += 2
case 'b':
b.WriteByte('\b')
i += 2
case 'f':
b.WriteByte('\f')
i += 2
case 'u':
// Unicode escape \uXXXX
if i+5 < len(s) {
// Parse hex digits
hexStr := s[i+2 : i+6]
var codepoint int
for _, h := range hexStr {
codepoint <<= 4
switch {
case h >= '0' && h <= '9':
codepoint += int(h - '0')
case h >= 'a' && h <= 'f':
codepoint += int(h-'a') + 10
case h >= 'A' && h <= 'F':
codepoint += int(h-'A') + 10
}
}
b.WriteRune(rune(codepoint))
i += 6
} else {
b.WriteByte(s[i])
i++
}
default:
b.WriteByte(s[i])
i++
}
} else {
b.WriteByte(s[i])
i++
}
}
return b.String()
}
@@ -0,0 +1,165 @@
package chat
import (
"testing"
)
func TestJSONFieldExtractor_Basic(t *testing.T) {
e := newJSONFieldExtractor("answer")
// Simulate streaming JSON: {"answer":"Hello world"}
got := ""
got += e.Feed(`{"answer":"`)
got += e.Feed(`Hello`)
got += e.Feed(` world`)
got += e.Feed(`"}`)
if got != "Hello world" {
t.Errorf("expected 'Hello world', got %q", got)
}
if !e.IsDone() {
t.Error("expected extractor to be done")
}
}
func TestJSONFieldExtractor_WithEscapes(t *testing.T) {
e := newJSONFieldExtractor("answer")
// Simulate: {"answer":"line1\nline2 and \"quoted\""}
got := ""
got += e.Feed(`{"answer":"line1\nline2`)
got += e.Feed(` and \"quoted`)
got += e.Feed(`\""}`)
expected := "line1\nline2 and \"quoted\""
if got != expected {
t.Errorf("expected %q, got %q", expected, got)
}
}
func TestJSONFieldExtractor_OneChunk(t *testing.T) {
e := newJSONFieldExtractor("answer")
got := e.Feed(`{"answer":"complete answer here"}`)
if got != "complete answer here" {
t.Errorf("expected 'complete answer here', got %q", got)
}
if !e.IsDone() {
t.Error("expected extractor to be done")
}
}
func TestJSONFieldExtractor_SmallChunks(t *testing.T) {
e := newJSONFieldExtractor("answer")
// Very small chunks
got := ""
chunks := []string{`{`, `"`, `a`, `n`, `s`, `w`, `e`, `r`, `"`, `:`, `"`, `H`, `i`, `"`, `}`}
for _, c := range chunks {
got += e.Feed(c)
}
if got != "Hi" {
t.Errorf("expected 'Hi', got %q", got)
}
}
func TestJSONFieldExtractor_Markdown(t *testing.T) {
e := newJSONFieldExtractor("answer")
got := ""
got += e.Feed(`{"answer":"# Title\n\n`)
got += e.Feed(`This is **bold** and `)
got += e.Feed(`*italic* text.\n\n`)
got += e.Feed(`- item 1\n- item 2`)
got += e.Feed(`"}`)
expected := "# Title\n\nThis is **bold** and *italic* text.\n\n- item 1\n- item 2"
if got != expected {
t.Errorf("expected %q, got %q", expected, got)
}
}
func TestJSONFieldExtractor_UnicodeEscape(t *testing.T) {
e := newJSONFieldExtractor("answer")
got := ""
got += e.Feed(`{"answer":"Hello \u4e16\u754c`)
got += e.Feed(`"}`)
expected := "Hello 世界"
if got != expected {
t.Errorf("expected %q, got %q", expected, got)
}
}
func TestJSONFieldExtractor_IncompleteEscapeAtBoundary(t *testing.T) {
e := newJSONFieldExtractor("answer")
// Escape sequence split across chunks
got := ""
got += e.Feed(`{"answer":"before\`)
got += e.Feed(`nafter"}`)
expected := "before\nafter"
if got != expected {
t.Errorf("expected %q, got %q", expected, got)
}
}
func TestJSONFieldExtractor_WhitespaceInJSON(t *testing.T) {
e := newJSONFieldExtractor("answer")
// Whitespace around colon
got := e.Feed(`{ "answer" : "content here" }`)
if got != "content here" {
t.Errorf("expected 'content here', got %q", got)
}
}
func TestJSONFieldExtractor_EmptyAnswer(t *testing.T) {
e := newJSONFieldExtractor("answer")
got := e.Feed(`{"answer":""}`)
if got != "" {
t.Errorf("expected empty string, got %q", got)
}
if !e.IsDone() {
t.Error("expected extractor to be done")
}
}
// Test extracting "thought" field (for thinking tool)
func TestJSONFieldExtractor_ThoughtField(t *testing.T) {
e := newJSONFieldExtractor("thought")
got := ""
got += e.Feed(`{"thought":"Let me analyze`)
got += e.Feed(` the problem step by step`)
got += e.Feed(`","next_thought_needed":true,"thought_number":1,"total_thoughts":3}`)
expected := "Let me analyze the problem step by step"
if got != expected {
t.Errorf("expected %q, got %q", expected, got)
}
if !e.IsDone() {
t.Error("expected extractor to be done")
}
}
func TestJSONFieldExtractor_ThoughtFieldWithEscapes(t *testing.T) {
e := newJSONFieldExtractor("thought")
got := ""
got += e.Feed(`{"thought":"Step 1:\n- Analyze the query\n- `)
got += e.Feed(`Search for \"relevant\" info`)
got += e.Feed(`","thought_number":1}`)
expected := "Step 1:\n- Analyze the query\n- Search for \"relevant\" info"
if got != expected {
t.Errorf("expected %q, got %q", expected, got)
}
}
+249
View File
@@ -0,0 +1,249 @@
package chat
import (
"context"
"time"
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
"github.com/Tencent/WeKnora/internal/types"
)
// langfuseChat wraps a Chat implementation and emits a Langfuse generation
// observation for every Chat/ChatStream call, capturing prompt, response and
// token usage. The wrapper is only installed when the Langfuse manager is
// enabled, so there is no cost for deployments that don't use Langfuse.
type langfuseChat struct {
inner Chat
}
func (l *langfuseChat) GetModelName() string { return l.inner.GetModelName() }
func (l *langfuseChat) GetModelID() string { return l.inner.GetModelID() }
func (l *langfuseChat) Chat(ctx context.Context, messages []Message, opts *ChatOptions) (*types.ChatResponse, error) {
mgr := langfuse.GetManager()
if !mgr.Enabled() {
return l.inner.Chat(ctx, messages, opts)
}
purpose, prefixFingerprint := types.LLMCallMetadataFromContext(ctx)
genCtx, gen := mgr.StartGeneration(ctx, langfuse.GenerationOptions{
Name: "chat.completion",
Model: l.inner.GetModelName(),
Input: buildLangfuseMessages(messages),
ModelParameters: buildLangfuseModelParams(opts),
Metadata: map[string]interface{}{
"model_id": l.inner.GetModelID(),
"streaming": false,
"has_tools": opts != nil && len(opts.Tools) > 0,
"call_purpose": purpose,
"prompt_prefix_fingerprint": prefixFingerprint,
},
})
resp, err := l.inner.Chat(genCtx, messages, opts)
var usage *langfuse.TokenUsage
var output interface{}
if resp != nil {
usage = convertUsage(&resp.Usage)
output = buildLangfuseGenerationOutput(
resp.Content, resp.ReasoningContent, resp.FinishReason, resp.ToolCalls,
)
}
gen.Finish(output, usage, err)
return resp, err
}
func (l *langfuseChat) ChatStream(ctx context.Context, messages []Message, opts *ChatOptions) (<-chan types.StreamResponse, error) {
mgr := langfuse.GetManager()
if !mgr.Enabled() {
return l.inner.ChatStream(ctx, messages, opts)
}
purpose, prefixFingerprint := types.LLMCallMetadataFromContext(ctx)
genCtx, gen := mgr.StartGeneration(ctx, langfuse.GenerationOptions{
Name: "chat.completion.stream",
Model: l.inner.GetModelName(),
Input: buildLangfuseMessages(messages),
ModelParameters: buildLangfuseModelParams(opts),
Metadata: map[string]interface{}{
"model_id": l.inner.GetModelID(),
"streaming": true,
"has_tools": opts != nil && len(opts.Tools) > 0,
"call_purpose": purpose,
"prompt_prefix_fingerprint": prefixFingerprint,
},
})
ch, err := l.inner.ChatStream(genCtx, messages, opts)
if err != nil {
gen.Finish(nil, nil, err)
return ch, err
}
if ch == nil {
gen.Finish(nil, nil, nil)
return nil, nil
}
wrapped := make(chan types.StreamResponse)
go func() {
defer close(wrapped)
var contentBuf []byte
var reasoningBuf []byte
var usage *types.TokenUsage
var toolCalls []types.LLMToolCall
var finishReason string
var firstToken bool
for resp := range ch {
if resp.ResponseType == types.ResponseTypeThinking && resp.Content != "" {
if !firstToken {
gen.MarkCompletionStart(time.Now())
firstToken = true
}
reasoningBuf = append(reasoningBuf, resp.Content...)
}
if resp.ResponseType == types.ResponseTypeAnswer && resp.Content != "" {
if !firstToken {
gen.MarkCompletionStart(time.Now())
firstToken = true
}
contentBuf = append(contentBuf, resp.Content...)
}
if resp.Usage != nil {
usage = resp.Usage
}
if len(resp.ToolCalls) > 0 {
// The downstream model-context registry decodes arguments in
// place before tool execution. Snapshot the provider payload so
// the generation observation remains the exact model output and
// cannot be changed through the shared slice backing array.
toolCalls = snapshotLangfuseToolCalls(resp.ToolCalls)
}
if resp.FinishReason != "" {
finishReason = resp.FinishReason
}
wrapped <- resp
}
output := buildLangfuseGenerationOutput(
string(contentBuf), string(reasoningBuf), finishReason, toolCalls,
)
gen.Finish(output, convertUsage(usage), nil)
}()
return wrapped, nil
}
func snapshotLangfuseToolCalls(toolCalls []types.LLMToolCall) []types.LLMToolCall {
return append([]types.LLMToolCall(nil), toolCalls...)
}
func buildLangfuseMessages(messages []Message) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(messages))
for _, m := range messages {
entry := map[string]interface{}{
"role": m.Role,
}
if m.Content != "" {
entry["content"] = m.Content
}
if len(m.MultiContent) > 0 {
entry["content"] = m.MultiContent
}
if m.Name != "" {
entry["name"] = m.Name
}
if m.ToolCallID != "" {
entry["tool_call_id"] = m.ToolCallID
}
if len(m.ToolCalls) > 0 {
entry["tool_calls"] = m.ToolCalls
}
if m.ReasoningContent != "" {
entry["reasoning_content"] = m.ReasoningContent
}
out = append(out, entry)
}
return out
}
func buildLangfuseGenerationOutput(
content, reasoningContent, finishReason string,
toolCalls []types.LLMToolCall,
) map[string]interface{} {
output := map[string]interface{}{
"content": content,
"tool_calls": toolCalls,
"finish_reason": finishReason,
}
if reasoningContent != "" {
output["reasoning_content"] = reasoningContent
}
return output
}
func buildLangfuseModelParams(opts *ChatOptions) map[string]interface{} {
if opts == nil {
return nil
}
params := map[string]interface{}{}
if opts.Temperature != 0 {
params["temperature"] = opts.Temperature
}
if opts.TopP != 0 {
params["top_p"] = opts.TopP
}
if opts.MaxTokens > 0 {
params["max_tokens"] = opts.MaxTokens
}
if opts.MaxCompletionTokens > 0 {
params["max_completion_tokens"] = opts.MaxCompletionTokens
}
if opts.FrequencyPenalty != 0 {
params["frequency_penalty"] = opts.FrequencyPenalty
}
if opts.PresencePenalty != 0 {
params["presence_penalty"] = opts.PresencePenalty
}
if opts.Seed != 0 {
params["seed"] = opts.Seed
}
if opts.ToolChoice != "" {
params["tool_choice"] = opts.ToolChoice
}
if len(params) == 0 {
return nil
}
return params
}
func convertUsage(u *types.TokenUsage) *langfuse.TokenUsage {
if u == nil {
return nil
}
if u.PromptTokens == 0 && u.CompletionTokens == 0 && u.TotalTokens == 0 {
return nil
}
return &langfuse.TokenUsage{
Input: u.PromptTokens,
Output: u.CompletionTokens,
Total: u.TotalTokens,
CacheRead: u.CacheReadTokens,
CacheWrite: u.CacheWriteTokens,
CacheMiss: u.CacheMissTokens,
Unit: "TOKENS",
}
}
// wrapChatLangfuse wraps a Chat in a Langfuse-aware decorator when the
// manager is enabled. Called from NewChat after the debug wrapper so both
// sinks observe the same call.
func wrapChatLangfuse(c Chat, err error) (Chat, error) {
if err != nil || c == nil {
return c, err
}
if !langfuse.GetManager().Enabled() {
return c, nil
}
return &langfuseChat{inner: c}, nil
}
@@ -0,0 +1,71 @@
package chat
import (
"reflect"
"testing"
"github.com/Tencent/WeKnora/internal/types"
)
func TestBuildLangfuseGenerationOutput(t *testing.T) {
toolCalls := []types.LLMToolCall{{ID: "call_1", Type: "function"}}
got := buildLangfuseGenerationOutput("", "", "tool_calls", toolCalls)
want := map[string]interface{}{
"content": "",
"tool_calls": toolCalls,
"finish_reason": "tool_calls",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("output without reasoning = %#v; want %#v", got, want)
}
got = buildLangfuseGenerationOutput("answer", "thinking", "stop", nil)
want = map[string]interface{}{
"content": "answer",
"tool_calls": []types.LLMToolCall(nil),
"finish_reason": "stop",
"reasoning_content": "thinking",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("output with reasoning = %#v; want %#v", got, want)
}
}
func TestSnapshotLangfuseToolCallsKeepsModelArguments(t *testing.T) {
providerCalls := []types.LLMToolCall{{
ID: "call_1",
Function: types.FunctionCall{Name: "wiki_read_page", Arguments: `{"slugs":["res://0001"]}`},
}}
snapshot := snapshotLangfuseToolCalls(providerCalls)
providerCalls[0].Function.Arguments = `{"slugs":["summary/uuid"]}`
if got := snapshot[0].Function.Arguments; got != `{"slugs":["res://0001"]}` {
t.Fatalf("Langfuse snapshot was mutated to %s", got)
}
}
func TestBuildLangfuseMessagesReasoningContent(t *testing.T) {
msgs := buildLangfuseMessages([]Message{
{Role: "assistant", ReasoningContent: "chain of thought", ToolCalls: []ToolCall{{ID: "tc1"}}},
})
if len(msgs) != 1 {
t.Fatalf("len(messages) = %d; want 1", len(msgs))
}
if msgs[0]["reasoning_content"] != "chain of thought" {
t.Fatalf("reasoning_content = %v; want chain of thought", msgs[0]["reasoning_content"])
}
}
func TestConvertUsageIncludesPromptCacheCounters(t *testing.T) {
got := convertUsage(&types.TokenUsage{
PromptTokens: 1000, CompletionTokens: 50, TotalTokens: 1050,
CacheReadTokens: 800, CacheWriteTokens: 100, CacheMissTokens: 200,
})
if got == nil {
t.Fatal("convertUsage returned nil")
}
if got.CacheRead != 800 || got.CacheWrite != 100 || got.CacheMiss != 200 {
t.Fatalf("cache usage = read:%d write:%d miss:%d", got.CacheRead, got.CacheWrite, got.CacheMiss)
}
}
+215
View File
@@ -0,0 +1,215 @@
package chat
import (
"context"
"fmt"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
)
func buildLLMMessages(messages []Message) []logger.LLMMessage {
out := make([]logger.LLMMessage, 0, len(messages))
for _, m := range messages {
lm := logger.LLMMessage{
Role: m.Role,
Content: m.Content,
Name: m.Name,
ToolCallID: m.ToolCallID,
Images: m.Images,
}
if lm.Content == "" && len(m.MultiContent) > 0 {
var parts []string
for _, mc := range m.MultiContent {
switch mc.Type {
case "text":
parts = append(parts, mc.Text)
case "image_url":
if mc.ImageURL != nil {
parts = append(parts, fmt.Sprintf("[image_url: %s]", truncateForDebug(mc.ImageURL.URL, 120)))
}
}
}
lm.Content = strings.Join(parts, "\n")
}
for _, tc := range m.ToolCalls {
lm.ToolCalls = append(lm.ToolCalls, logger.LLMToolCallInfo{
ID: tc.ID,
FuncName: tc.Function.Name,
Arguments: tc.Function.Arguments,
})
}
out = append(out, lm)
}
return out
}
func buildOptionsSection(opts *ChatOptions) string {
if opts == nil {
return ""
}
var parts []string
parts = append(parts, fmt.Sprintf("Temperature=%.2f", opts.Temperature))
if opts.TopP > 0 {
parts = append(parts, fmt.Sprintf("TopP=%.2f", opts.TopP))
}
if opts.MaxTokens > 0 {
parts = append(parts, fmt.Sprintf("MaxTokens=%d", opts.MaxTokens))
}
if opts.MaxCompletionTokens > 0 {
parts = append(parts, fmt.Sprintf("MaxCompletionTokens=%d", opts.MaxCompletionTokens))
}
if opts.FrequencyPenalty > 0 {
parts = append(parts, fmt.Sprintf("FrequencyPenalty=%.2f", opts.FrequencyPenalty))
}
if opts.PresencePenalty > 0 {
parts = append(parts, fmt.Sprintf("PresencePenalty=%.2f", opts.PresencePenalty))
}
if opts.ToolChoice != "" {
parts = append(parts, fmt.Sprintf("ToolChoice=%s", opts.ToolChoice))
}
if len(opts.Format) > 0 {
parts = append(parts, "ResponseFormat=json_object")
}
return strings.Join(parts, ", ")
}
func buildToolsSection(opts *ChatOptions) string {
if opts == nil || len(opts.Tools) == 0 {
return ""
}
var b strings.Builder
for i, t := range opts.Tools {
if i > 0 {
b.WriteString("\n")
}
b.WriteString(fmt.Sprintf("- %s: %s", t.Function.Name, t.Function.Description))
}
return b.String()
}
func buildResponseToolCalls(tcs []types.LLMToolCall) []logger.LLMToolCallInfo {
if len(tcs) == 0 {
return nil
}
out := make([]logger.LLMToolCallInfo, 0, len(tcs))
for _, tc := range tcs {
out = append(out, logger.LLMToolCallInfo{
ID: tc.ID,
FuncName: tc.Function.Name,
Arguments: tc.Function.Arguments,
})
}
return out
}
func usageString(u types.TokenUsage) string {
return fmt.Sprintf("Prompt: %d, Completion: %d, Total: %d, CacheRead: %d, CacheWrite: %d, CacheMiss: %d, CacheStatus: %s",
u.PromptTokens, u.CompletionTokens, u.TotalTokens, u.CacheReadTokens,
u.CacheWriteTokens, u.CacheMissTokens, u.CacheStatus)
}
// logLLMDebugCall logs a complete non-stream LLM chat call.
func logLLMDebugCall(ctx context.Context, model string, messages []Message, opts *ChatOptions, resp *types.ChatResponse, callErr error, dur time.Duration) {
if !logger.LLMDebugEnabled() {
return
}
record := &logger.LLMCallRecord{
CallType: "Chat",
Model: model,
Duration: dur,
}
record.Sections = append(record.Sections, logger.RecordSection{
Title: "Messages",
Content: logger.FormatMessages(buildLLMMessages(messages)),
})
if s := buildOptionsSection(opts); s != "" {
record.Sections = append(record.Sections, logger.RecordSection{Title: "Options", Content: s})
}
if s := buildToolsSection(opts); s != "" {
record.Sections = append(record.Sections, logger.RecordSection{Title: "Tools", Content: s})
}
if resp != nil {
var respText strings.Builder
if resp.Content != "" {
respText.WriteString("[assistant]\n")
respText.WriteString(resp.Content)
respText.WriteString("\n")
}
tcs := buildResponseToolCalls(resp.ToolCalls)
if len(tcs) > 0 {
respText.WriteString(logger.FormatToolCalls(tcs))
}
if respText.Len() > 0 {
record.Sections = append(record.Sections, logger.RecordSection{Title: "Response", Content: respText.String()})
}
record.Sections = append(record.Sections, logger.RecordSection{Title: "Usage", Content: usageString(resp.Usage)})
}
if callErr != nil {
record.Error = callErr.Error()
}
logger.LLMDebugLog(ctx, record)
}
// logLLMDebugStream logs a complete stream LLM chat call after all chunks have been received.
func logLLMDebugStream(ctx context.Context, model string, messages []Message, opts *ChatOptions, fullContent string, toolCalls []types.LLMToolCall, usage *types.TokenUsage, callErr error, dur time.Duration) {
if !logger.LLMDebugEnabled() {
return
}
record := &logger.LLMCallRecord{
CallType: "Chat Stream",
Model: model,
Duration: dur,
}
record.Sections = append(record.Sections, logger.RecordSection{
Title: "Messages",
Content: logger.FormatMessages(buildLLMMessages(messages)),
})
if s := buildOptionsSection(opts); s != "" {
record.Sections = append(record.Sections, logger.RecordSection{Title: "Options", Content: s})
}
if s := buildToolsSection(opts); s != "" {
record.Sections = append(record.Sections, logger.RecordSection{Title: "Tools", Content: s})
}
var respText strings.Builder
if fullContent != "" {
respText.WriteString("[assistant]\n")
respText.WriteString(fullContent)
respText.WriteString("\n")
}
tcs := buildResponseToolCalls(toolCalls)
if len(tcs) > 0 {
respText.WriteString(logger.FormatToolCalls(tcs))
}
if respText.Len() > 0 {
record.Sections = append(record.Sections, logger.RecordSection{Title: "Response", Content: respText.String()})
}
if usage != nil {
record.Sections = append(record.Sections, logger.RecordSection{Title: "Usage", Content: usageString(*usage)})
}
if callErr != nil {
record.Error = callErr.Error()
}
logger.LLMDebugLog(ctx, record)
}
func truncateForDebug(s string, maxRunes int) string {
runes := []rune(s)
if len(runes) <= maxRunes {
return s
}
return string(runes[:maxRunes]) + fmt.Sprintf("...(%d chars)", len(runes))
}
+77
View File
@@ -0,0 +1,77 @@
package chat
import (
"context"
"fmt"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
)
// debugChat wraps a Chat implementation and logs all calls to the LLM debug log.
// It works with both RemoteAPIChat and OllamaChat (or any future Chat impl).
type debugChat struct {
inner Chat
}
func (d *debugChat) GetModelName() string { return d.inner.GetModelName() }
func (d *debugChat) GetModelID() string { return d.inner.GetModelID() }
func (d *debugChat) Chat(ctx context.Context, messages []Message, opts *ChatOptions) (*types.ChatResponse, error) {
callStart := time.Now()
resp, err := d.inner.Chat(ctx, messages, opts)
logLLMDebugCall(ctx, d.inner.GetModelName(), messages, opts, resp, err, time.Since(callStart))
return resp, err
}
func (d *debugChat) ChatStream(ctx context.Context, messages []Message, opts *ChatOptions) (<-chan types.StreamResponse, error) {
callStart := time.Now()
ch, err := d.inner.ChatStream(ctx, messages, opts)
if err != nil {
logLLMDebugStream(ctx, d.inner.GetModelName(), messages, opts, "", nil, nil, err, time.Since(callStart))
return ch, err
}
if ch == nil {
return nil, nil
}
wrapped := make(chan types.StreamResponse)
go func() {
defer close(wrapped)
var content strings.Builder
var usage *types.TokenUsage
var toolCalls []types.LLMToolCall
var streamErr error
for resp := range ch {
if resp.ResponseType == types.ResponseTypeAnswer && resp.Content != "" {
content.WriteString(resp.Content)
}
if resp.ResponseType == types.ResponseTypeError {
streamErr = fmt.Errorf("%s", resp.Content)
}
if resp.Usage != nil {
usage = resp.Usage
}
if len(resp.ToolCalls) > 0 {
toolCalls = resp.ToolCalls
}
wrapped <- resp
}
logLLMDebugStream(ctx, d.inner.GetModelName(), messages, opts,
content.String(), toolCalls, usage, streamErr, time.Since(callStart))
}()
return wrapped, nil
}
// wrapChatDebug wraps a Chat if LLM debug logging is enabled.
func wrapChatDebug(c Chat, err error) (Chat, error) {
if err != nil || !logger.LLMDebugEnabled() {
return c, err
}
return &debugChat{inner: c}, nil
}
+405
View File
@@ -0,0 +1,405 @@
package chat
import (
"context"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/models/requestbudget"
"github.com/Tencent/WeKnora/internal/models/utils/ollama"
"github.com/Tencent/WeKnora/internal/types"
secutils "github.com/Tencent/WeKnora/internal/utils"
ollamaapi "github.com/ollama/ollama/api"
)
// OllamaChat 实现了基于 Ollama 的聊天
type OllamaChat struct {
modelName string
modelID string
ollamaService *ollama.OllamaService
}
// NewOllamaChat 创建 Ollama 聊天实例
func NewOllamaChat(config *ChatConfig, ollamaService *ollama.OllamaService) (*OllamaChat, error) {
return &OllamaChat{
modelName: config.ModelName,
modelID: config.ModelID,
ollamaService: ollamaService,
}, nil
}
// convertMessages 转换消息格式为Ollama API格式
func (c *OllamaChat) convertMessages(messages []Message) []ollamaapi.Message {
ollamaMessages := make([]ollamaapi.Message, 0, len(messages))
for _, msg := range messages {
msgOllama := ollamaapi.Message{
Role: msg.Role,
Content: msg.Content,
ToolCalls: c.toolCallFrom(msg.ToolCalls),
}
if msg.Role == "tool" {
msgOllama.ToolName = msg.Name
}
if len(msg.Images) > 0 && msg.Role == "user" {
for _, imgURL := range msg.Images {
if imgData := resolveImageForOllama(imgURL); imgData != nil {
msgOllama.Images = append(msgOllama.Images, imgData)
}
}
}
ollamaMessages = append(ollamaMessages, msgOllama)
}
return ollamaMessages
}
// resolveImageForOllama resolves an image URL into raw bytes for Ollama.
// Handles local serving paths (/files/...), data URIs, and remote HTTP URLs.
func resolveImageForOllama(imageURL string) ollamaapi.ImageData {
if data := resolveImageURLForOllama(imageURL); data != nil {
return data
}
if strings.HasPrefix(imageURL, "http://") || strings.HasPrefix(imageURL, "https://") {
if err := secutils.ValidateURLForSSRF(imageURL); err != nil {
return nil
}
client := secutils.NewSSRFSafeHTTPClient(secutils.SSRFSafeHTTPClientConfig{
Timeout: 30 * time.Second,
MaxRedirects: 5,
})
resp, err := client.Get(imageURL)
if err != nil {
return nil
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 20*1024*1024))
if err != nil {
return nil
}
return data
}
return nil
}
// buildChatRequest 构建聊天请求参数
func (c *OllamaChat) buildChatRequest(messages []Message, opts *ChatOptions, isStream bool) *ollamaapi.ChatRequest {
// 设置流式标志
streamFlag := isStream
// 构建请求参数
chatReq := &ollamaapi.ChatRequest{
Model: c.modelName,
Messages: c.convertMessages(messages),
Stream: &streamFlag,
Options: make(map[string]interface{}),
}
// 添加可选参数
if opts != nil {
chatReq.Options["temperature"] = opts.Temperature
if opts.TopP > 0 {
chatReq.Options["top_p"] = opts.TopP
}
if opts.MaxTokens > 0 {
chatReq.Options["num_predict"] = opts.MaxTokens
}
if opts.Thinking != nil {
chatReq.Think = &ollamaapi.ThinkValue{
Value: *opts.Thinking,
}
}
if len(opts.Format) > 0 {
chatReq.Format = opts.Format
}
if len(opts.Tools) > 0 {
chatReq.Tools = c.toolFrom(opts.Tools)
}
}
return chatReq
}
// Chat 进行非流式聊天
func (c *OllamaChat) Chat(ctx context.Context, messages []Message, opts *ChatOptions) (*types.ChatResponse, error) {
if err := requestbudget.RequireSupported(ctx, false); err != nil {
return nil, err
}
// 确保模型可用
if err := c.ensureModelAvailable(ctx); err != nil {
return nil, err
}
// 构建请求参数
chatReq := c.buildChatRequest(messages, opts, false)
// 记录请求日志
logger.GetLogger(ctx).Infof("发送聊天请求到模型 %s", c.modelName)
var responseContent string
var toolCalls []types.LLMToolCall
var promptTokens, completionTokens int
// 使用 Ollama 客户端发送请求
err := c.ollamaService.Chat(ctx, chatReq, func(resp ollamaapi.ChatResponse) error {
responseContent = resp.Message.Content
// 当 Content 为空但 Thinking 有内容时(如推理模型未正确配置 thinking 参数),使用 Thinking 作为兜底
if responseContent == "" && resp.Message.Thinking != "" {
responseContent = resp.Message.Thinking
}
toolCalls = c.toolCallTo(resp.Message.ToolCalls)
// 获取token计数
if resp.EvalCount > 0 {
promptTokens = resp.PromptEvalCount
completionTokens = resp.EvalCount - promptTokens
}
return nil
})
if err != nil {
return nil, fmt.Errorf("聊天请求失败: %w", err)
}
usage := types.TokenUsage{
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
TotalTokens: promptTokens + completionTokens,
}
usage.MarkPromptCacheUnsupported()
logUsage(ctx, c.modelName, &usage)
return &types.ChatResponse{
Content: responseContent,
ToolCalls: toolCalls,
Usage: usage,
}, nil
}
// ChatStream 进行流式聊天
func (c *OllamaChat) ChatStream(
ctx context.Context,
messages []Message,
opts *ChatOptions,
) (<-chan types.StreamResponse, error) {
if err := requestbudget.RequireSupported(ctx, false); err != nil {
return nil, err
}
// 确保模型可用
if err := c.ensureModelAvailable(ctx); err != nil {
return nil, err
}
// 构建请求参数
chatReq := c.buildChatRequest(messages, opts, true)
// 记录请求日志
logger.GetLogger(ctx).Infof("发送流式聊天请求到模型 %s", c.modelName)
// 创建流式响应通道
streamChan := make(chan types.StreamResponse)
// 启动goroutine处理流式响应
go func() {
defer close(streamChan)
var thinking thinkingEmitter
err := c.ollamaService.Chat(ctx, chatReq, func(resp ollamaapi.ChatResponse) error {
// 发送思考内容(支持 Qwen3、DeepSeek 等推理模型)
if resp.Message.Thinking != "" {
thinking.emit(streamChan, resp.Message.Thinking)
}
if resp.Message.Content != "" {
// 思考阶段结束后,发送思考完成事件
thinking.finish(streamChan)
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeAnswer,
Content: resp.Message.Content,
Done: false,
}
}
if len(resp.Message.ToolCalls) > 0 {
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeToolCall,
ToolCalls: c.toolCallTo(resp.Message.ToolCalls),
Done: false,
}
// Ollama returns tool calls as complete objects (not incremental deltas).
// Log this so we can trace non-streaming thought delivery.
for _, tc := range resp.Message.ToolCalls {
if tc.Function.Name == "thinking" {
argsBytes, _ := json.Marshal(tc.Function.Arguments)
logger.Warnf(ctx, "[Ollama Stream] Tool %q arrived non-incrementally (%d bytes args), "+
"thought will not be token-streamed to frontend",
tc.Function.Name, len(argsBytes))
}
}
for _, tc := range resp.Message.ToolCalls {
argsMap := tc.Function.Arguments.ToMap()
switch tc.Function.Name {
case "thinking":
if thought, ok := argsMap["thought"].(string); ok && thought != "" {
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeThinking,
Content: thought,
Done: false,
Data: map[string]interface{}{
"source": "thinking_tool",
"tool_call_id": tooli2s(tc.Function.Index),
},
}
}
}
}
}
if resp.Done {
var usage *types.TokenUsage
if resp.PromptEvalCount > 0 || resp.EvalCount > 0 {
usage = &types.TokenUsage{
PromptTokens: resp.PromptEvalCount,
CompletionTokens: resp.EvalCount,
TotalTokens: resp.PromptEvalCount + resp.EvalCount,
}
usage.MarkPromptCacheUnsupported()
}
logUsage(ctx, c.modelName, usage)
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeAnswer,
Done: true,
Usage: usage,
}
}
return nil
})
if err != nil {
logger.GetLogger(ctx).Errorf("流式聊天请求失败: %v", err)
// 发送错误响应
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeError,
Content: err.Error(),
Done: true,
}
}
}()
return streamChan, nil
}
// 确保模型可用
func (c *OllamaChat) ensureModelAvailable(ctx context.Context) error {
logger.GetLogger(ctx).Infof("确保模型 %s 可用", c.modelName)
return c.ollamaService.EnsureModelAvailable(ctx, c.modelName)
}
// GetModelName 获取模型名称
func (c *OllamaChat) GetModelName() string {
return c.modelName
}
// GetModelID 获取模型ID
func (c *OllamaChat) GetModelID() string {
return c.modelID
}
// toolFrom 将本模块的 Tool 转换为 Ollama 的 Tool
func (c *OllamaChat) toolFrom(tools []Tool) ollamaapi.Tools {
if len(tools) == 0 {
return nil
}
ollamaTools := make(ollamaapi.Tools, 0, len(tools))
for _, tool := range tools {
function := ollamaapi.ToolFunction{
Name: tool.Function.Name,
Description: tool.Function.Description,
}
if len(tool.Function.Parameters) > 0 {
_ = json.Unmarshal(tool.Function.Parameters, &function.Parameters)
}
ollamaTools = append(ollamaTools, ollamaapi.Tool{
Type: tool.Type,
Function: function,
})
}
return ollamaTools
}
// toolTo 将 Ollama 的 Tool 转换为本模块的 Tool
func (c *OllamaChat) toolTo(ollamaTools ollamaapi.Tools) []Tool {
if len(ollamaTools) == 0 {
return nil
}
tools := make([]Tool, 0, len(ollamaTools))
for _, tool := range ollamaTools {
paramsBytes, _ := json.Marshal(tool.Function.Parameters)
tools = append(tools, Tool{
Type: tool.Type,
Function: FunctionDef{
Name: tool.Function.Name,
Description: tool.Function.Description,
Parameters: paramsBytes,
},
})
}
return tools
}
// toolCallFrom 将本模块的 ToolCall 转换为 Ollama 的 ToolCall
func (c *OllamaChat) toolCallFrom(toolCalls []ToolCall) []ollamaapi.ToolCall {
if len(toolCalls) == 0 {
return nil
}
ollamaToolCalls := make([]ollamaapi.ToolCall, 0, len(toolCalls))
for _, tc := range toolCalls {
args := ollamaapi.NewToolCallFunctionArguments()
if tc.Function.Arguments != "" {
_ = args.UnmarshalJSON([]byte(tc.Function.Arguments))
}
ollamaToolCalls = append(ollamaToolCalls, ollamaapi.ToolCall{
Function: ollamaapi.ToolCallFunction{
Index: tools2i(tc.ID),
Name: tc.Function.Name,
Arguments: args,
},
})
}
return ollamaToolCalls
}
// toolCallTo 将 Ollama 的 ToolCall 转换为本模块的 ToolCall
func (c *OllamaChat) toolCallTo(ollamaToolCalls []ollamaapi.ToolCall) []types.LLMToolCall {
if len(ollamaToolCalls) == 0 {
return nil
}
toolCalls := make([]types.LLMToolCall, 0, len(ollamaToolCalls))
for _, tc := range ollamaToolCalls {
argsBytes, _ := json.Marshal(tc.Function.Arguments)
toolCalls = append(toolCalls, types.LLMToolCall{
ID: tooli2s(tc.Function.Index),
Type: "function",
Function: types.FunctionCall{
Name: tc.Function.Name,
Arguments: string(argsBytes),
},
})
}
return toolCalls
}
func tooli2s(i int) string {
return strconv.Itoa(i)
}
func tools2i(s string) int {
i, _ := strconv.Atoi(s)
return i
}
+34
View File
@@ -0,0 +1,34 @@
package chat
import (
"net/http"
"net/http/httptest"
"testing"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
func TestResolveImageForOllamaRejectsInternalURL(t *testing.T) {
t.Setenv("SSRF_WHITELIST", "")
secutils.ResetSSRFWhitelistForTest()
t.Cleanup(secutils.ResetSSRFWhitelistForTest)
if data := resolveImageForOllama("http://169.254.169.254/latest/meta-data/"); data != nil {
t.Fatalf("resolveImageForOllama returned data for blocked internal URL")
}
}
func TestResolveImageForOllamaBlocksRedirectToInternalURL(t *testing.T) {
t.Setenv("SSRF_WHITELIST", "127.0.0.1")
secutils.ResetSSRFWhitelistForTest()
t.Cleanup(secutils.ResetSSRFWhitelistForTest)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound)
}))
defer server.Close()
if data := resolveImageForOllama(server.URL); data != nil {
t.Fatalf("resolveImageForOllama returned data after redirect to blocked internal URL")
}
}
+242
View File
@@ -0,0 +1,242 @@
package chat
import (
"encoding/json"
"fmt"
"github.com/sashabaranov/go-openai"
)
// ConvertMessages 转换消息格式为 OpenAI 格式(导出供子类使用)
func (c *RemoteAPIChat) ConvertMessages(messages []Message) []openai.ChatCompletionMessage {
openaiMessages := make([]openai.ChatCompletionMessage, 0, len(messages))
for _, msg := range messages {
openaiMsg := openai.ChatCompletionMessage{
Role: msg.Role,
}
// 优先处理多内容消息(包含图片等)
if len(msg.MultiContent) > 0 {
openaiMsg.MultiContent = make([]openai.ChatMessagePart, 0, len(msg.MultiContent))
for _, part := range msg.MultiContent {
switch part.Type {
case "text":
openaiMsg.MultiContent = append(openaiMsg.MultiContent, openai.ChatMessagePart{
Type: openai.ChatMessagePartTypeText,
Text: part.Text,
})
case "image_url":
if part.ImageURL != nil {
openaiMsg.MultiContent = append(openaiMsg.MultiContent, openai.ChatMessagePart{
Type: openai.ChatMessagePartTypeImageURL,
ImageURL: &openai.ChatMessageImageURL{
URL: part.ImageURL.URL,
Detail: openai.ImageURLDetail(part.ImageURL.Detail),
},
})
}
}
}
} else if len(msg.Images) > 0 && msg.Role == "user" {
parts := make([]openai.ChatMessagePart, 0, len(msg.Images)+1)
for _, imgURL := range msg.Images {
resolved := resolveImageURLForLLM(imgURL)
parts = append(parts, openai.ChatMessagePart{
Type: openai.ChatMessagePartTypeImageURL,
ImageURL: &openai.ChatMessageImageURL{
URL: resolved,
Detail: openai.ImageURLDetailAuto,
},
})
}
parts = append(parts, openai.ChatMessagePart{
Type: openai.ChatMessagePartTypeText,
Text: msg.Content,
})
openaiMsg.MultiContent = parts
} else if msg.Content != "" {
openaiMsg.Content = msg.Content
}
if len(msg.ToolCalls) > 0 {
openaiMsg.ToolCalls = make([]openai.ToolCall, 0, len(msg.ToolCalls))
for _, tc := range msg.ToolCalls {
toolType := openai.ToolType(tc.Type)
openaiMsg.ToolCalls = append(openaiMsg.ToolCalls, openai.ToolCall{
ID: tc.ID,
Type: toolType,
Function: openai.FunctionCall{
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
},
})
}
}
if msg.Role == "tool" {
openaiMsg.ToolCallID = msg.ToolCallID
openaiMsg.Name = msg.Name
}
// Round-trip reasoning_content on assistant turns. MiMo and DeepSeek V3.2+
// thinking mode reject multi-turn requests where the prior assistant
// message lacks its reasoning_content with HTTP 400 ("The reasoning_content
// in the thinking mode must be passed back to the API."). Providers that
// don't recognize the field ignore it harmlessly.
if msg.Role == "assistant" && msg.ReasoningContent != "" {
openaiMsg.ReasoningContent = msg.ReasoningContent
}
openaiMessages = append(openaiMessages, openaiMsg)
}
return openaiMessages
}
// BuildChatCompletionRequest 构建标准聊天请求参数(导出供子类使用)。
//
// 这是一个不含任何 provider 特定逻辑的通用实现:所有采样参数(temperature /
// top_p / penalties)与 max_tokens 都按 opts 直接映射。供应商相关的特判
// (OpenAI o-series / GPT-5 改用 max_completion_tokens、Moonshot 固定温度等)
// 由对应的 providerAdapter.ShapeRequest 在事后施加,见 provider.go。
func (c *RemoteAPIChat) BuildChatCompletionRequest(
messages []Message, opts *ChatOptions, isStream bool,
) openai.ChatCompletionRequest {
req := openai.ChatCompletionRequest{
Model: c.modelName,
Messages: c.ConvertMessages(messages),
Stream: isStream,
}
if isStream {
req.StreamOptions = &openai.StreamOptions{IncludeUsage: true}
}
if opts == nil {
return req
}
req.Temperature = float32(opts.Temperature)
if opts.TopP > 0 {
req.TopP = float32(opts.TopP)
}
if opts.FrequencyPenalty > 0 {
req.FrequencyPenalty = float32(opts.FrequencyPenalty)
}
if opts.PresencePenalty > 0 {
req.PresencePenalty = float32(opts.PresencePenalty)
}
if opts.MaxTokens > 0 {
req.MaxTokens = opts.MaxTokens
}
if opts.MaxCompletionTokens > 0 {
req.MaxCompletionTokens = opts.MaxCompletionTokens
}
// 处理 Tools
if len(opts.Tools) > 0 {
req.Tools = make([]openai.Tool, 0, len(opts.Tools))
for _, tool := range opts.Tools {
toolType := openai.ToolType(tool.Type)
openaiTool := openai.Tool{
Type: toolType,
Function: &openai.FunctionDefinition{
Name: tool.Function.Name,
Description: tool.Function.Description,
},
}
if tool.Function.Parameters != nil {
openaiTool.Function.Parameters = tool.Function.Parameters
}
req.Tools = append(req.Tools, openaiTool)
}
}
// 处理 ParallelToolCalls
if opts.ParallelToolCalls != nil {
val := *opts.ParallelToolCalls
req.ParallelToolCalls = val
}
// 处理 ToolChoice(标准实现)
if opts.ToolChoice != "" {
switch opts.ToolChoice {
case "none", "required", "auto":
req.ToolChoice = opts.ToolChoice
default:
req.ToolChoice = openai.ToolChoice{
Type: "function",
Function: openai.ToolFunction{
Name: opts.ToolChoice,
},
}
}
}
if len(opts.Format) > 0 {
req.ResponseFormat = &openai.ChatCompletionResponseFormat{
Type: openai.ChatCompletionResponseFormatTypeJSONObject,
}
req.Messages[len(req.Messages)-1].Content += fmt.Sprintf("\nUse this JSON schema: %s", opts.Format)
}
return req
}
func (c *RemoteAPIChat) buildProviderOpenAIRequest(
body any,
openAIMessages []openai.ChatCompletionMessage,
messages []Message,
) (map[string]any, error) {
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal provider request: %w", err)
}
var out map[string]any
if err := json.Unmarshal(data, &out); err != nil {
return nil, fmt.Errorf("unmarshal provider request: %w", err)
}
providerMessages := make([]map[string]any, 0, len(openAIMessages))
for i, msg := range openAIMessages {
msgData, err := json.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("marshal provider message: %w", err)
}
var msgMap map[string]any
if err := json.Unmarshal(msgData, &msgMap); err != nil {
return nil, fmt.Errorf("unmarshal provider message: %w", err)
}
if i < len(messages) && len(messages[i].ToolCalls) > 0 && len(msg.ToolCalls) > 0 {
toolCalls := make([]map[string]any, 0, len(msg.ToolCalls))
for j, tc := range msg.ToolCalls {
tcData, err := json.Marshal(tc)
if err != nil {
return nil, fmt.Errorf("marshal provider tool call: %w", err)
}
var tcMap map[string]any
if err := json.Unmarshal(tcData, &tcMap); err != nil {
return nil, fmt.Errorf("unmarshal provider tool call: %w", err)
}
if j < len(messages[i].ToolCalls) {
c.adapter.InjectToolCallMetadata(tcMap, messages[i].ToolCalls[j].ProviderMetadata)
}
toolCalls = append(toolCalls, tcMap)
}
msgMap["tool_calls"] = toolCalls
}
providerMessages = append(providerMessages, msgMap)
}
out["messages"] = providerMessages
return out, nil
}
func (c *RemoteAPIChat) shapeProviderRequest(body any, req openai.ChatCompletionRequest, messages []Message) (any, error) {
if !c.adapter.ForceRawHTTP() {
return body, nil
}
return c.buildProviderOpenAIRequest(body, req.Messages, messages)
}
+631
View File
@@ -0,0 +1,631 @@
package chat
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/sashabaranov/go-openai"
)
// parseCompletionResponse 解析非流式响应
func (c *RemoteAPIChat) parseCompletionResponse(resp *openai.ChatCompletionResponse) (*types.ChatResponse, error) {
if len(resp.Choices) == 0 {
return nil, fmt.Errorf("no response from API")
}
choice := resp.Choices[0]
// 处理思考模型的输出:移除 <think></think> 标签包裹的思考过程
// 为设置了 Thinking=false 但模型仍返回思考内容的情况和部分不支持Thinking=false的思考模型(例如Miniax-M2.1)提供兜底策略
content := removeThinkingContent(choice.Message.Content)
usage := tokenUsageFromOpenAI(resp.Usage, c.provider)
response := &types.ChatResponse{
Content: content,
ReasoningContent: choice.Message.ReasoningContent,
FinishReason: string(choice.FinishReason),
Usage: usage,
}
if len(choice.Message.ToolCalls) > 0 {
response.ToolCalls = make([]types.LLMToolCall, 0, len(choice.Message.ToolCalls))
for _, tc := range choice.Message.ToolCalls {
response.ToolCalls = append(response.ToolCalls, types.LLMToolCall{
ID: tc.ID,
Type: string(tc.Type),
Function: types.FunctionCall{
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
},
})
}
}
return response, nil
}
func (c *RemoteAPIChat) applyCompletionToolCallMetadata(body []byte, result *types.ChatResponse) {
if result == nil || len(result.ToolCalls) == 0 {
return
}
var raw struct {
Choices []struct {
Message struct {
ToolCalls []json.RawMessage `json:"tool_calls,omitempty"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(body, &raw); err != nil || len(raw.Choices) == 0 {
return
}
for i, rawToolCall := range raw.Choices[0].Message.ToolCalls {
var indexed struct {
Index *int `json:"index,omitempty"`
}
_ = json.Unmarshal(rawToolCall, &indexed)
idx := i
if indexed.Index != nil {
idx = *indexed.Index
}
if idx >= 0 && idx < len(result.ToolCalls) {
result.ToolCalls[idx].ProviderMetadata = c.adapter.ExtractToolCallMetadata(rawToolCall)
}
}
}
// removeThinkingContent 移除思考模型输出中的 <think></think> 思考过程
// 仅当内容以 <think> 开头时才处理
func removeThinkingContent(content string) string {
const thinkStartTag = "<think>"
const thinkEndTag = "</think>"
trimmed := strings.TrimSpace(content)
if !strings.HasPrefix(trimmed, thinkStartTag) {
return content
}
// 查找最后一个 </think> 标签(处理嵌套情况)
if lastEndIdx := strings.LastIndex(trimmed, thinkEndTag); lastEndIdx != -1 {
if result := strings.TrimSpace(trimmed[lastEndIdx+len(thinkEndTag):]); result != "" {
return result
}
return ""
}
return "" // 未找到 </think>,可能思考内容过长被截断,返回空字符串
}
// processStream 处理 OpenAI SDK 流式响应
func (c *RemoteAPIChat) processStream(
ctx context.Context,
stream *openai.ChatCompletionStream,
streamChan chan types.StreamResponse,
dumper *streamPacketDumper,
) {
defer close(streamChan)
defer stream.Close()
state := newStreamState()
for {
response, err := stream.Recv()
if err != nil {
if err == io.EOF {
logUsage(ctx, c.modelName, state.usage)
toolCalls := state.buildOrderedToolCalls()
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeAnswer,
Content: "",
Done: true,
ToolCalls: toolCalls,
Usage: state.usage,
FinishReason: state.lastFinishReason,
}
} else {
logger.Errorf(ctx, "Stream read error: %v (tool_calls_assembled=%d)",
err, len(state.toolCallMap))
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeError,
Content: err.Error(),
Done: true,
ToolCalls: state.buildOrderedToolCalls(),
Usage: state.usage,
FinishReason: types.FinishReasonIncomplete,
}
}
return
}
if dumper != nil {
dumper.WritePacket(response)
}
if response.Usage != nil {
usage := tokenUsageFromOpenAI(*response.Usage, c.provider)
state.usage = &usage
}
if len(response.Choices) > 0 {
c.processStreamDelta(ctx, &response.Choices[0], state, streamChan, response.Choices[0].Delta.ReasoningContent)
}
}
}
// processRawHTTPStream 处理原始 HTTP 流式响应
func (c *RemoteAPIChat) processRawHTTPStream(
ctx context.Context,
resp *http.Response,
streamChan chan types.StreamResponse,
dumper *streamPacketDumper,
) {
defer close(streamChan)
defer resp.Body.Close()
state := newStreamState()
reader := NewSSEReader(resp.Body)
for {
event, err := reader.ReadEvent()
if err != nil {
if err == io.EOF {
logUsage(ctx, c.modelName, state.usage)
toolCalls := state.buildOrderedToolCalls()
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeAnswer,
Content: "",
Done: true,
ToolCalls: toolCalls,
Usage: state.usage,
}
} else {
logger.Errorf(ctx, "Stream read error: %v (tool_calls_assembled=%d)",
err, len(state.toolCallMap))
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeError,
Content: err.Error(),
Done: true,
ToolCalls: state.buildOrderedToolCalls(),
Usage: state.usage,
FinishReason: types.FinishReasonIncomplete,
}
}
return
}
if event == nil {
continue
}
if event.Done {
logUsage(ctx, c.modelName, state.usage)
toolCalls := state.buildOrderedToolCalls()
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeAnswer,
Content: "",
Done: true,
ToolCalls: toolCalls,
Usage: state.usage,
}
return
}
if event.Data == nil {
continue
}
if dumper != nil {
// 保留上游 SSE data 行的原始 JSON,不经过中间结构体裁剪。
raw := make([]byte, len(event.Data))
copy(raw, event.Data)
dumper.WritePacketRaw(raw)
}
// 使用局部结构体进行一次性解析,同时捕捉标准字段和 vLLM 的 reasoning 字段,避免性能损失
var streamResp struct {
openai.ChatCompletionStreamResponse
Choices []struct {
Index int `json:"index"`
Delta struct {
openai.ChatCompletionStreamChoiceDelta
Reasoning string `json:"reasoning,omitempty"`
} `json:"delta"`
FinishReason openai.FinishReason `json:"finish_reason"`
} `json:"choices"`
}
if err := json.Unmarshal(event.Data, &streamResp); err != nil {
logger.Errorf(ctx, "Failed to parse stream response: %v", err)
continue
}
if streamResp.Usage != nil {
usage := tokenUsageFromOpenAI(*streamResp.Usage, c.provider)
applyRawPromptCacheUsage(event.Data, &usage)
state.usage = &usage
}
if len(streamResp.Choices) > 0 {
choice := streamResp.Choices[0]
// 统一获取逻辑(支持标准和 vLLM 两种路径)
reasoning := choice.Delta.Reasoning
if reasoning == "" {
reasoning = choice.Delta.ReasoningContent
}
// 构造一个标准 SDK 兼容的 choice 对象传给下游,保证现有逻辑完全不动
sdkChoice := openai.ChatCompletionStreamChoice{
Index: choice.Index,
Delta: choice.Delta.ChatCompletionStreamChoiceDelta,
FinishReason: choice.FinishReason,
}
c.applyStreamToolCallMetadata(event.Data, state)
c.processStreamDelta(ctx, &sdkChoice, state, streamChan, reasoning)
}
}
}
func (c *RemoteAPIChat) applyStreamToolCallMetadata(data []byte, state *streamState) {
if state == nil {
return
}
var raw struct {
Choices []struct {
Delta struct {
ToolCalls []json.RawMessage `json:"tool_calls,omitempty"`
} `json:"delta"`
} `json:"choices"`
}
if err := json.Unmarshal(data, &raw); err != nil || len(raw.Choices) == 0 {
return
}
for _, rawToolCall := range raw.Choices[0].Delta.ToolCalls {
metadata := c.adapter.ExtractToolCallMetadata(rawToolCall)
if len(metadata) == 0 {
continue
}
var indexed struct {
Index *int `json:"index,omitempty"`
}
_ = json.Unmarshal(rawToolCall, &indexed)
idx := 0
if indexed.Index != nil {
idx = *indexed.Index
}
state.setToolCallProviderMetadata(idx, metadata)
}
}
// streamState 流式处理状态
type streamState struct {
thinkingEmitter
toolCallMap map[int]*types.LLMToolCall
lastFunctionName map[int]string
nameNotified map[int]bool
fieldExtractors map[int]*jsonFieldExtractor // per tool-call-index extractors for streaming field extraction
fileProgress map[int]*sandboxFileProgress
usage *types.TokenUsage // captured from the final stream chunk when include_usage is enabled
lastFinishReason string // last observed finish_reason for EOF handler fallback
// Diagnostic flags (fire-once) used to log earliest signals of tool_call
// presence/absence at the OpenAI-protocol level. These are independent of
// the higher-level ResponseTypeToolCall marker (which only fires once
// function name has stabilized) and let us distinguish between
// (A) no tool_calls field ever observed (true natural-stop), and
// (B) tool_calls field observed but marker not yet emitted.
firstToolCallSeen bool // true once any delta carried tool_calls
noToolCallStopLogged bool // true once we logged "stop without tool_calls"
firstContentSeen bool // true once delta.Content first appeared
firstReasoningSeen bool // true once reasoning_content first appeared
streamStartedAt time.Time
}
func newStreamState() *streamState {
return &streamState{
toolCallMap: make(map[int]*types.LLMToolCall),
lastFunctionName: make(map[int]string),
nameNotified: make(map[int]bool),
fieldExtractors: make(map[int]*jsonFieldExtractor),
fileProgress: make(map[int]*sandboxFileProgress),
streamStartedAt: time.Now(),
}
}
// elapsedMs returns the milliseconds elapsed since the stream state was
// initialized. Used to attach time-since-stream-start to fire-once diagnostic
// logs so a single grep can reveal the temporal layout of a single stream
// (TTFC / TTFT / first-tool-call / natural-stop confirmation, etc).
func (s *streamState) elapsedMs() int64 {
if s.streamStartedAt.IsZero() {
return 0
}
return time.Since(s.streamStartedAt).Milliseconds()
}
func (s *streamState) buildOrderedToolCalls() []types.LLMToolCall {
if len(s.toolCallMap) == 0 {
return nil
}
result := make([]types.LLMToolCall, 0, len(s.toolCallMap))
for i := 0; i < len(s.toolCallMap); i++ {
if tc, ok := s.toolCallMap[i]; ok && tc != nil {
result = append(result, *tc)
}
}
if len(result) == 0 {
return nil
}
return result
}
func (s *streamState) setToolCallProviderMetadata(index int, metadata types.ToolCallMetadata) {
if len(metadata) == 0 {
return
}
toolCallEntry, exists := s.toolCallMap[index]
if !exists || toolCallEntry == nil {
toolCallEntry = &types.LLMToolCall{
Type: "function",
Function: types.FunctionCall{
Name: "",
Arguments: "",
},
}
s.toolCallMap[index] = toolCallEntry
}
toolCallEntry.ProviderMetadata = metadata
}
// processStreamDelta 处理流式响应的单个 delta
func (c *RemoteAPIChat) processStreamDelta(
ctx context.Context,
choice *openai.ChatCompletionStreamChoice,
state *streamState,
streamChan chan types.StreamResponse,
reasoningContent string,
) {
delta := choice.Delta
isDone := string(choice.FinishReason) != ""
// Track finish_reason for EOF handler fallback
if isDone {
state.lastFinishReason = string(choice.FinishReason)
}
// 处理 tool calls
if len(delta.ToolCalls) > 0 {
c.processToolCallsDelta(ctx, delta.ToolCalls, state, streamChan)
}
// Earliest reliable "no tool_calls" signal at the OpenAI-protocol level:
// finish_reason=stop arrived AND we never observed a tool_calls field on
// any prior delta. Logged once per stream so callers can grep for the
// natural-stop entry point without waiting for the higher-level summary.
if isDone &&
string(choice.FinishReason) == "stop" &&
!state.firstToolCallSeen &&
!state.noToolCallStopLogged {
logger.Infof(ctx, "[LLM Stream] Natural-stop at OpenAI layer "+
"(finish=stop, tool_calls field never observed, thinking_seen=%t, "+
"first_content_seen=%t, elapsed_ms=%d)",
state.active, state.firstContentSeen, state.elapsedMs())
state.noToolCallStopLogged = true
}
// 发送思考内容(ReasoningContent,支持 DeepSeek 等模型)
if reasoningContent != "" {
// Earliest reasoning_content signal at the OpenAI-protocol level. Fired
// once per stream so we can distinguish "model emitted thinking before
// answer" vs "model never produced thinking" when triaging logs.
if !state.firstReasoningSeen {
state.firstReasoningSeen = true
logger.Infof(ctx, "[LLM Stream] First reasoning_content at OpenAI layer "+
"(len=%d, preview=%q, elapsed_ms=%d)",
len(reasoningContent), truncateForDebug(reasoningContent, 80), state.elapsedMs())
}
state.emit(streamChan, reasoningContent)
}
// 发送回答内容
if delta.Content != "" {
// Earliest delta.Content signal at the OpenAI-protocol level. Fired once
// per stream so we can measure TTFC (time-to-first-content) and tell
// "answer started before any tool_call" from "tool_call came first".
if !state.firstContentSeen {
state.firstContentSeen = true
logger.Infof(ctx, "[LLM Stream] First delta.Content at OpenAI layer "+
"(len=%d, preview=%q, tool_call_seen=%t, thinking_seen=%t, elapsed_ms=%d)",
len(delta.Content), truncateForDebug(delta.Content, 80),
state.firstToolCallSeen, state.firstReasoningSeen, state.elapsedMs())
}
// If we had thinking content and this is the first answer chunk,
// send a thinking done event first.
state.finish(streamChan)
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeAnswer,
Content: delta.Content,
Done: isDone,
ToolCalls: state.buildOrderedToolCalls(),
FinishReason: string(choice.FinishReason),
}
}
if isDone && len(state.toolCallMap) > 0 {
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeAnswer,
Content: "",
Done: true,
ToolCalls: state.buildOrderedToolCalls(),
FinishReason: string(choice.FinishReason),
}
}
// Ensure thinking done is sent when stream finishes without any answer content
// (e.g., model only produced reasoning then hit finish_reason with empty content).
if isDone {
state.finish(streamChan)
}
// Catch-all: isDone but none of the above branches sent a response with
// FinishReason (empty content, no tool calls, no thinking). This prevents
// the finish_reason from being lost in the streaming pipeline.
if isDone && delta.Content == "" && len(state.toolCallMap) == 0 {
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeAnswer,
Done: true,
FinishReason: string(choice.FinishReason),
}
}
}
// processToolCallsDelta 处理 tool calls 的增量更新
func (c *RemoteAPIChat) processToolCallsDelta(
ctx context.Context,
toolCalls []openai.ToolCall,
state *streamState,
streamChan chan types.StreamResponse,
) {
// Earliest signal at the OpenAI-protocol level that this stream will
// produce at least one tool call. Fires *before* the function name has
// stabilized, i.e. earlier than the higher-level ResponseTypeToolCall
// marker downstream consumers see. Useful for distinguishing
// "tool_calls field arrived but marker not yet emitted" from
// "tool_calls field truly absent" when triaging stream behavior.
if !state.firstToolCallSeen && len(toolCalls) > 0 {
state.firstToolCallSeen = true
var firstID, firstName string
for _, tc := range toolCalls {
if tc.ID != "" {
firstID = tc.ID
}
if tc.Function.Name != "" {
firstName = tc.Function.Name
}
if firstID != "" || firstName != "" {
break
}
}
logger.Infof(ctx, "[LLM Stream] First tool_calls delta at OpenAI layer "+
"(count=%d, first_id=%q, first_name=%q, "+
"first_content_seen=%t, thinking_seen=%t, elapsed_ms=%d)",
len(toolCalls), firstID, firstName,
state.firstContentSeen, state.firstReasoningSeen, state.elapsedMs())
}
for _, tc := range toolCalls {
var toolCallIndex int
if tc.Index != nil {
toolCallIndex = *tc.Index
}
toolCallEntry, exists := state.toolCallMap[toolCallIndex]
if !exists || toolCallEntry == nil {
toolCallEntry = &types.LLMToolCall{
Type: string(tc.Type),
Function: types.FunctionCall{
Name: "",
Arguments: "",
},
}
state.toolCallMap[toolCallIndex] = toolCallEntry
}
if tc.ID != "" {
toolCallEntry.ID = tc.ID
}
if tc.Type != "" {
toolCallEntry.Type = string(tc.Type)
}
if tc.Function.Name != "" {
// 防御性校验:解决部分供应商(如vLLM Ascend等)在每个流 Chunk 中重复发送完整工具名的问题。
// 如果当前已存名字与新收到名字一致,则视为冗余重复,不进行叠加。
if toolCallEntry.Function.Name != tc.Function.Name {
toolCallEntry.Function.Name += tc.Function.Name
}
}
argsUpdated := false
if tc.Function.Arguments != "" {
toolCallEntry.Function.Arguments += tc.Function.Arguments
argsUpdated = true
}
currName := toolCallEntry.Function.Name
var progressArgs map[string]any
if isSandboxMutationTool(currName) && argsUpdated && tc.Function.Arguments != "" {
prog := state.fileProgress[toolCallIndex]
if prog == nil {
prog = newSandboxFileProgress(currName)
state.fileProgress[toolCallIndex] = prog
}
if payload, ok := prog.Feed(tc.Function.Arguments); ok {
progressArgs = payload
}
}
if currName != "" &&
currName == state.lastFunctionName[toolCallIndex] &&
argsUpdated &&
!state.nameNotified[toolCallIndex] &&
toolCallEntry.ID != "" {
data := map[string]interface{}{
"tool_name": currName,
"tool_call_id": toolCallEntry.ID,
}
if progressArgs != nil {
data["arguments"] = progressArgs
}
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeToolCall,
Content: "",
Done: false,
Data: data,
}
state.nameNotified[toolCallIndex] = true
progressArgs = nil
} else if progressArgs != nil && toolCallEntry.ID != "" && currName != "" {
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeToolCall,
Content: "",
Done: false,
Data: map[string]interface{}{
"tool_name": currName,
"tool_call_id": toolCallEntry.ID,
"arguments": progressArgs,
},
}
}
state.lastFunctionName[toolCallIndex] = currName
// Stream thinking tool's thought field as thinking-type chunks
if toolCallEntry.Function.Name == "thinking" && argsUpdated {
extractor, exists := state.fieldExtractors[toolCallIndex]
if !exists {
extractor = newJSONFieldExtractor("thought")
state.fieldExtractors[toolCallIndex] = extractor
}
thoughtChunk := extractor.Feed(tc.Function.Arguments)
if thoughtChunk != "" {
streamChan <- types.StreamResponse{
ResponseType: types.ResponseTypeThinking,
Content: thoughtChunk,
Done: false,
Data: map[string]interface{}{
"source": "thinking_tool",
"tool_call_id": toolCallEntry.ID,
},
}
}
}
}
}
+374
View File
@@ -0,0 +1,374 @@
package chat
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/Tencent/WeKnora/internal/models/provider"
"github.com/Tencent/WeKnora/internal/types"
"github.com/sashabaranov/go-openai"
)
// FingerprintPromptPrefix returns a short, non-reversible identifier suitable
// for logs and cache routing. Raw prompts must never be used as metric labels.
func FingerprintPromptPrefix(parts ...string) string {
h := sha256.New()
for _, part := range parts {
_, _ = h.Write([]byte(part))
_, _ = h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil))[:16]
}
// PromptPrefixFingerprint hashes the stable portion common to normal chat and
// agent requests: leading system messages plus the deterministic tool schema.
// Dynamic conversation/user messages intentionally do not participate.
func PromptPrefixFingerprint(messages []Message, opts *ChatOptions) string {
type stablePrefix struct {
System []Message `json:"system,omitempty"`
Tools []Tool `json:"tools,omitempty"`
}
prefix := stablePrefix{}
for _, message := range messages {
if message.Role != "system" {
break
}
prefix.System = append(prefix.System, message)
}
if opts != nil {
prefix.Tools = opts.Tools
}
data, _ := json.Marshal(prefix)
return FingerprintPromptPrefix(string(data))
}
// BuildPromptCacheKey derives an opaque process-local coordination key.
// Tenant and model identifiers are hashed rather than retained in memory.
func BuildPromptCacheKey(tenantID uint64, modelID, purpose, prefixFingerprint string) string {
return "wk-" + FingerprintPromptPrefix(
fmt.Sprintf("%d", tenantID), modelID, purpose, prefixFingerprint,
)
}
func providerCacheAccountingStatus(name provider.ProviderName) types.PromptCacheStatus {
switch name {
case provider.ProviderOpenAI,
provider.ProviderAzureOpenAI,
provider.ProviderDeepSeek,
provider.ProviderAliyun,
provider.ProviderAnthropic:
return types.PromptCacheStatusUnreported
default:
return types.PromptCacheStatusUnsupported
}
}
func tokenUsageFromOpenAI(usage openai.Usage, providerName provider.ProviderName) types.TokenUsage {
u := types.TokenUsage{
PromptTokens: usage.PromptTokens,
CompletionTokens: usage.CompletionTokens,
TotalTokens: usage.TotalTokens,
}
if usage.PromptTokensDetails != nil {
read := usage.PromptTokensDetails.CachedTokens
u.SetPromptCacheUsage(read, 0, max(0, usage.PromptTokens-read), true)
return u
}
if providerCacheAccountingStatus(providerName) == types.PromptCacheStatusUnsupported {
u.MarkPromptCacheUnsupported()
} else {
u.SetPromptCacheUsage(0, 0, 0, false)
}
return u
}
// cachedTokens is retained as the nil-safe primitive used by older callers
// and focused tests; normalization happens in tokenUsageFromOpenAI.
func cachedTokens(details *openai.PromptTokensDetails) int {
if details == nil {
return 0
}
return details.CachedTokens
}
type rawPromptCacheUsage struct {
Usage struct {
PromptTokens int `json:"prompt_tokens"`
PromptCacheHit *int `json:"prompt_cache_hit_tokens"`
PromptCacheMiss *int `json:"prompt_cache_miss_tokens"`
CacheReadInput *int `json:"cache_read_input_tokens"`
CacheCreationInput *int `json:"cache_creation_input_tokens"`
PromptTokensDetails *struct {
CachedTokens *int `json:"cached_tokens"`
CacheWriteTokens *int `json:"cache_write_tokens"`
} `json:"prompt_tokens_details"`
} `json:"usage"`
}
// applyRawPromptCacheUsage captures native fields discarded by the pinned
// OpenAI-compatible SDK (notably DeepSeek hit/miss counters).
func applyRawPromptCacheUsage(data []byte, usage *types.TokenUsage) {
if usage == nil || len(data) == 0 {
return
}
var raw rawPromptCacheUsage
if json.Unmarshal(data, &raw) != nil {
return
}
if raw.Usage.PromptCacheHit != nil || raw.Usage.PromptCacheMiss != nil {
read := valueOrZero(raw.Usage.PromptCacheHit)
miss := valueOrZero(raw.Usage.PromptCacheMiss)
usage.SetPromptCacheUsage(read, 0, miss, true)
return
}
if raw.Usage.CacheReadInput != nil || raw.Usage.CacheCreationInput != nil {
read := valueOrZero(raw.Usage.CacheReadInput)
write := valueOrZero(raw.Usage.CacheCreationInput)
usage.SetPromptCacheUsage(read, write, max(0, usage.PromptTokens-read), true)
return
}
if details := raw.Usage.PromptTokensDetails; details != nil {
read := valueOrZero(details.CachedTokens)
write := valueOrZero(details.CacheWriteTokens)
usage.SetPromptCacheUsage(read, write, max(0, usage.PromptTokens-read), true)
}
}
func valueOrZero(value *int) int {
if value == nil {
return 0
}
return *value
}
// CacheRetention is the prompt-cache TTL preference. Empty means short (the
// default 5-minute provider cache). Compaction/summarization uses none so a
// different prompt prefix does not occupy the session's cache slot.
type CacheRetention string
const (
CacheRetentionNone CacheRetention = "none"
CacheRetentionShort CacheRetention = "short"
CacheRetentionLong CacheRetention = "long"
)
const openAIPromptCacheKeyMaxLength = 64
func clampPromptCacheKey(key string) string {
if key == "" {
return ""
}
runes := []rune(key)
if len(runes) <= openAIPromptCacheKeyMaxLength {
return key
}
return string(runes[:openAIPromptCacheKeyMaxLength])
}
func resolveCacheRetention(opts *ChatOptions) CacheRetention {
if opts != nil && opts.CacheRetention != "" {
return opts.CacheRetention
}
return CacheRetentionShort
}
func promptCacheSessionID(ctx context.Context, opts *ChatOptions) string {
if opts != nil && opts.PromptCacheKey != "" {
return clampPromptCacheKey(opts.PromptCacheKey)
}
if sessionID, ok := types.SessionIDFromContext(ctx); ok {
return clampPromptCacheKey(sessionID)
}
return ""
}
type promptCachePolicy struct {
sendKey bool
sendCacheControl bool
sendAffinity bool
}
func promptCachePolicyFor(name provider.ProviderName, baseURL string) promptCachePolicy {
switch name {
case provider.ProviderOpenAI, provider.ProviderAzureOpenAI, provider.ProviderOpenRouter:
return promptCachePolicy{sendKey: true, sendAffinity: true}
case provider.ProviderAliyun:
return promptCachePolicy{sendCacheControl: true}
case provider.ProviderAnthropic:
return promptCachePolicy{sendCacheControl: true}
}
if strings.Contains(baseURL, "api.openai.com") {
return promptCachePolicy{sendKey: true, sendAffinity: true}
}
return promptCachePolicy{}
}
type cacheControlMarker struct {
Type string `json:"type"`
TTL string `json:"ttl,omitempty"`
}
func cacheControlFor(retention CacheRetention, longTTL string) *cacheControlMarker {
if retention == CacheRetentionNone {
return nil
}
marker := &cacheControlMarker{Type: "ephemeral"}
if retention == CacheRetentionLong && longTTL != "" {
marker.TTL = longTTL
}
return marker
}
// applyPromptCacheToJSONBody injects provider cache routing and breakpoints
// into an already-shaped OpenAI-compatible request object. Returns the (possibly
// rewritten) body and whether the caller must send it via raw HTTP because the
// SDK struct cannot carry these fields.
func applyPromptCacheToJSONBody(
body any,
policy promptCachePolicy,
sessionID string,
retention CacheRetention,
) (any, bool, error) {
if retention == CacheRetentionNone {
return body, false, nil
}
if !policy.sendKey && !policy.sendCacheControl {
return body, false, nil
}
data, err := json.Marshal(body)
if err != nil {
return nil, false, err
}
var payload map[string]any
if err := json.Unmarshal(data, &payload); err != nil {
return nil, false, err
}
rewritten := false
if policy.sendKey && sessionID != "" {
payload["prompt_cache_key"] = sessionID
if retention == CacheRetentionLong {
payload["prompt_cache_retention"] = "24h"
}
rewritten = true
}
if policy.sendCacheControl {
marker := cacheControlFor(retention, "1h")
if marker != nil {
applyCacheControlBreakpoints(payload, marker)
rewritten = true
}
}
if !rewritten {
return body, false, nil
}
return payload, true, nil
}
func applyCacheControlBreakpoints(payload map[string]any, marker *cacheControlMarker) {
if marker == nil {
return
}
applyCacheControlToInstructionMessages(payload["messages"], marker)
applyCacheControlToLastTool(payload["tools"], marker)
applyCacheControlToLastConversationMessage(payload["messages"], marker)
}
func applyCacheControlToInstructionMessages(raw any, marker *cacheControlMarker) {
messages, ok := raw.([]any)
if !ok {
return
}
for _, item := range messages {
msg, ok := item.(map[string]any)
if !ok {
continue
}
role, _ := msg["role"].(string)
if role == "system" || role == "developer" {
addCacheControlToMessageContent(msg, marker)
return
}
}
}
func applyCacheControlToLastConversationMessage(raw any, marker *cacheControlMarker) {
messages, ok := raw.([]any)
if !ok {
return
}
for i := len(messages) - 1; i >= 0; i-- {
msg, ok := messages[i].(map[string]any)
if !ok {
continue
}
role, _ := msg["role"].(string)
if role == "user" || role == "assistant" || role == "tool" {
if addCacheControlToMessageContent(msg, marker) {
return
}
}
}
}
func applyCacheControlToLastTool(raw any, marker *cacheControlMarker) {
tools, ok := raw.([]any)
if !ok || len(tools) == 0 {
return
}
last, ok := tools[len(tools)-1].(map[string]any)
if !ok {
return
}
last["cache_control"] = marker
}
func addCacheControlToMessageContent(msg map[string]any, marker *cacheControlMarker) bool {
content, ok := msg["content"]
if !ok || content == nil {
return false
}
if text, ok := content.(string); ok {
if text == "" {
return false
}
msg["content"] = []any{
map[string]any{
"type": "text",
"text": text,
"cache_control": marker,
},
}
return true
}
parts, ok := content.([]any)
if !ok {
return false
}
for i := len(parts) - 1; i >= 0; i-- {
part, ok := parts[i].(map[string]any)
if !ok {
continue
}
if partType, _ := part["type"].(string); partType == "text" || partType == "tool_result" {
part["cache_control"] = marker
return true
}
}
return false
}
func attachPromptCacheHeaders(req *http.Request, policy promptCachePolicy, sessionID string) {
if req == nil || !policy.sendAffinity || sessionID == "" {
return
}
req.Header.Set("session_id", sessionID)
req.Header.Set("x-client-request-id", sessionID)
req.Header.Set("x-session-affinity", sessionID)
}
@@ -0,0 +1,117 @@
package chat
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/models/provider"
"github.com/Tencent/WeKnora/internal/types"
"github.com/sashabaranov/go-openai"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClampPromptCacheKey(t *testing.T) {
assert.Equal(t, "", clampPromptCacheKey(""))
assert.Equal(t, "sess-1", clampPromptCacheKey("sess-1"))
long := strings.Repeat("a", 80)
got := clampPromptCacheKey(long)
assert.Equal(t, 64, len([]rune(got)))
assert.Equal(t, strings.Repeat("a", 64), got)
}
func TestApplyPromptCacheToJSONBody_OpenAIKey(t *testing.T) {
req := openai.ChatCompletionRequest{
Model: "gpt-4o",
Messages: []openai.ChatCompletionMessage{
{Role: "system", Content: "sys"},
{Role: "user", Content: "hi"},
},
}
policy := promptCachePolicyFor(provider.ProviderOpenAI, "")
body, forceRaw, err := applyPromptCacheToJSONBody(req, policy, "sess-abc", CacheRetentionShort)
require.NoError(t, err)
require.True(t, forceRaw)
payload := body.(map[string]any)
assert.Equal(t, "sess-abc", payload["prompt_cache_key"])
assert.NotContains(t, payload, "prompt_cache_retention")
}
func TestApplyPromptCacheToJSONBody_OpenAILongRetention(t *testing.T) {
req := openai.ChatCompletionRequest{Model: "gpt-4o"}
policy := promptCachePolicyFor(provider.ProviderOpenAI, "")
body, forceRaw, err := applyPromptCacheToJSONBody(req, policy, "sess-abc", CacheRetentionLong)
require.NoError(t, err)
require.True(t, forceRaw)
payload := body.(map[string]any)
assert.Equal(t, "24h", payload["prompt_cache_retention"])
}
func TestApplyPromptCacheToJSONBody_NoneLeavesBodyUntouched(t *testing.T) {
req := &openai.ChatCompletionRequest{Model: "gpt-4o"}
policy := promptCachePolicyFor(provider.ProviderOpenAI, "")
body, forceRaw, err := applyPromptCacheToJSONBody(req, policy, "sess-abc", CacheRetentionNone)
require.NoError(t, err)
assert.False(t, forceRaw)
assert.Equal(t, req, body)
}
func TestApplyPromptCacheToJSONBody_AliyunCacheControlBreakpoints(t *testing.T) {
req := openai.ChatCompletionRequest{
Model: "qwen-plus",
Messages: []openai.ChatCompletionMessage{
{Role: "system", Content: "stable system"},
{Role: "user", Content: "turn question"},
},
Tools: []openai.Tool{{
Type: openai.ToolTypeFunction,
Function: &openai.FunctionDefinition{Name: "search", Description: "d", Parameters: json.RawMessage(`{}`)},
}},
}
policy := promptCachePolicyFor(provider.ProviderAliyun, "")
body, forceRaw, err := applyPromptCacheToJSONBody(req, policy, "", CacheRetentionShort)
require.NoError(t, err)
require.True(t, forceRaw)
wire, err := json.Marshal(body)
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(wire, &payload))
messages := payload["messages"].([]any)
sys := messages[0].(map[string]any)
sysParts := sys["content"].([]any)
sysCache := sysParts[0].(map[string]any)["cache_control"].(map[string]any)
assert.Equal(t, "ephemeral", sysCache["type"])
user := messages[1].(map[string]any)
userParts := user["content"].([]any)
userCache := userParts[0].(map[string]any)["cache_control"].(map[string]any)
assert.Equal(t, "ephemeral", userCache["type"])
tools := payload["tools"].([]any)
lastTool := tools[len(tools)-1].(map[string]any)
toolCache := lastTool["cache_control"].(map[string]any)
assert.Equal(t, "ephemeral", toolCache["type"])
}
func TestBuildOutbound_OpenAIPromptCacheKeyFromSession(t *testing.T) {
c := newOutboundChat(t, string(provider.ProviderOpenAI), "gpt-4o", nil)
ctx := types.WithSessionID(context.Background(), "sess-live")
body, _, useRaw, err := c.buildOutbound(ctx, []Message{{Role: "user", Content: "hi"}}, &ChatOptions{}, false)
require.NoError(t, err)
require.True(t, useRaw)
payload := body.(map[string]any)
assert.Equal(t, "sess-live", payload["prompt_cache_key"])
}
func TestAttachPromptCacheHeaders(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, "https://api.openai.com/v1/chat/completions", nil)
require.NoError(t, err)
attachPromptCacheHeaders(req, promptCachePolicy{sendAffinity: true}, "sess-1")
assert.Equal(t, "sess-1", req.Header.Get("session_id"))
assert.Equal(t, "sess-1", req.Header.Get("x-client-request-id"))
assert.Equal(t, "sess-1", req.Header.Get("x-session-affinity"))
}
+305
View File
@@ -0,0 +1,305 @@
package chat
import (
"encoding/json"
"net/http"
"strings"
"github.com/Tencent/WeKnora/internal/models/provider"
modelutils "github.com/Tencent/WeKnora/internal/models/utils"
"github.com/Tencent/WeKnora/internal/types"
"github.com/google/uuid"
"github.com/sashabaranov/go-openai"
)
// authCreds carries the credentials a providerAdapter needs to authenticate a
// raw HTTP request. APIKey covers the common Bearer / api-key cases; AppID and
// AppSecret are only used by signing providers (WeKnoraCloud).
type authCreds struct {
APIKey string
AppID string
AppSecret string
}
// providerAdapter captures everything provider-specific about an
// OpenAI-compatible chat backend. Every method has a sensible default on
// baseProvider, so a new provider is added by embedding baseProvider and
// overriding only the one or two methods that actually differ.
type providerAdapter interface {
// Name is the provider this adapter handles.
Name() provider.ProviderName
// Matches reports whether this adapter applies to the given model name.
// Used for sub-provider routing (e.g. Qwen thinking models within Aliyun,
// reasoning models within OpenAI). Default: true.
Matches(model string) bool
// Thinking is how this provider encodes ChatOptions.Thinking. Default: none.
Thinking() ThinkingStrategy
// ShapeRequest applies in-place parameter quirks to the standard request
// (stripping unsupported fields, pinning temperature, …). Default: noop.
ShapeRequest(req *openai.ChatCompletionRequest, opts *ChatOptions, isStream bool)
// TransformMessages rewrites the converted messages (e.g. downgrading
// multi-content to plain text). Default: identity.
TransformMessages(msgs []openai.ChatCompletionMessage) []openai.ChatCompletionMessage
// Endpoint overrides the request URL. Empty string means the standard
// "<baseURL>/chat/completions" handled by the caller. Default: "".
Endpoint(baseURL, modelID string, isStream bool) string
// Auth sets authentication headers on a raw HTTP request. Default: Bearer.
Auth(req *http.Request, creds authCreds, body []byte)
// ForceRawHTTP forces the raw HTTP path even when the body is standard
// (needed by providers that must sign the exact request bytes). Default: false.
ForceRawHTTP() bool
// ExtractToolCallMetadata captures provider-specific state from a raw
// OpenAI-compatible tool_call object. Default: nil.
ExtractToolCallMetadata(raw json.RawMessage) types.ToolCallMetadata
// InjectToolCallMetadata writes provider-specific state back into an outbound
// OpenAI-compatible tool_call object. Default: noop.
InjectToolCallMetadata(toolCall map[string]any, metadata types.ToolCallMetadata)
}
// baseProvider supplies the default behavior for every providerAdapter method.
// It is also the fallback returned by resolveProvider for unknown providers:
// Bearer auth, standard endpoint, no thinking, no request shaping.
type baseProvider struct{}
func (baseProvider) Name() provider.ProviderName { return "" }
func (baseProvider) Matches(string) bool { return true }
func (baseProvider) Thinking() ThinkingStrategy { return noThinking{} }
func (baseProvider) ShapeRequest(*openai.ChatCompletionRequest, *ChatOptions, bool) {}
func (baseProvider) TransformMessages(msgs []openai.ChatCompletionMessage) []openai.ChatCompletionMessage {
return msgs
}
func (baseProvider) Endpoint(string, string, bool) string { return "" }
func (baseProvider) Auth(req *http.Request, creds authCreds, _ []byte) {
req.Header.Set("Authorization", "Bearer "+creds.APIKey)
}
func (baseProvider) ForceRawHTTP() bool { return false }
func (baseProvider) ExtractToolCallMetadata(json.RawMessage) types.ToolCallMetadata {
return nil
}
func (baseProvider) InjectToolCallMetadata(map[string]any, types.ToolCallMetadata) {}
// --- WeKnoraCloud: custom endpoint + request signing + multi-content downgrade ---
type weKnoraCloudProvider struct{ baseProvider }
func (weKnoraCloudProvider) Name() provider.ProviderName { return provider.ProviderWeKnoraCloud }
func (weKnoraCloudProvider) Endpoint(baseURL, _ string, _ bool) string {
return strings.TrimRight(baseURL, "/") + "/api/v1/chat/completions"
}
func (weKnoraCloudProvider) ForceRawHTTP() bool { return true }
func (weKnoraCloudProvider) Auth(req *http.Request, creds authCreds, body []byte) {
requestID := uuid.NewString()
headers := modelutils.Sign(creds.AppID, creds.AppSecret, requestID, string(body))
for k, v := range headers {
req.Header.Set(k, v)
}
}
// TransformMessages downgrades MultiContent to plain text while preserving
// tool_calls / tool_call_id / name so the function-calling protocol keeps working.
func (weKnoraCloudProvider) TransformMessages(messages []openai.ChatCompletionMessage) []openai.ChatCompletionMessage {
result := make([]openai.ChatCompletionMessage, 0, len(messages))
for _, m := range messages {
msg := m
if msg.Content == "" && len(msg.MultiContent) > 0 {
var textParts []string
for _, part := range msg.MultiContent {
if part.Type == openai.ChatMessagePartTypeText && part.Text != "" {
textParts = append(textParts, part.Text)
}
}
msg.Content = strings.Join(textParts, "\n")
msg.MultiContent = nil
}
result = append(result, msg)
}
return result
}
// --- Aliyun Qwen thinking models: enable_thinking (always sent, forced off non-stream) ---
type qwenThinkingProvider struct{ baseProvider }
func (qwenThinkingProvider) Name() provider.ProviderName { return provider.ProviderAliyun }
func (qwenThinkingProvider) Matches(model string) bool { return provider.IsQwenThinkingModel(model) }
func (qwenThinkingProvider) Thinking() ThinkingStrategy {
return enableThinking{alwaysSend: true, disableOnNonStream: true}
}
// --- LKEAP: thinking via { "thinking": { "type": ... } }, only for DeepSeek V3.x ---
// R1 series enables chain-of-thought by default and is left untouched (falls
// back to baseProvider). See https://cloud.tencent.com/document/product/1772/115963
type lkeapProvider struct{ baseProvider }
func (lkeapProvider) Name() provider.ProviderName { return provider.ProviderLKEAP }
func (lkeapProvider) Matches(model string) bool {
return strings.Contains(strings.ToLower(model), "deepseek-v3")
}
func (lkeapProvider) Thinking() ThinkingStrategy { return thinkingTypeField{} }
// --- DeepSeek: does not support tool_choice ---
type deepseekProvider struct{ baseProvider }
func (deepseekProvider) Name() provider.ProviderName { return provider.ProviderDeepSeek }
func (deepseekProvider) Thinking() ThinkingStrategy { return thinkingTypeField{} }
// Native DeepSeek cache counters are not represented by go-openai v1.41.2;
// use the raw path so prompt_cache_hit_tokens/miss_tokens remain observable.
func (deepseekProvider) ForceRawHTTP() bool { return true }
func (deepseekProvider) ShapeRequest(req *openai.ChatCompletionRequest, opts *ChatOptions, _ bool) {
if opts != nil && opts.ToolChoice != "" {
req.ToolChoice = nil
}
}
// --- Generic (vLLM) / NVIDIA / LiteLLM: thinking via chat_template_kwargs ---
type genericProvider struct{ baseProvider }
func (genericProvider) Name() provider.ProviderName { return provider.ProviderGeneric }
func (genericProvider) Thinking() ThinkingStrategy { return chatTemplateKwargs{} }
type nvidiaProvider struct{ baseProvider }
func (nvidiaProvider) Name() provider.ProviderName { return provider.ProviderNvidia }
func (nvidiaProvider) Thinking() ThinkingStrategy { return chatTemplateKwargs{} }
type liteLLMProvider struct{ baseProvider }
func (liteLLMProvider) Name() provider.ProviderName { return provider.ProviderLiteLLM }
func (liteLLMProvider) Thinking() ThinkingStrategy { return chatTemplateKwargs{} }
// --- Gemini OpenAI compatibility: tool thought signatures live in extra_content ---
type geminiProvider struct{ baseProvider }
func (geminiProvider) Name() provider.ProviderName { return provider.ProviderGemini }
func (geminiProvider) ForceRawHTTP() bool { return true }
func (geminiProvider) ExtractToolCallMetadata(raw json.RawMessage) types.ToolCallMetadata {
var tc struct {
ExtraContent map[string]json.RawMessage `json:"extra_content,omitempty"`
}
if err := json.Unmarshal(raw, &tc); err != nil {
return nil
}
google, ok := tc.ExtraContent["google"]
if !ok || len(google) == 0 {
return nil
}
return types.ToolCallMetadata{"google": google}
}
func (geminiProvider) InjectToolCallMetadata(toolCall map[string]any, metadata types.ToolCallMetadata) {
if len(metadata) == 0 {
return
}
google, ok := metadata["google"]
if !ok || len(google) == 0 {
return
}
var googleValue any
if err := json.Unmarshal(google, &googleValue); err != nil {
return
}
toolCall["extra_content"] = map[string]any{"google": googleValue}
}
// --- Volcengine (火山引擎 Ark): thinking via { "thinking": { "type": ... } } ---
type volcengineProvider struct{ baseProvider }
func (volcengineProvider) Name() provider.ProviderName { return provider.ProviderVolcengine }
func (volcengineProvider) Thinking() ThinkingStrategy { return thinkingTypeField{} }
// --- Azure OpenAI: api-key auth (reasoning variant also strips sampling params) ---
type azureProvider struct{ baseProvider }
func (azureProvider) Name() provider.ProviderName { return provider.ProviderAzureOpenAI }
func (azureProvider) Auth(req *http.Request, creds authCreds, _ []byte) {
req.Header.Set("api-key", creds.APIKey)
}
type azureReasoningProvider struct{ azureProvider }
func (azureReasoningProvider) Matches(model string) bool {
return provider.IsOpenAIReasoningOrGPT5Model(model)
}
func (azureReasoningProvider) ShapeRequest(req *openai.ChatCompletionRequest, _ *ChatOptions, _ bool) {
shapeOpenAIReasoning(req)
}
// --- OpenAI reasoning / GPT-5: no sampling params, must use max_completion_tokens ---
type openAIReasoningProvider struct{ baseProvider }
func (openAIReasoningProvider) Name() provider.ProviderName { return provider.ProviderOpenAI }
func (openAIReasoningProvider) Matches(model string) bool {
return provider.IsOpenAIReasoningOrGPT5Model(model)
}
func (openAIReasoningProvider) ShapeRequest(req *openai.ChatCompletionRequest, _ *ChatOptions, _ bool) {
shapeOpenAIReasoning(req)
}
// --- Moonshot: v1 models accept only temperature=1 ---
type moonshotProvider struct{ baseProvider }
func (moonshotProvider) Name() provider.ProviderName { return provider.ProviderMoonshot }
func (moonshotProvider) Matches(model string) bool {
return provider.IsMoonshotFixedTempModel(model)
}
func (moonshotProvider) ShapeRequest(req *openai.ChatCompletionRequest, _ *ChatOptions, _ bool) {
// Pin temperature to 1 and drop the other sampling params, matching the
// pre-refactor behavior where these fields were never set for this model.
req.Temperature = 1
req.TopP = 0
req.FrequencyPenalty = 0
req.PresencePenalty = 0
}
// shapeOpenAIReasoning strips sampling params (unsupported by o-series / GPT-5)
// and migrates max_tokens to max_completion_tokens. See issue #1283.
func shapeOpenAIReasoning(req *openai.ChatCompletionRequest) {
req.Temperature = 0
req.TopP = 0
req.FrequencyPenalty = 0
req.PresencePenalty = 0
if req.MaxCompletionTokens == 0 && req.MaxTokens > 0 {
req.MaxCompletionTokens = req.MaxTokens
}
req.MaxTokens = 0
}
// providerRegistry is ordered: more specific adapters (those with a real
// Matches predicate) must precede the generic catch-all for the same provider.
var providerRegistry = []providerAdapter{
weKnoraCloudProvider{},
qwenThinkingProvider{},
lkeapProvider{},
deepseekProvider{},
genericProvider{},
liteLLMProvider{},
geminiProvider{},
volcengineProvider{},
nvidiaProvider{},
azureReasoningProvider{},
azureProvider{},
openAIReasoningProvider{},
moonshotProvider{},
}
// resolveProvider returns the adapter handling the given provider+model, or
// baseProvider{} (Bearer auth, standard endpoint, no thinking) when none matches.
func resolveProvider(name provider.ProviderName, model string) providerAdapter {
for _, p := range providerRegistry {
if p.Name() == name && p.Matches(model) {
return p
}
}
return baseProvider{}
}
+229
View File
@@ -0,0 +1,229 @@
package chat
import (
"context"
"encoding/json"
"testing"
"github.com/Tencent/WeKnora/internal/models/provider"
"github.com/Tencent/WeKnora/internal/types"
"github.com/sashabaranov/go-openai"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestResolveProvider pins the provider+model routing table, including the
// sub-model matchers (reasoning models, Qwen thinking, LKEAP DeepSeek V3) and
// the baseProvider fallback for everything else.
func TestResolveProvider(t *testing.T) {
cases := []struct {
name string
prov provider.ProviderName
model string
want providerAdapter
}{
{"deepseek", provider.ProviderDeepSeek, "deepseek-chat", deepseekProvider{}},
{"lkeap v3", provider.ProviderLKEAP, "deepseek-v3.1", lkeapProvider{}},
{"lkeap r1 falls back", provider.ProviderLKEAP, "deepseek-r1", baseProvider{}},
{"qwen thinking", provider.ProviderAliyun, "qwen3-32b", qwenThinkingProvider{}},
{"generic", provider.ProviderGeneric, "anything", genericProvider{}},
{"litellm", provider.ProviderLiteLLM, "anything", liteLLMProvider{}},
{"gemini", provider.ProviderGemini, "gemini-3-flash-preview", geminiProvider{}},
{"nvidia", provider.ProviderNvidia, "anything", nvidiaProvider{}},
{"volcengine", provider.ProviderVolcengine, "doubao", volcengineProvider{}},
{"openai non-reasoning falls back", provider.ProviderOpenAI, "gpt-4o", baseProvider{}},
{"openai reasoning", provider.ProviderOpenAI, "gpt-5", openAIReasoningProvider{}},
{"azure non-reasoning", provider.ProviderAzureOpenAI, "gpt-4", azureProvider{}},
{"azure reasoning", provider.ProviderAzureOpenAI, "gpt-5-mini", azureReasoningProvider{}},
{"moonshot fixed temp", provider.ProviderMoonshot, "moonshot-v1-8k", moonshotProvider{}},
{"moonshot other falls back", provider.ProviderMoonshot, "kimi-latest", baseProvider{}},
{"weknora cloud", provider.ProviderWeKnoraCloud, "anything", weKnoraCloudProvider{}},
{"unknown falls back", provider.ProviderName("nope"), "x", baseProvider{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.IsType(t, tc.want, resolveProvider(tc.prov, tc.model))
})
}
}
func newOutboundChat(t *testing.T, providerName, model string, extra map[string]string) *RemoteAPIChat {
t.Helper()
c, err := NewRemoteAPIChat(&ChatConfig{
Source: types.ModelSourceRemote,
ModelName: model,
APIKey: "k",
ModelID: model,
Provider: providerName,
ExtraConfig: extra,
})
require.NoError(t, err)
return c
}
// TestBuildOutbound_Thinking is the characterization suite for the merged
// thinking-control path: it asserts that buildOutbound produces the same wire
// formats the pre-refactor provider customizers did.
func TestBuildOutbound_Thinking(t *testing.T) {
msgs := []Message{{Role: "user", Content: "hi"}}
t.Run("deepseek honors explicit thinking controls", func(t *testing.T) {
for _, stream := range []bool{false, true} {
for _, enabled := range []bool{false, true} {
c := newOutboundChat(t, string(provider.ProviderDeepSeek), "deepseek-v4-flash", nil)
body, _, useRaw, err := c.buildOutbound(context.Background(), msgs, &ChatOptions{
Thinking: ptrBool(enabled), MaxTokens: 4096,
}, stream)
require.NoError(t, err)
require.True(t, useRaw)
var request map[string]any
require.NoError(t, json.Unmarshal([]byte(mustJSON(t, body)), &request))
want := "disabled"
if enabled {
want = "enabled"
}
assert.Equal(t, map[string]any{"type": want}, request["thinking"])
assert.EqualValues(t, 4096, request["max_tokens"])
assert.NotContains(t, request, "chat_template_kwargs")
}
}
})
t.Run("deepseek preserves provider default when thinking is unset", func(t *testing.T) {
c := newOutboundChat(t, string(provider.ProviderDeepSeek), "deepseek-v4-flash", nil)
body, _, _, err := c.buildOutbound(context.Background(), msgs, &ChatOptions{MaxTokens: 4096}, false)
require.NoError(t, err)
assert.NotContains(t, mustJSON(t, body), `"thinking"`)
})
t.Run("generic explicit thinking_type overrides legacy kwargs", func(t *testing.T) {
c := newOutboundChat(t, string(provider.ProviderGeneric), "deepseek-v4-flash",
map[string]string{ExtraConfigThinkingControl: "thinking_type"})
body, _, useRaw, err := c.buildOutbound(context.Background(), msgs, &ChatOptions{Thinking: ptrBool(false)}, true)
require.NoError(t, err)
require.True(t, useRaw)
js := mustJSON(t, body)
assert.Contains(t, js, `"thinking"`)
assert.Contains(t, js, `"disabled"`)
assert.NotContains(t, js, "chat_template_kwargs")
})
t.Run("generic legacy chat_template_kwargs", func(t *testing.T) {
c := newOutboundChat(t, string(provider.ProviderGeneric), "qwen", nil)
body, _, useRaw, err := c.buildOutbound(context.Background(), msgs, &ChatOptions{Thinking: ptrBool(false)}, true)
require.NoError(t, err)
require.True(t, useRaw)
assert.Contains(t, mustJSON(t, body), "chat_template_kwargs")
})
t.Run("none keeps the standard SDK request", func(t *testing.T) {
c := newOutboundChat(t, string(provider.ProviderGeneric), "x",
map[string]string{ExtraConfigThinkingControl: "none"})
body, _, useRaw, err := c.buildOutbound(context.Background(), msgs, &ChatOptions{Thinking: ptrBool(false)}, true)
require.NoError(t, err)
assert.False(t, useRaw)
_, ok := body.(*openai.ChatCompletionRequest)
assert.True(t, ok)
})
t.Run("qwen non-stream forces disabled", func(t *testing.T) {
c := newOutboundChat(t, string(provider.ProviderAliyun), "qwen3-32b", nil)
body, _, useRaw, err := c.buildOutbound(context.Background(), msgs, &ChatOptions{Thinking: ptrBool(true)}, false)
require.NoError(t, err)
require.True(t, useRaw)
assert.Contains(t, mustJSON(t, body), `"enable_thinking":false`)
})
t.Run("qwen stream honors requested true", func(t *testing.T) {
c := newOutboundChat(t, string(provider.ProviderAliyun), "qwen3-32b", nil)
body, _, _, err := c.buildOutbound(context.Background(), msgs, &ChatOptions{Thinking: ptrBool(true)}, true)
require.NoError(t, err)
assert.Contains(t, mustJSON(t, body), `"enable_thinking":true`)
})
t.Run("volcengine thinking enabled", func(t *testing.T) {
c := newOutboundChat(t, string(provider.ProviderVolcengine), "doubao", nil)
body, _, useRaw, err := c.buildOutbound(context.Background(), msgs, &ChatOptions{Thinking: ptrBool(true)}, true)
require.NoError(t, err)
require.True(t, useRaw)
js := mustJSON(t, body)
assert.Contains(t, js, `"thinking"`)
assert.Contains(t, js, `"enabled"`)
})
t.Run("lkeap deepseek-v3 emits thinking type", func(t *testing.T) {
c := newOutboundChat(t, string(provider.ProviderLKEAP), "deepseek-v3.1", nil)
body, _, useRaw, err := c.buildOutbound(context.Background(), msgs, &ChatOptions{Thinking: ptrBool(false)}, true)
require.NoError(t, err)
require.True(t, useRaw)
assert.Contains(t, mustJSON(t, body), `"thinking"`)
})
t.Run("lkeap r1 left untouched", func(t *testing.T) {
c := newOutboundChat(t, string(provider.ProviderLKEAP), "deepseek-r1", nil)
body, _, useRaw, err := c.buildOutbound(context.Background(), msgs, &ChatOptions{Thinking: ptrBool(false)}, true)
require.NoError(t, err)
assert.False(t, useRaw)
_, ok := body.(*openai.ChatCompletionRequest)
assert.True(t, ok)
})
}
// TestBuildOutbound_ShapeRequest covers the param-shaping providers that used
// to live inline in BuildChatCompletionRequest.
func TestBuildOutbound_ShapeRequest(t *testing.T) {
msgs := []Message{{Role: "user", Content: "hi"}}
t.Run("deepseek strips tool_choice", func(t *testing.T) {
c := newOutboundChat(t, string(provider.ProviderDeepSeek), "deepseek-chat", nil)
body, _, useRaw, err := c.buildOutbound(context.Background(), msgs, &ChatOptions{ToolChoice: "auto"}, false)
require.NoError(t, err)
assert.True(t, useRaw)
request, ok := body.(map[string]any)
require.True(t, ok)
assert.NotContains(t, request, "tool_choice")
})
t.Run("moonshot pins temperature to 1", func(t *testing.T) {
c := newOutboundChat(t, string(provider.ProviderMoonshot), "moonshot-v1-8k", nil)
body, _, _, err := c.buildOutbound(context.Background(), msgs, &ChatOptions{Temperature: 0.7, TopP: 0.9}, false)
require.NoError(t, err)
req := body.(*openai.ChatCompletionRequest)
assert.EqualValues(t, 1, req.Temperature)
assert.EqualValues(t, 0, req.TopP)
})
}
func TestBuildOutbound_GeminiProviderMetadata(t *testing.T) {
c := newOutboundChat(t, string(provider.ProviderGemini), "gemini-3-flash-preview", nil)
messages := []Message{
{Role: "user", Content: "find docs"},
{
Role: "assistant",
ToolCalls: []ToolCall{{
ID: "call_1",
Type: "function",
ProviderMetadata: types.ToolCallMetadata{"google": json.RawMessage(`{"thought_signature":"gemini-signature"}`)},
Function: FunctionCall{
Name: "wiki_search",
Arguments: `{"query":"MACS"}`,
},
}},
},
}
body, _, useRaw, err := c.buildOutbound(context.Background(), messages, &ChatOptions{}, false)
require.NoError(t, err)
require.True(t, useRaw)
js := mustJSON(t, body)
assert.Contains(t, js, `"extra_content"`)
assert.Contains(t, js, `"thought_signature":"gemini-signature"`)
}
func mustJSON(t *testing.T, v any) string {
t.Helper()
b, err := json.Marshal(v)
require.NoError(t, err)
return string(b)
}
+440
View File
@@ -0,0 +1,440 @@
package chat
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/models/provider"
"github.com/Tencent/WeKnora/internal/models/requestbudget"
"github.com/Tencent/WeKnora/internal/types"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/sashabaranov/go-openai"
)
// RemoteAPIChat 实现了基于 OpenAI 兼容 API 的聊天。
// 它本身只负责通用的请求/响应/流式处理;所有 provider 特定行为都委托给
// providerAdapter(见 provider.go),thinking 编码委托给 ThinkingStrategy
// (见 thinking.go)。
type RemoteAPIChat struct {
modelName string
client *openai.Client
modelID string
baseURL string
apiKey string
provider provider.ProviderName
appID string
appSecret string
// customHeaders 为用户在模型配置中指定的自定义 HTTP 请求头(类似 OpenAI Python SDK 的 extra_headers)。
customHeaders map[string]string
// adapter 承载所有 provider 特定行为(thinking / 参数特判 / endpoint / 鉴权 / 消息变换)。
adapter providerAdapter
// thinkingOverride 来自 extra_config.thinking_control,非 nil 时覆盖 adapter.Thinking()。
thinkingOverride ThinkingStrategy
}
// NewRemoteAPIChat 创建远程 API 聊天实例
func NewRemoteAPIChat(chatConfig *ChatConfig) (*RemoteAPIChat, error) {
if chatConfig.BaseURL != "" {
if err := secutils.ValidateURLForSSRF(chatConfig.BaseURL); err != nil {
return nil, fmt.Errorf("baseURL SSRF check failed: %w", err)
}
}
apiKey := chatConfig.APIKey
providerName := provider.ProviderName(chatConfig.Provider)
if providerName == "" {
providerName = provider.DetectProvider(chatConfig.BaseURL)
}
var config openai.ClientConfig
if providerName == provider.ProviderAzureOpenAI {
config = openai.DefaultAzureConfig(apiKey, chatConfig.BaseURL)
config.AzureModelMapperFunc = func(model string) string {
return model
}
if chatConfig.ExtraConfig != nil {
if v, ok := chatConfig.ExtraConfig["api_version"]; ok {
config.APIVersion = v
}
}
} else {
config = openai.DefaultConfig(apiKey)
if baseURL := chatConfig.BaseURL; baseURL != "" {
config.BaseURL = baseURL
} else if providerName == provider.ProviderDeepSeek {
config.BaseURL = provider.DeepSeekBaseURL
}
}
// The SDK must use the same SSRF-safe transport as the raw HTTP paths.
// Constructor-time URL validation alone cannot prevent DNS rebinding or a
// later redirect to an internal address.
sdkHTTPClient := rawHTTPClient
// 如果指定了 CustomHeaders,则给 SDK 使用的 HTTPClient 挂一层 RoundTripper,
// 在每个请求上自动注入这些 header(raw HTTP 路径会在发送前单独处理)。
if len(chatConfig.CustomHeaders) > 0 {
sdkHTTPClient = secutils.WrapHTTPClientWithHeaders(sdkHTTPClient, chatConfig.CustomHeaders)
}
config.HTTPClient = sdkHTTPClient
modelName := chatConfig.ModelName
if chatConfig.ExtraConfig != nil {
if override := strings.TrimSpace(chatConfig.ExtraConfig["remote_model_name"]); override != "" {
modelName = override
}
}
if providerName == provider.ProviderWeKnoraCloud {
if chatConfig.AppID == "" {
return nil, fmt.Errorf("WeKnoraCloud provider: AppID is required")
}
if chatConfig.AppSecret == "" {
return nil, fmt.Errorf("WeKnoraCloud provider: AppSecret is required")
}
}
return &RemoteAPIChat{
modelName: modelName,
client: openai.NewClientWithConfig(config),
modelID: chatConfig.ModelID,
baseURL: strings.TrimRight(config.BaseURL, "/"),
apiKey: apiKey,
provider: providerName,
appID: chatConfig.AppID,
appSecret: chatConfig.AppSecret,
customHeaders: chatConfig.CustomHeaders,
adapter: resolveProvider(providerName, modelName),
thinkingOverride: parseThinkingOverride(chatConfig.ExtraConfig),
}, nil
}
// authCreds bundles the credentials passed to the adapter's Auth method.
func (c *RemoteAPIChat) authCreds() authCreds {
return authCreds{APIKey: c.apiKey, AppID: c.appID, AppSecret: c.appSecret}
}
// shapedRequest builds the standard request and applies the adapter's message
// transform and parameter shaping (but not thinking, which may wrap the body).
func (c *RemoteAPIChat) shapedRequest(messages []Message, opts *ChatOptions, isStream bool) openai.ChatCompletionRequest {
req := c.BuildChatCompletionRequest(messages, opts, isStream)
req.Messages = c.adapter.TransformMessages(req.Messages)
c.adapter.ShapeRequest(&req, opts, isStream)
return req
}
// buildOutbound assembles the final outbound request: the body to send, the
// endpoint override (empty for the standard endpoint), and whether the raw HTTP
// path is required. This is the single place that composes adapter + thinking,
// replacing the former buildRequestCustomizer plumbing.
func (c *RemoteAPIChat) buildOutbound(
ctx context.Context, messages []Message, opts *ChatOptions, isStream bool,
) (body any, endpoint string, useRawHTTP bool, err error) {
req := c.shapedRequest(messages, opts, isStream)
thinking := c.thinkingOverride
if thinking == nil {
thinking = c.adapter.Thinking()
}
customBody, useRaw := thinking.Apply(&req, opts, isStream)
body = &req
if customBody != nil {
body = customBody
}
body, err = c.shapeProviderRequest(body, req, messages)
if err != nil {
return nil, "", false, err
}
retention := resolveCacheRetention(opts)
policy := promptCachePolicyFor(c.provider, c.baseURL)
sessionID := promptCacheSessionID(ctx, opts)
cachedBody, forceRaw, err := applyPromptCacheToJSONBody(body, policy, sessionID, retention)
if err != nil {
return nil, "", false, err
}
body = cachedBody
endpoint = c.adapter.Endpoint(c.baseURL, c.modelID, isStream)
useRawHTTP = useRaw || c.adapter.ForceRawHTTP() || endpoint != "" || forceRaw
return body, endpoint, useRawHTTP, nil
}
// logRequest 记录请求日志
func (c *RemoteAPIChat) logRequest(ctx context.Context, req any, isStream bool) {
if jsonData, err := json.MarshalIndent(req, "", " "); err == nil {
logger.Infof(ctx, "[LLM Request] model=%s, stream=%v, request:\n%s",
c.modelName, isStream, secutils.CompactImageDataURLForLog(string(jsonData)))
}
}
// Chat 进行非流式聊天
func (c *RemoteAPIChat) Chat(ctx context.Context, messages []Message, opts *ChatOptions) (*types.ChatResponse, error) {
ctx = requestbudget.WithModel(ctx, c.modelID)
if err := requestbudget.Check(ctx); err != nil {
return nil, err
}
// 仅在调用方未设置 deadline 时附加一个兜底超时,防止 hung 请求永久阻塞 worker;
// 调用方若显式设置了更短或更长的 deadline,都会被原样尊重。
timeoutCtx, cancel := withLLMTimeout(ctx, defaultChatTimeout)
defer cancel()
body, endpoint, useRawHTTP, err := c.buildOutbound(timeoutCtx, messages, opts, false)
if err != nil {
return nil, err
}
if useRawHTTP {
return c.chatWithRawHTTP(timeoutCtx, endpoint, body, opts)
}
req := *(body.(*openai.ChatCompletionRequest))
c.logRequest(timeoutCtx, req, false)
resp, err := c.client.CreateChatCompletion(timeoutCtx, req)
if err != nil {
if isMultimodalNotSupportedError(err) {
logger.Warnf(timeoutCtx, "[LLM Request] Model %s does not support multimodal, retrying without images", c.modelName)
cleaned := stripImagesFromMessages(messages)
req = c.shapedRequest(cleaned, opts, false)
resp, err = c.client.CreateChatCompletion(timeoutCtx, req)
}
if err != nil {
return nil, fmt.Errorf("create chat completion: %w", err)
}
}
result, err := c.parseCompletionResponse(&resp)
if err != nil {
return nil, err
}
logUsage(timeoutCtx, c.modelName, &result.Usage)
return result, nil
}
// chatWithRawHTTP 使用原始 HTTP 请求进行聊天(供自定义请求使用)
func (c *RemoteAPIChat) chatWithRawHTTP(ctx context.Context, endpoint string, customReq any, opts *ChatOptions) (*types.ChatResponse, error) {
ctx = requestbudget.WithModel(ctx, c.modelID)
jsonData, err := json.Marshal(customReq)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
if endpoint == "" {
endpoint = c.baseURL + "/chat/completions"
}
if err := secutils.ValidateURLForSSRF(endpoint); err != nil {
return nil, fmt.Errorf("endpoint SSRF check failed: %w", err)
}
logger.Infof(ctx, "[LLM Request] Remote HTTP, endpoint=%s, model=%s, raw HTTP request:\n%s",
endpoint, c.modelName, secutils.CompactImageDataURLForLog(string(jsonData)))
httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
c.adapter.Auth(httpReq, c.authCreds(), jsonData)
// 注入用户自定义 header(保留头会在工具内部自动跳过)
secutils.ApplyCustomHeaders(httpReq, c.customHeaders)
attachPromptCacheHeaders(httpReq, promptCachePolicyFor(c.provider, c.baseURL), promptCacheSessionID(ctx, opts))
logger.Infof(ctx, "[LLM Request] Remote HTTP, endpoint=%s, model=%s",
endpoint, c.modelName)
resp, err := rawHTTPClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
var chatResp openai.ChatCompletionResponse
if err := json.Unmarshal(body, &chatResp); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
result, err := c.parseCompletionResponse(&chatResp)
if err != nil {
return nil, err
}
c.applyCompletionToolCallMetadata(body, result)
applyRawPromptCacheUsage(body, &result.Usage)
logUsage(ctx, c.modelName, &result.Usage)
return result, nil
}
// ChatStream 进行流式聊天
func (c *RemoteAPIChat) ChatStream(ctx context.Context, messages []Message, opts *ChatOptions) (<-chan types.StreamResponse, error) {
ctx = requestbudget.WithModel(ctx, c.modelID)
if err := requestbudget.Check(ctx); err != nil {
return nil, err
}
// 仅在调用方未设置 deadline 时附加兜底超时;流式调用默认超时更长,
// 因为带思考/推理的模型可能数十秒甚至几分钟才产出首 token。
timeoutCtx, cancel := withLLMTimeout(ctx, defaultStreamTimeout)
body, endpoint, useRawHTTP, err := c.buildOutbound(timeoutCtx, messages, opts, true)
if err != nil {
cancel()
return nil, err
}
if useRawHTTP {
ch, err := c.chatStreamWithRawHTTP(timeoutCtx, endpoint, body, opts)
return wrapStreamCancel(ch, err, cancel)
}
req := *(body.(*openai.ChatCompletionRequest))
c.logRequest(timeoutCtx, req, true)
streamDumper := newStreamPacketDumper(c.modelName, &req)
if streamDumper != nil {
logger.Infof(timeoutCtx, "[LLM Stream Raw Dump] writing packets to %s", streamDumper.Path())
}
streamChan := make(chan types.StreamResponse)
stream, err := c.client.CreateChatCompletionStream(timeoutCtx, req)
if err != nil {
if isMultimodalNotSupportedError(err) {
logger.Warnf(timeoutCtx, "[LLM Stream] Model %s does not support multimodal, retrying without images", c.modelName)
cleaned := stripImagesFromMessages(messages)
req = c.shapedRequest(cleaned, opts, true)
stream, err = c.client.CreateChatCompletionStream(timeoutCtx, req)
}
if err != nil {
cancel()
close(streamChan)
return nil, fmt.Errorf("create chat completion stream: %w", err)
}
}
go func() {
defer cancel()
if streamDumper != nil {
defer streamDumper.Close()
}
c.processStream(timeoutCtx, stream, streamChan, streamDumper)
}()
return streamChan, nil
}
// wrapStreamCancel 在子 channel 关闭后执行 cancel,避免 timeout context 泄漏。
// 当底层调用直接返回 error 时,立即调用 cancel 并将 error 透出。
func wrapStreamCancel(in <-chan types.StreamResponse, err error, cancel context.CancelFunc) (<-chan types.StreamResponse, error) {
if err != nil {
cancel()
return nil, err
}
out := make(chan types.StreamResponse)
go func() {
defer cancel()
defer close(out)
for v := range in {
out <- v
}
}()
return out, nil
}
// chatStreamWithRawHTTP 使用原始 HTTP 请求进行流式聊天
func (c *RemoteAPIChat) chatStreamWithRawHTTP(ctx context.Context, endpoint string, customReq any, opts *ChatOptions) (<-chan types.StreamResponse, error) {
ctx = requestbudget.WithModel(ctx, c.modelID)
jsonData, err := json.Marshal(customReq)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
if endpoint == "" {
endpoint = c.baseURL + "/chat/completions"
}
if err := secutils.ValidateURLForSSRF(endpoint); err != nil {
return nil, fmt.Errorf("endpoint SSRF check failed: %w", err)
}
if prettyJSON, pErr := json.MarshalIndent(customReq, "", " "); pErr == nil {
logger.Infof(ctx, "[LLM Stream Request] endpoint=%s, model=%s, stream=true, request:\n%s",
endpoint, c.modelName, secutils.CompactImageDataURLForLog(string(prettyJSON)))
} else {
logger.Infof(ctx, "[LLM Stream] endpoint=%s, model=%s", endpoint, c.modelName)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
c.adapter.Auth(httpReq, c.authCreds(), jsonData)
httpReq.Header.Set("Accept", "text/event-stream")
// 注入用户自定义 header(保留头会在工具内部自动跳过)
secutils.ApplyCustomHeaders(httpReq, c.customHeaders)
attachPromptCacheHeaders(httpReq, promptCachePolicyFor(c.provider, c.baseURL), promptCacheSessionID(ctx, opts))
resp, err := rawHTTPClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
}
streamChan := make(chan types.StreamResponse)
streamDumper := newStreamPacketDumper(c.modelName, customReq)
if streamDumper != nil {
logger.Infof(ctx, "[LLM Stream Raw Dump] writing packets to %s", streamDumper.Path())
}
go func() {
if streamDumper != nil {
defer streamDumper.Close()
}
c.processRawHTTPStream(ctx, resp, streamChan, streamDumper)
}()
return streamChan, nil
}
// GetModelName 获取模型名称
func (c *RemoteAPIChat) GetModelName() string {
return c.modelName
}
// GetModelID 获取模型ID
func (c *RemoteAPIChat) GetModelID() string {
return c.modelID
}
// GetProvider 获取 provider 名称
func (c *RemoteAPIChat) GetProvider() provider.ProviderName {
return c.provider
}
// GetBaseURL 获取 baseURL
func (c *RemoteAPIChat) GetBaseURL() string {
return c.baseURL
}
// GetAPIKey 获取 apiKey
func (c *RemoteAPIChat) GetAPIKey() string {
return c.apiKey
}
+575
View File
@@ -0,0 +1,575 @@
package chat
import (
"context"
"encoding/json"
"os"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/sashabaranov/go-openai"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestRemoteChat(t *testing.T) *RemoteAPIChat {
t.Helper()
chat, err := NewRemoteAPIChat(&ChatConfig{
Source: types.ModelSourceRemote,
BaseURL: "",
ModelName: "test-model",
APIKey: "test-key",
ModelID: "test-model",
})
require.NoError(t, err)
return chat
}
func TestBuildChatCompletionRequest_ParallelToolCalls(t *testing.T) {
chat := newTestRemoteChat(t)
messages := []Message{{Role: "user", Content: "hello"}}
t.Run("nil ParallelToolCalls leaves default", func(t *testing.T) {
opts := &ChatOptions{Temperature: 0.7}
req := chat.BuildChatCompletionRequest(messages, opts, false)
assert.Nil(t, req.ParallelToolCalls, "should be nil when not set")
})
t.Run("ParallelToolCalls true is propagated", func(t *testing.T) {
ptc := true
opts := &ChatOptions{
Temperature: 0.7,
ParallelToolCalls: &ptc,
Tools: []Tool{{
Type: "function",
Function: FunctionDef{
Name: "mcp_weather_getforecast",
Description: "Get weather",
Parameters: json.RawMessage(`{"type":"object"}`),
},
}},
}
req := chat.BuildChatCompletionRequest(messages, opts, true)
assert.NotNil(t, req.ParallelToolCalls)
val, ok := req.ParallelToolCalls.(bool)
if ok {
assert.Equal(t, true, val)
} else {
assert.Equal(t, true, req.ParallelToolCalls)
}
assert.Len(t, req.Tools, 1)
assert.Equal(t, "mcp_weather_getforecast", req.Tools[0].Function.Name)
})
t.Run("ParallelToolCalls false is propagated", func(t *testing.T) {
ptc := false
opts := &ChatOptions{
Temperature: 0.7,
ParallelToolCalls: &ptc,
}
req := chat.BuildChatCompletionRequest(messages, opts, false)
assert.NotNil(t, req.ParallelToolCalls)
val, ok := req.ParallelToolCalls.(bool)
if ok {
assert.Equal(t, false, val)
} else {
assert.Equal(t, false, req.ParallelToolCalls)
}
})
}
func TestBuildChatCompletionRequest_MCPToolsFormat(t *testing.T) {
chat := newTestRemoteChat(t)
messages := []Message{{Role: "user", Content: "查询乙醇的理化性质"}}
mcpTools := []Tool{
{
Type: "function",
Function: FunctionDef{
Name: "mcp_hazardous_chemicals_gethazardouschemicals",
Description: "[MCP Service: hazardous_chemicals (external)] Get hazardous chemicals list",
Parameters: json.RawMessage(`{"type":"object","properties":{}}`),
},
},
{
Type: "function",
Function: FunctionDef{
Name: "mcp_hazardous_chemicals_gethazardouschemicalbybizid",
Description: "[MCP Service: hazardous_chemicals (external)] Get hazardous chemical by biz ID",
Parameters: json.RawMessage(`{"type":"object","properties":{"bizId":{"type":"string"}},"required":["bizId"]}`),
},
},
}
ptc := true
opts := &ChatOptions{
Temperature: 0.7,
Tools: mcpTools,
ParallelToolCalls: &ptc,
}
req := chat.BuildChatCompletionRequest(messages, opts, true)
assert.Len(t, req.Tools, 2)
assert.Equal(t, "mcp_hazardous_chemicals_gethazardouschemicals", req.Tools[0].Function.Name)
assert.Equal(t, "mcp_hazardous_chemicals_gethazardouschemicalbybizid", req.Tools[1].Function.Name)
assert.Equal(t, true, req.ParallelToolCalls)
assert.True(t, req.Stream)
for _, tool := range req.Tools {
name := tool.Function.Name
assert.NotContains(t, name, "ed606721", "tool name must use service name, not UUID")
assert.Regexp(t, `^[a-zA-Z0-9_-]+$`, name, "tool name must match OpenAI pattern")
assert.LessOrEqual(t, len(name), 64, "tool name must be <= 64 chars")
}
}
// TestBuildChatCompletionRequest_GPT5MaxCompletionTokens 验证 GPT-5 / o-series
// 模型的 MaxTokens 自动迁移到 MaxCompletionTokens,且采样参数被剔除。
// 见 issue #1283:Azure OpenAI 的 gpt-5 系列模型不再支持 max_tokens 字段。
func TestBuildChatCompletionRequest_GPT5MaxCompletionTokens(t *testing.T) {
build := func(t *testing.T, providerName, modelName string) *RemoteAPIChat {
t.Helper()
c, err := NewRemoteAPIChat(&ChatConfig{
Source: types.ModelSourceRemote,
BaseURL: "https://example.openai.azure.com",
ModelName: modelName,
APIKey: "test-key",
ModelID: modelName,
Provider: providerName,
ExtraConfig: map[string]string{
"api_version": "2025-04-01-preview",
},
})
require.NoError(t, err)
return c
}
messages := []Message{{Role: "user", Content: "test"}}
cases := []struct {
name string
provider string
model string
shouldRewriteMaxT bool
}{
{"AzureOpenAI gpt-5.2", "azure_openai", "gpt-5.2", true},
{"AzureOpenAI gpt-5-mini", "azure_openai", "gpt-5-mini", true},
{"OpenAI gpt-5", "openai", "gpt-5", true},
{"OpenAI o1-mini", "openai", "o1-mini", true},
{"OpenAI o3", "openai", "o3", true},
{"OpenAI o4-mini", "openai", "o4-mini", true},
{"OpenAI gpt-4o (unchanged)", "openai", "gpt-4o", false},
{"AzureOpenAI gpt-4 (unchanged)", "azure_openai", "gpt-4", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c := build(t, tc.provider, tc.model)
opts := &ChatOptions{
Temperature: 0.7,
TopP: 0.9,
MaxTokens: 128,
FrequencyPenalty: 0.1,
PresencePenalty: 0.2,
}
req := c.shapedRequest(messages, opts, false)
if tc.shouldRewriteMaxT {
assert.Equal(t, 0, req.MaxTokens, "MaxTokens must NOT be sent for GPT-5/o-series")
assert.Equal(t, 128, req.MaxCompletionTokens, "MaxCompletionTokens should be populated from MaxTokens")
assert.EqualValues(t, 0, req.Temperature, "temperature must be omitted")
assert.EqualValues(t, 0, req.TopP, "top_p must be omitted")
assert.EqualValues(t, 0, req.FrequencyPenalty, "frequency_penalty must be omitted")
assert.EqualValues(t, 0, req.PresencePenalty, "presence_penalty must be omitted")
} else {
assert.Equal(t, 128, req.MaxTokens)
assert.Equal(t, 0, req.MaxCompletionTokens)
assert.InDelta(t, 0.7, req.Temperature, 1e-6)
}
})
}
t.Run("MaxCompletionTokens takes precedence over MaxTokens", func(t *testing.T) {
c := build(t, "openai", "gpt-5.2")
opts := &ChatOptions{
MaxTokens: 128,
MaxCompletionTokens: 2048,
}
req := c.shapedRequest(messages, opts, false)
assert.Equal(t, 0, req.MaxTokens)
assert.Equal(t, 2048, req.MaxCompletionTokens)
})
}
func TestBuildChatCompletionRequest_ToolChoice(t *testing.T) {
chat := newTestRemoteChat(t)
messages := []Message{{Role: "user", Content: "test"}}
t.Run("auto tool choice", func(t *testing.T) {
opts := &ChatOptions{ToolChoice: "auto"}
req := chat.BuildChatCompletionRequest(messages, opts, false)
assert.Equal(t, "auto", req.ToolChoice)
})
t.Run("specific tool choice", func(t *testing.T) {
opts := &ChatOptions{ToolChoice: "mcp_svc_tool"}
req := chat.BuildChatCompletionRequest(messages, opts, false)
assert.NotNil(t, req.ToolChoice)
})
}
// TestConvertMessages_ReasoningContentRoundTrip verifies that assistant
// reasoning_content is propagated through ConvertMessages so that providers
// like MiMo / DeepSeek thinking-mode can read it back from prior turns.
// See issue #1302: MiMo rejects multi-turn requests with HTTP 400
// "The reasoning_content in the thinking mode must be passed back to the API."
// when this field is dropped.
func TestConvertMessages_ReasoningContentRoundTrip(t *testing.T) {
c := newTestRemoteChat(t)
t.Run("assistant reasoning_content propagated", func(t *testing.T) {
messages := []Message{
{Role: "user", Content: "hi"},
{
Role: "assistant",
Content: "the answer",
ReasoningContent: "let me think about this carefully",
},
{Role: "user", Content: "follow-up"},
}
out := c.ConvertMessages(messages)
require.Len(t, out, 3)
assert.Equal(t, "let me think about this carefully", out[1].ReasoningContent,
"assistant reasoning_content must be retained for multi-turn replay")
assert.Empty(t, out[0].ReasoningContent, "user message must not carry reasoning_content")
assert.Empty(t, out[2].ReasoningContent, "user message must not carry reasoning_content")
})
t.Run("non-assistant role drops reasoning_content even if set", func(t *testing.T) {
messages := []Message{
{Role: "user", Content: "hi", ReasoningContent: "should be dropped"},
}
out := c.ConvertMessages(messages)
require.Len(t, out, 1)
assert.Empty(t, out[0].ReasoningContent, "non-assistant roles must never carry reasoning_content upstream")
})
t.Run("empty assistant reasoning_content stays empty", func(t *testing.T) {
messages := []Message{
{Role: "assistant", Content: "no thinking"},
}
out := c.ConvertMessages(messages)
require.Len(t, out, 1)
assert.Empty(t, out[0].ReasoningContent)
})
}
func TestApplyCompletionToolCallMetadata(t *testing.T) {
c := newTestRemoteChat(t)
c.adapter = geminiProvider{}
resp := &types.ChatResponse{
ToolCalls: []types.LLMToolCall{{
ID: "call_1",
Type: "function",
Function: types.FunctionCall{
Name: "wiki_search",
Arguments: `{"query":"MACS"}`,
},
}},
}
body := []byte(`{
"choices":[{
"message":{
"tool_calls":[{
"id":"call_1",
"type":"function",
"function":{"name":"wiki_search","arguments":"{\"query\":\"MACS\"}"},
"extra_content":{"google":{"thought_signature":"sig-from-gemini"}}
}]
}
}]
}`)
c.applyCompletionToolCallMetadata(body, resp)
require.Len(t, resp.ToolCalls, 1)
assert.JSONEq(t, `{"thought_signature":"sig-from-gemini"}`,
string(resp.ToolCalls[0].ProviderMetadata["google"]))
}
func TestApplyStreamToolCallMetadata(t *testing.T) {
c := newTestRemoteChat(t)
c.adapter = geminiProvider{}
state := newStreamState()
body := []byte(`{
"choices":[{
"delta":{
"tool_calls":[{
"index":0,
"id":"call_1",
"type":"function",
"function":{"name":"wiki_search","arguments":"{\"query\":\"MACS\"}"},
"extra_content":{"google":{"thought_signature":"stream-sig-from-gemini"}}
}]
}
}]
}`)
c.applyStreamToolCallMetadata(body, state)
toolCalls := state.buildOrderedToolCalls()
require.Len(t, toolCalls, 1)
assert.JSONEq(t, `{"thought_signature":"stream-sig-from-gemini"}`,
string(toolCalls[0].ProviderMetadata["google"]))
}
// TestRemoteAPIChat 综合测试 Remote API Chat 的所有功能
func TestRemoteAPIChat(t *testing.T) {
// 获取环境变量
deepseekAPIKey := os.Getenv("DEEPSEEK_API_KEY")
aliyunAPIKey := os.Getenv("ALIYUN_API_KEY")
// 定义测试配置
testConfigs := []struct {
name string
apiKey string
config *ChatConfig
skipMsg string
}{
{
name: "DeepSeek API",
apiKey: deepseekAPIKey,
config: &ChatConfig{
Source: types.ModelSourceRemote,
BaseURL: "https://api.deepseek.com/v1",
ModelName: "deepseek-chat",
APIKey: deepseekAPIKey,
ModelID: "deepseek-chat",
},
skipMsg: "DEEPSEEK_API_KEY environment variable not set",
},
{
name: "Aliyun DeepSeek",
apiKey: aliyunAPIKey,
config: &ChatConfig{
Source: types.ModelSourceRemote,
BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
ModelName: "deepseek-v3.1",
APIKey: aliyunAPIKey,
ModelID: "deepseek-v3.1",
},
skipMsg: "ALIYUN_API_KEY environment variable not set",
},
{
name: "Aliyun Qwen3-32b",
apiKey: aliyunAPIKey,
config: &ChatConfig{
Source: types.ModelSourceRemote,
BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
ModelName: "qwen3-32b",
APIKey: aliyunAPIKey,
ModelID: "qwen3-32b",
},
skipMsg: "ALIYUN_API_KEY environment variable not set",
},
{
name: "Aliyun Qwen-max",
apiKey: aliyunAPIKey,
config: &ChatConfig{
Source: types.ModelSourceRemote,
BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
ModelName: "qwen-max",
APIKey: aliyunAPIKey,
ModelID: "qwen-max",
},
skipMsg: "ALIYUN_API_KEY environment variable not set",
},
}
// 测试消息
testMessages := []Message{
{
Role: "user",
Content: "test",
},
}
// 测试选项
testOptions := &ChatOptions{
Temperature: 0.7,
MaxTokens: 100,
}
// 创建上下文
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 遍历所有配置进行测试
for _, tc := range testConfigs {
t.Run(tc.name, func(t *testing.T) {
// 检查 API Key
if tc.apiKey == "" {
t.Skip(tc.skipMsg)
}
// 创建聊天实例
chat, err := NewRemoteAPIChat(tc.config)
require.NoError(t, err)
assert.Equal(t, tc.config.ModelName, chat.GetModelName())
assert.Equal(t, tc.config.ModelID, chat.GetModelID())
// 测试基本聊天功能
t.Run("Basic Chat", func(t *testing.T) {
response, err := chat.Chat(ctx, testMessages, testOptions)
require.NoError(t, err)
require.NotNil(t, response, "response should not be nil")
assert.NotEmpty(t, response.Content)
assert.Greater(t, response.Usage.TotalTokens, 0)
assert.Greater(t, response.Usage.PromptTokens, 0)
assert.Greater(t, response.Usage.CompletionTokens, 0)
t.Logf("%s Response: %s", tc.name, response.Content)
t.Logf("Usage: Prompt=%d, Completion=%d, Total=%d",
response.Usage.PromptTokens,
response.Usage.CompletionTokens,
response.Usage.TotalTokens)
})
})
}
}
// TestCachedTokensHelper covers the nil-safety contract of the cachedTokens
// helper. Some providers omit PromptTokensDetails entirely; the helper must
// return zero rather than panic.
func TestCachedTokensHelper(t *testing.T) {
assert.Equal(t, 0, cachedTokens(nil), "nil details must return zero")
assert.Equal(t, 0, cachedTokens(&openai.PromptTokensDetails{}),
"empty details must return zero")
assert.Equal(t, 1234, cachedTokens(&openai.PromptTokensDetails{CachedTokens: 1234}),
"populated cached_tokens must round-trip")
}
func TestParseCompletionResponse_ReasoningRemainsSeparate(t *testing.T) {
c := newTestRemoteChat(t)
for _, tc := range []struct {
name, content, reasoning, want string
}{
{"answer with reasoning", "answer", "private reasoning", "answer"},
{"reasoning only", "", "private reasoning", ""},
{"ordinary answer", "answer", "", "answer"},
{"incomplete inline thinking", "<think>private incomplete reasoning", "", ""},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := c.parseCompletionResponse(&openai.ChatCompletionResponse{
Choices: []openai.ChatCompletionChoice{{
Message: openai.ChatCompletionMessage{Role: "assistant", Content: tc.content, ReasoningContent: tc.reasoning},
FinishReason: openai.FinishReasonLength,
}},
})
require.NoError(t, err)
assert.Equal(t, tc.want, got.Content)
assert.Equal(t, tc.reasoning, got.ReasoningContent)
assert.Equal(t, "length", got.FinishReason)
})
}
}
// TestParseCompletionResponse_CachedTokens verifies that
// prompt_tokens_details.cached_tokens from an OpenAI-compatible response is
// propagated into TokenUsage.CachedTokens. This is the field Qwen explicit
// caching populates on a cache hit.
func TestParseCompletionResponse_CachedTokens(t *testing.T) {
c := newTestRemoteChat(t)
t.Run("cached_tokens populated from prompt_tokens_details", func(t *testing.T) {
resp := &openai.ChatCompletionResponse{
Choices: []openai.ChatCompletionChoice{
{
Message: openai.ChatCompletionMessage{Role: "assistant", Content: "hi"},
FinishReason: openai.FinishReasonStop,
},
},
Usage: openai.Usage{
PromptTokens: 6929,
CompletionTokens: 42,
TotalTokens: 6971,
PromptTokensDetails: &openai.PromptTokensDetails{
CachedTokens: 6900,
},
},
}
got, err := c.parseCompletionResponse(resp)
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, 6929, got.Usage.PromptTokens)
assert.Equal(t, 42, got.Usage.CompletionTokens)
assert.Equal(t, 6971, got.Usage.TotalTokens)
assert.Equal(t, 6900, got.Usage.CachedTokens,
"cached_tokens must mirror prompt_tokens_details.cached_tokens")
assert.Equal(t, 6900, got.Usage.CacheReadTokens)
assert.Equal(t, 29, got.Usage.CacheMissTokens)
assert.True(t, got.Usage.CacheReported)
assert.Equal(t, types.PromptCacheStatusHit, got.Usage.CacheStatus)
})
t.Run("missing prompt_tokens_details yields zero cached_tokens", func(t *testing.T) {
resp := &openai.ChatCompletionResponse{
Choices: []openai.ChatCompletionChoice{
{
Message: openai.ChatCompletionMessage{Role: "assistant", Content: "hi"},
FinishReason: openai.FinishReasonStop,
},
},
Usage: openai.Usage{
PromptTokens: 100,
CompletionTokens: 10,
TotalTokens: 110,
// PromptTokensDetails intentionally nil — providers like Ollama
// and older OpenAI-compat backends omit this block entirely.
},
}
got, err := c.parseCompletionResponse(resp)
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, 0, got.Usage.CachedTokens,
"missing details must surface as zero, not panic")
assert.False(t, got.Usage.CacheReported)
assert.Equal(t, types.PromptCacheStatusUnsupported, got.Usage.CacheStatus)
})
}
func TestApplyRawPromptCacheUsage_DeepSeekNativeFields(t *testing.T) {
usage := types.TokenUsage{PromptTokens: 4096, CompletionTokens: 10, TotalTokens: 4106}
applyRawPromptCacheUsage([]byte(`{"usage":{"prompt_tokens":4096,"prompt_cache_hit_tokens":3072,"prompt_cache_miss_tokens":1024}}`), &usage)
assert.Equal(t, 3072, usage.CacheReadTokens)
assert.Equal(t, 1024, usage.CacheMissTokens)
assert.True(t, usage.CacheReported)
assert.Equal(t, types.PromptCacheStatusHit, usage.CacheStatus)
}
// TestTokenUsage_CachedTokensJSONOmitempty ensures the new CachedTokens field
// stays out of serialized payloads when it is zero. This keeps logs and API
// responses unchanged for providers that never report cache hits.
func TestTokenUsage_CachedTokensJSONOmitempty(t *testing.T) {
t.Run("zero is omitted", func(t *testing.T) {
u := types.TokenUsage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}
b, err := json.Marshal(u)
require.NoError(t, err)
assert.NotContains(t, string(b), "cached_tokens")
})
t.Run("non-zero is emitted", func(t *testing.T) {
u := types.TokenUsage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15, CachedTokens: 7}
b, err := json.Marshal(u)
require.NoError(t, err)
assert.Contains(t, string(b), `"cached_tokens":7`)
})
}
+44
View File
@@ -0,0 +1,44 @@
package chat
import (
"context"
"github.com/Tencent/WeKnora/internal/models/requestbudget"
"github.com/Tencent/WeKnora/internal/types"
)
// Keep the supported set explicit: adding a factory adapter cannot silently
// bypass a controlled task's actual-send budget.
type requestBudgetChat struct {
inner Chat
supported bool
}
func wrapChatRequestBudget(inner Chat, err error) (Chat, error) {
if err != nil || inner == nil {
return inner, err
}
supported := false
switch inner.(type) {
case *RemoteAPIChat, *AnthropicChat:
supported = true
}
return &requestBudgetChat{inner: inner, supported: supported}, nil
}
func (w *requestBudgetChat) GetModelName() string { return w.inner.GetModelName() }
func (w *requestBudgetChat) GetModelID() string { return w.inner.GetModelID() }
func (w *requestBudgetChat) Chat(ctx context.Context, messages []Message, opts *ChatOptions) (*types.ChatResponse, error) {
if err := requestbudget.RequireSupported(ctx, w.supported); err != nil {
return nil, err
}
return w.inner.Chat(ctx, messages, opts)
}
func (w *requestBudgetChat) ChatStream(ctx context.Context, messages []Message, opts *ChatOptions) (<-chan types.StreamResponse, error) {
if err := requestbudget.RequireSupported(ctx, w.supported); err != nil {
return nil, err
}
return w.inner.ChatStream(ctx, messages, opts)
}
+253
View File
@@ -0,0 +1,253 @@
package chat
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/models/requestbudget"
"github.com/Tencent/WeKnora/internal/types"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
type chatBudgetGuard struct {
mu sync.Mutex
limit int
models, phases []string
checkErr error
}
func (g *chatBudgetGuard) Key() string { return "local-test" }
func (g *chatBudgetGuard) Check(ctx context.Context) error {
if g.checkErr != nil {
return g.checkErr
}
return ctx.Err()
}
func (g *chatBudgetGuard) Reserve(ctx context.Context, model, phase string) error {
g.mu.Lock()
defer g.mu.Unlock()
if err := ctx.Err(); err != nil {
return err
}
if len(g.models) >= g.limit {
return requestbudget.ErrStopped
}
g.models, g.phases = append(g.models, model), append(g.phases, phase)
return nil
}
func chatBudgetContext(g *chatBudgetGuard) context.Context {
return requestbudget.WithModel(requestbudget.WithPhase(requestbudget.WithGuard(context.Background(), g), "wiki_generate"), "caller-forged-model")
}
func TestRequestBudgetChatSDKRawAndStreaming(t *testing.T) {
t.Setenv("SSRF_WHITELIST", "127.0.0.1")
secutils.ResetSSRFWhitelistForTest()
t.Cleanup(secutils.ResetSSRFWhitelistForTest)
for _, raw := range []bool{false, true} {
for _, stream := range []bool{false, true} {
for _, limit := range []int{0, 1} {
t.Run(fmt.Sprintf("raw=%t/stream=%t/limit=%d", raw, stream, limit), func(t *testing.T) {
var sends atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sends.Add(1)
if stream {
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")
} else {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`)
}
}))
defer server.Close()
c, err := NewRemoteAPIChat(&ChatConfig{BaseURL: server.URL, ModelID: "concrete-chat", ModelName: "gpt-4o", APIKey: "local-only", Provider: "openai"})
if err != nil {
t.Fatal(err)
}
opts := &ChatOptions{CacheRetention: CacheRetentionNone}
if raw {
opts.CacheRetention, opts.PromptCacheKey = CacheRetentionShort, "force-raw"
}
g := &chatBudgetGuard{limit: limit}
ctx, cancel := context.WithTimeout(chatBudgetContext(g), 3*time.Second)
defer cancel()
messages := []Message{{Role: "user", Content: "local test"}}
_, _, actualRaw, err := c.buildOutbound(ctx, messages, opts, stream)
if err != nil || actualRaw != raw {
t.Fatalf("test route mismatch: %t %v", actualRaw, err)
}
if stream {
var responses <-chan types.StreamResponse
responses, err = c.ChatStream(ctx, messages, opts)
if err == nil {
for range responses {
}
}
} else {
_, err = c.Chat(ctx, messages, opts)
}
if limit == 0 && !errors.Is(err, requestbudget.ErrStopped) {
t.Fatalf("expected stop: %v", err)
}
if limit == 1 && err != nil {
t.Fatal(err)
}
if int(sends.Load()) != limit || len(g.models) != limit {
t.Fatalf("unaccounted sends: %d models=%v", sends.Load(), g.models)
}
if limit == 1 && (g.models[0] != "concrete-chat" || g.phases[0] != "wiki_generate") {
t.Fatalf("identity/phase lost: %v %v", g.models, g.phases)
}
})
}
}
}
}
func TestRequestBudgetMultimodalFallbackCountsEachSend(t *testing.T) {
t.Setenv("SSRF_WHITELIST", "127.0.0.1")
secutils.ResetSSRFWhitelistForTest()
t.Cleanup(secutils.ResetSSRFWhitelistForTest)
for _, stream := range []bool{false, true} {
for _, limit := range []int{1, 2} {
t.Run(fmt.Sprintf("stream=%t/limit=%d", stream, limit), func(t *testing.T) {
var sends atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if sends.Add(1) == 1 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `{"error":{"message":"image input not supported","type":"invalid_request_error"}}`)
return
}
if stream {
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, "data: [DONE]\n\n")
} else {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"choices":[{"message":{"role":"assistant","content":"ok"}}]}`)
}
}))
defer server.Close()
c, err := NewRemoteAPIChat(&ChatConfig{BaseURL: server.URL, ModelID: "concrete-chat", ModelName: "gpt-4o", APIKey: "local-only", Provider: "openai"})
if err != nil {
t.Fatal(err)
}
g := &chatBudgetGuard{limit: limit}
ctx, cancel := context.WithTimeout(chatBudgetContext(g), 3*time.Second)
defer cancel()
messages := []Message{{Role: "user", Content: "local test", Images: []string{"data:image/png;base64,AA=="}}}
opts := &ChatOptions{CacheRetention: CacheRetentionNone}
if stream {
var responses <-chan types.StreamResponse
responses, err = c.ChatStream(ctx, messages, opts)
if err == nil {
for range responses {
}
}
} else {
_, err = c.Chat(ctx, messages, opts)
}
if limit == 1 && !errors.Is(err, requestbudget.ErrStopped) {
t.Fatalf("fallback escaped budget: %v", err)
}
if limit == 2 && err != nil {
t.Fatal(err)
}
if int(sends.Load()) != limit || len(g.models) != limit {
t.Fatalf("fallback sends=%d reservations=%d", sends.Load(), len(g.models))
}
for i := range g.models {
if g.models[i] != "concrete-chat" || g.phases[i] != "wiki_generate" {
t.Fatal("fallback context lost")
}
}
})
}
}
}
func TestRequestBudgetChatFactoryFailsClosed(t *testing.T) {
g := &chatBudgetGuard{limit: 1}
ctx := chatBudgetContext(g)
c, err := NewChat(&ChatConfig{Source: types.ModelSourceLocal, ModelID: "local-model"}, nil)
if err != nil {
t.Fatal(err)
}
if _, err := c.Chat(ctx, nil, nil); !errors.Is(err, requestbudget.ErrStopped) {
t.Fatalf("local adapter invoked: %v", err)
}
if _, err := c.ChatStream(ctx, nil, nil); !errors.Is(err, requestbudget.ErrStopped) {
t.Fatalf("local stream invoked: %v", err)
}
if len(g.models) != 0 {
t.Fatal("unsupported adapter reserved a send")
}
unknown, err := wrapChatRequestBudget(&fakeChat{id: "unknown"}, nil)
if err != nil {
t.Fatal(err)
}
if _, err := unknown.Chat(ctx, nil, nil); !errors.Is(err, requestbudget.ErrStopped) {
t.Fatalf("unknown adapter allowed: %v", err)
}
}
func TestRequestBudgetAnthropicFactory(t *testing.T) {
t.Setenv("SSRF_WHITELIST", "127.0.0.1")
secutils.ResetSSRFWhitelistForTest()
t.Cleanup(secutils.ResetSSRFWhitelistForTest)
for _, stream := range []bool{false, true} {
for _, limit := range []int{0, 1} {
t.Run(fmt.Sprintf("stream=%t/limit=%d", stream, limit), func(t *testing.T) {
var sends atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sends.Add(1)
if stream {
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
} else {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn"}`)
}
}))
defer server.Close()
c, err := NewChat(&ChatConfig{Source: types.ModelSourceRemote, BaseURL: server.URL, ModelID: "concrete-chat", ModelName: "claude-test", APIKey: "local-only", Provider: "anthropic"}, nil)
if err != nil {
t.Fatal(err)
}
g := &chatBudgetGuard{limit: limit}
ctx, cancel := context.WithTimeout(chatBudgetContext(g), 3*time.Second)
defer cancel()
messages := []Message{{Role: "user", Content: "local test"}}
if stream {
var responses <-chan types.StreamResponse
responses, err = c.ChatStream(ctx, messages, nil)
if err == nil {
for range responses {
}
}
} else {
_, err = c.Chat(ctx, messages, nil)
}
if limit == 0 && !errors.Is(err, requestbudget.ErrStopped) {
t.Fatalf("guard not applied: %v", err)
}
if limit == 1 && err != nil {
t.Fatal(err)
}
if int(sends.Load()) != limit || len(g.models) != limit {
t.Fatalf("sends=%d models=%v", sends.Load(), g.models)
}
if limit == 1 && (g.models[0] != "concrete-chat" || g.phases[0] != "wiki_generate") {
t.Fatalf("context lost: %v %v", g.models, g.phases)
}
})
}
}
}
@@ -0,0 +1,293 @@
package chat
import (
"encoding/json"
"strings"
"time"
)
// Live write/edit progress while the model is still emitting tool-call JSON:
// path as soon as it appears, then a running +N / -M as content lands. The
// SSE payload is only those stats plus a short preview — never the whole file.
const (
sandboxFilePreviewMaxLines = 10
writeSandboxFileName = "write_sandbox_file"
editSandboxFileName = "edit_sandbox_file"
)
// sandboxProgressMinInterval caps how often we emit when only byte counts
// change (a long line with no newline). Line-count changes always emit.
var sandboxProgressMinInterval = 120 * time.Millisecond
func isSandboxMutationTool(name string) bool {
return name == writeSandboxFileName || name == editSandboxFileName
}
type sandboxFileProgress struct {
toolName string
pathEx *jsonFieldExtractor
contentEx *jsonFieldExtractor
path string
content string
buf string
added int
removed int
bytes int
preview string
lastPath string
lastAdded int
lastRemoved int
lastBytes int
lastPreview string
lastEmit time.Time
emitted bool
lastParseAt time.Time
lastParseLen int
}
func newSandboxFileProgress(toolName string) *sandboxFileProgress {
return &sandboxFileProgress{toolName: toolName}
}
// Feed consumes one arguments delta. ok is true when the UI should be sent
// an updated stats payload.
func (p *sandboxFileProgress) Feed(delta string) (map[string]any, bool) {
if p == nil || delta == "" {
return nil, false
}
switch p.toolName {
case writeSandboxFileName:
p.feedWrite(delta)
case editSandboxFileName:
p.feedEdit(delta)
default:
return nil, false
}
if !p.shouldEmit() {
return nil, false
}
p.markEmitted()
return p.payload(), true
}
func (p *sandboxFileProgress) feedWrite(delta string) {
if p.pathEx == nil {
p.pathEx = newJSONFieldExtractor("path")
}
if p.contentEx == nil {
p.contentEx = newJSONFieldExtractor("content")
}
if chunk := p.pathEx.Feed(delta); chunk != "" {
p.path += chunk
}
if chunk := p.contentEx.Feed(delta); chunk != "" {
p.content += chunk
}
p.added = countContentLines(p.content)
p.removed = 0
p.bytes = len(p.content)
p.preview = sandboxContentPreview(p.content)
}
func (p *sandboxFileProgress) feedEdit(delta string) {
p.buf += delta
if p.pathEx == nil {
p.pathEx = newJSONFieldExtractor("path")
}
if chunk := p.pathEx.Feed(delta); chunk != "" {
p.path += chunk
}
now := time.Now()
if p.lastParseLen > 0 &&
len(p.buf)-p.lastParseLen < 512 &&
now.Sub(p.lastParseAt) < sandboxProgressMinInterval {
return
}
p.lastParseAt = now
p.lastParseLen = len(p.buf)
obj := parsePartialJSONObject(p.buf)
if obj == nil {
return
}
if path, _ := obj["path"].(string); path != "" && p.path == "" {
p.path = path
}
p.added, p.removed = editArgsLineStats(obj)
p.bytes = len(p.buf)
p.preview = ""
}
func (p *sandboxFileProgress) shouldEmit() bool {
if p.path == "" && p.added == 0 && p.removed == 0 && p.bytes == 0 {
return false
}
pathChanged := p.path != p.lastPath
linesChanged := p.added != p.lastAdded || p.removed != p.lastRemoved
if !p.emitted || pathChanged || linesChanged {
return true
}
if p.bytes == p.lastBytes && p.preview == p.lastPreview {
return false
}
return time.Since(p.lastEmit) >= sandboxProgressMinInterval
}
func (p *sandboxFileProgress) markEmitted() {
p.emitted = true
p.lastPath = p.path
p.lastAdded = p.added
p.lastRemoved = p.removed
p.lastBytes = p.bytes
p.lastPreview = p.preview
p.lastEmit = time.Now()
}
func (p *sandboxFileProgress) payload() map[string]any {
out := map[string]any{
"added_lines": p.added,
"removed_lines": p.removed,
"bytes": p.bytes,
}
if p.path != "" {
out["path"] = p.path
}
if p.preview != "" {
out["preview"] = p.preview
}
return out
}
func countContentLines(s string) int {
if s == "" {
return 0
}
n := strings.Count(s, "\n")
if s[len(s)-1] != '\n' {
n++
}
return n
}
func sandboxContentPreview(s string) string {
if s == "" {
return ""
}
lines := strings.Split(s, "\n")
end := len(lines)
if end > 0 && lines[end-1] == "" {
end--
}
lines = lines[:end]
if len(lines) > sandboxFilePreviewMaxLines {
lines = lines[:sandboxFilePreviewMaxLines]
}
return strings.Join(lines, "\n")
}
func parsePartialJSONObject(s string) map[string]any {
var m map[string]any
if json.Unmarshal([]byte(s), &m) == nil {
return m
}
closed := closePartialJSON(s)
if closed == s {
return nil
}
if json.Unmarshal([]byte(closed), &m) == nil {
return m
}
return nil
}
// closePartialJSON closes an unterminated string and missing braces so a
// streaming prefix can be unmarshalled. Structural characters are ASCII, so
// byte iteration is enough.
func closePartialJSON(s string) string {
inString := false
escaped := false
var stack []byte
for i := 0; i < len(s); i++ {
c := s[i]
if escaped {
escaped = false
continue
}
if inString {
if c == '\\' {
escaped = true
continue
}
if c == '"' {
inString = false
}
continue
}
switch c {
case '"':
inString = true
case '{':
stack = append(stack, '}')
case '[':
stack = append(stack, ']')
case '}', ']':
if n := len(stack); n > 0 && stack[n-1] == c {
stack = stack[:n-1]
}
}
}
if !inString && !escaped && len(stack) == 0 {
return s
}
var b strings.Builder
b.Grow(len(s) + len(stack) + 2)
b.WriteString(s)
if escaped {
b.WriteByte('\\')
}
if inString || escaped {
b.WriteByte('"')
}
for i := len(stack) - 1; i >= 0; i-- {
b.WriteByte(stack[i])
}
return b.String()
}
func editArgsLineStats(obj map[string]any) (added, removed int) {
edits, _ := obj["edits"].([]any)
for _, raw := range edits {
m, ok := raw.(map[string]any)
if !ok {
continue
}
oldS := stringFromMap(m, "old_string", "oldText")
newS := stringFromMap(m, "new_string", "newText")
if oldS == "" && newS == "" {
continue
}
removed += countContentLines(oldS)
added += countContentLines(newS)
}
if added == 0 && removed == 0 {
oldS := stringFromMap(obj, "old_string", "oldText")
newS := stringFromMap(obj, "new_string", "newText")
removed = countContentLines(oldS)
added = countContentLines(newS)
}
return added, removed
}
func stringFromMap(m map[string]any, keys ...string) string {
for _, key := range keys {
if s, ok := m[key].(string); ok {
return s
}
}
return ""
}
@@ -0,0 +1,132 @@
package chat
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestCountContentLines(t *testing.T) {
cases := []struct {
in string
want int
}{
{"", 0},
{"hello", 1},
{"hello\n", 1},
{"hello\nworld", 2},
{"hello\nworld\n", 2},
{"a\n\nb", 3},
}
for _, tc := range cases {
if got := countContentLines(tc.in); got != tc.want {
t.Errorf("countContentLines(%q) = %d, want %d", tc.in, got, tc.want)
}
}
}
func TestSandboxFileProgressWriteStreamsLineCount(t *testing.T) {
p := newSandboxFileProgress(writeSandboxFileName)
if _, ok := p.Feed(`{"path":"`); ok {
t.Fatal("path still open, should not emit yet")
}
payload, ok := p.Feed(`/workspace/output/deck.py","content":"`)
if !ok {
t.Fatal("expected emit once path is complete")
}
if payload["path"] != "/workspace/output/deck.py" {
t.Fatalf("path = %v", payload["path"])
}
if payload["added_lines"] != 0 {
t.Fatalf("no content yet, added_lines = %v", payload["added_lines"])
}
payload, ok = p.Feed(`line1\nline2\nline3`)
if !ok {
t.Fatal("expected emit when line count grows")
}
if payload["added_lines"] != 3 {
t.Fatalf("added_lines = %v, want 3", payload["added_lines"])
}
preview, _ := payload["preview"].(string)
if !strings.Contains(preview, "line1") || !strings.Contains(preview, "line3") {
t.Fatalf("preview = %q", preview)
}
if _, exists := payload["content"]; exists {
t.Fatal("progress payload must not carry the file body")
}
}
func TestSandboxFileProgressWritePreviewCapsAtTenLines(t *testing.T) {
p := newSandboxFileProgress(writeSandboxFileName)
var body strings.Builder
body.WriteString(`{"path":"/workspace/a.py","content":"`)
for i := 0; i < 15; i++ {
if i > 0 {
body.WriteString(`\n`)
}
body.WriteString("x")
}
body.WriteString(`"}`)
payload, ok := p.Feed(body.String())
if !ok {
t.Fatal("expected emit")
}
if payload["added_lines"] != 15 {
t.Fatalf("added_lines = %v, want 15", payload["added_lines"])
}
preview, _ := payload["preview"].(string)
if strings.Count(preview, "\n")+1 != sandboxFilePreviewMaxLines {
t.Fatalf("preview lines = %q", preview)
}
}
func TestSandboxFileProgressEditCountsOldAndNew(t *testing.T) {
p := newSandboxFileProgress(editSandboxFileName)
payload, ok := p.Feed(
`{"path":"/workspace/a.py","edits":[{"old_string":"a\nb\n","new_string":"a\nb\nc\nd\n"}]}`,
)
if !ok {
t.Fatal("expected emit for complete edit JSON")
}
if payload["path"] != "/workspace/a.py" {
t.Fatalf("path = %v", payload["path"])
}
if payload["removed_lines"] != 2 {
t.Fatalf("removed_lines = %v, want 2", payload["removed_lines"])
}
if payload["added_lines"] != 4 {
t.Fatalf("added_lines = %v, want 4", payload["added_lines"])
}
}
func TestSandboxFileProgressEditPartialJSON(t *testing.T) {
p := newSandboxFileProgress(editSandboxFileName)
sandboxProgressMinInterval = 0
t.Cleanup(func() { sandboxProgressMinInterval = 120 * time.Millisecond })
payload, ok := p.Feed(`{"path":"/workspace/a.py","edits":[{"old_string":"foo","new_string":"foo\nbar\nbaz`)
if !ok {
t.Fatal("expected emit for closable partial JSON")
}
if payload["path"] != "/workspace/a.py" {
t.Fatalf("path = %v", payload["path"])
}
added, _ := payload["added_lines"].(int)
if added < 2 {
t.Fatalf("added_lines = %v, want at least 2 from the partial new_string", payload["added_lines"])
}
}
func TestClosePartialJSON(t *testing.T) {
closed := closePartialJSON(`{"path":"/x","content":"hello`)
var m map[string]any
if err := json.Unmarshal([]byte(closed), &m); err != nil {
t.Fatalf("closed JSON should parse: %v (%s)", err, closed)
}
if m["content"] != "hello" {
t.Fatalf("content = %v", m["content"])
}
}
+64
View File
@@ -0,0 +1,64 @@
package chat
import (
"bufio"
"io"
"strings"
)
// SSEEvent 表示一个 Server-Sent Events 事件
type SSEEvent struct {
Data []byte
Done bool
}
// SSEReader 用于读取 SSE 流
type SSEReader struct {
scanner *bufio.Scanner
}
// NewSSEReader 创建 SSE 读取器
func NewSSEReader(reader io.Reader) *SSEReader {
scanner := bufio.NewScanner(reader)
// 设置更大的缓冲区以处理长行(思维链内容可能很长)
buf := make([]byte, 1024*1024)
scanner.Buffer(buf, 1024*1024)
return &SSEReader{scanner: scanner}
}
// ReadEvent 读取下一个 SSE 事件
func (r *SSEReader) ReadEvent() (*SSEEvent, error) {
for r.scanner.Scan() {
line := r.scanner.Text()
// 空行,跳过
if line == "" {
continue
}
// 检查是否为结束标记
if line == "data: [DONE]" {
return &SSEEvent{Done: true}, nil
}
// 解析 data 行
if strings.HasPrefix(line, "data: ") {
jsonStr := line[6:]
return &SSEEvent{Data: []byte(jsonStr)}, nil
}
// 增强兼容性(data:后面没有紧接着一个空格)
if strings.HasPrefix(line, "data:") {
jsonStr := line[5:]
return &SSEEvent{Data: []byte(jsonStr)}, nil
}
// 其他行(如 event:, id: 等)跳过
}
if err := r.scanner.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
+36
View File
@@ -0,0 +1,36 @@
package chat
import "github.com/Tencent/WeKnora/internal/types"
// thinkingEmitter owns the "reasoning then answer" hand-off that every
// streaming Chat implementation shares: thinking chunks are forwarded as they
// arrive, and exactly one thinking-done marker is emitted before the first
// answer token (or when the stream ends without one). Centralizing the
// bookkeeping keeps the OpenAI-compatible and Ollama stream loops in sync.
type thinkingEmitter struct {
active bool
}
// emit forwards a reasoning chunk and records that a thinking-done marker is
// still owed.
func (e *thinkingEmitter) emit(ch chan types.StreamResponse, content string) {
e.active = true
ch <- types.StreamResponse{
ResponseType: types.ResponseTypeThinking,
Content: content,
Done: false,
}
}
// finish emits the single thinking-done marker if one is owed. Safe to call
// multiple times; only the first call after an emit sends anything.
func (e *thinkingEmitter) finish(ch chan types.StreamResponse) {
if !e.active {
return
}
e.active = false
ch <- types.StreamResponse{
ResponseType: types.ResponseTypeThinking,
Done: true,
}
}
+214
View File
@@ -0,0 +1,214 @@
package chat
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// streamRawDumpDir returns the directory for per-stream raw packet dumps.
// Enabled when WEKNORA_LLM_STREAM_RAW_DUMP_DIR is set, or when
// WEKNORA_LLM_STREAM_RAW_DUMP=1 (defaults to ~/.weknora/investigate/llm-stream).
func streamRawDumpDir() string {
if dir := strings.TrimSpace(os.Getenv("WEKNORA_LLM_STREAM_RAW_DUMP_DIR")); dir != "" {
return dir
}
v := strings.TrimSpace(os.Getenv("WEKNORA_LLM_STREAM_RAW_DUMP"))
if v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes") {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".weknora", "investigate", "llm-stream")
}
return ""
}
// streamPacketDumper writes one stream session to a dedicated JSONL file:
// line 1 = request wrapper; following lines = raw provider chunk JSON.
type streamPacketDumper struct {
mu sync.Mutex
file *os.File
path string
model string
seq int
}
func newStreamPacketDumper(modelName string, request any) *streamPacketDumper {
dir := streamRawDumpDir()
if dir == "" {
return nil
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil
}
safeModel := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
return r
default:
return '_'
}
}, modelName)
if safeModel == "" {
safeModel = "model"
}
name := fmt.Sprintf("llm_stream_%s_%s.jsonl", safeModel, time.Now().Format("20060102T150405.000000000"))
// Defense-in-depth against path traversal: the model name is externally
// controlled, so force the file to live directly under dir and verify the
// cleaned path cannot escape it.
name = filepath.Base(filepath.Clean("/" + name))
dir = filepath.Clean(dir)
path := filepath.Join(dir, name)
if rel, err := filepath.Rel(dir, path); err != nil || rel != name {
return nil
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return nil
}
d := &streamPacketDumper{file: f, path: path, model: modelName}
_ = d.writeRequest(request)
return d
}
func (d *streamPacketDumper) writeRequest(request any) error {
line, err := json.Marshal(map[string]any{
"type": "request",
"model": d.model,
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"data": request,
})
if err != nil {
return err
}
return d.writeLine(line)
}
func (d *streamPacketDumper) writeLine(line []byte) error {
d.mu.Lock()
defer d.mu.Unlock()
if _, err := d.file.Write(line); err != nil {
return err
}
_, err := d.file.Write([]byte{'\n'})
return err
}
// WritePacketRaw appends one provider chunk as a single JSONL line (valid JSON written as-is).
func (d *streamPacketDumper) WritePacketRaw(raw []byte) {
if d == nil || d.file == nil || len(raw) == 0 {
return
}
raw = bytesTrimSpace(raw)
if len(raw) == 0 {
return
}
d.mu.Lock()
defer d.mu.Unlock()
d.seq++
if json.Valid(raw) {
_, _ = d.file.Write(raw)
_, _ = d.file.Write([]byte{'\n'})
return
}
line, _ := json.Marshal(map[string]any{
"type": "packet",
"seq": d.seq,
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"data_raw": string(raw),
})
_, _ = d.file.Write(line)
_, _ = d.file.Write([]byte{'\n'})
}
// WriteError appends a terminal error record (stream read failure, API error, etc.).
func (d *streamPacketDumper) WriteError(message string) {
if d == nil || d.file == nil || strings.TrimSpace(message) == "" {
return
}
line, err := json.Marshal(map[string]any{
"type": "error",
"model": d.model,
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"message": message,
})
if err != nil {
return
}
_ = d.writeLine(line)
}
// WriteHTTPError appends a non-2xx HTTP response before any SSE packets.
func (d *streamPacketDumper) WriteHTTPError(statusCode int, body []byte) {
if d == nil || d.file == nil {
return
}
entry := map[string]any{
"type": "http_error",
"model": d.model,
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"status_code": statusCode,
}
if len(body) > 0 {
trimmed := bytesTrimSpace(body)
if json.Valid(trimmed) {
var parsed any
if json.Unmarshal(trimmed, &parsed) == nil {
entry["body"] = parsed
} else {
entry["body_raw"] = string(trimmed)
}
} else {
entry["body_raw"] = string(trimmed)
}
}
line, err := json.Marshal(entry)
if err != nil {
return
}
_ = d.writeLine(line)
}
// WritePacket marshals v as one JSON object per line (SDK stream Recv path).
func (d *streamPacketDumper) WritePacket(v any) {
if d == nil || v == nil {
return
}
line, err := json.Marshal(v)
if err != nil {
return
}
d.WritePacketRaw(line)
}
func (d *streamPacketDumper) Path() string {
if d == nil {
return ""
}
return d.path
}
func (d *streamPacketDumper) Close() {
if d == nil || d.file == nil {
return
}
_ = d.file.Close()
d.file = nil
}
func bytesTrimSpace(b []byte) []byte {
return []byte(strings.TrimSpace(string(b)))
}
+172
View File
@@ -0,0 +1,172 @@
package chat
import (
"strings"
"github.com/Tencent/WeKnora/internal/models/provider"
"github.com/sashabaranov/go-openai"
)
// ExtraConfigThinkingControl is the model parameters.extra_config key for
// selecting how ChatOptions.Thinking is translated to provider HTTP fields.
// The accepted values mirror the strings the frontend writes (see
// ModelEditorDialog.vue): "none", "enable_thinking", "thinking_type",
// "chat_template_kwargs".
const ExtraConfigThinkingControl = "thinking_control"
// Wire-format request bodies used by providers that express extended-thinking
// through a non-standard top-level field. They embed the standard OpenAI
// request so all other fields are marshalled unchanged.
// QwenChatCompletionRequest adds Aliyun Qwen's `enable_thinking` boolean.
type QwenChatCompletionRequest struct {
openai.ChatCompletionRequest
EnableThinking *bool `json:"enable_thinking,omitempty"`
}
// ThinkingConfig is the `{ "type": "enabled"|"disabled" }` block used by
// LKEAP / Volcengine style providers.
type ThinkingConfig struct {
Type string `json:"type"`
}
// ThinkingChatCompletionRequest adds the `thinking` object for providers that
// use the `{ "thinking": { "type": ... } }` wire format.
type ThinkingChatCompletionRequest struct {
openai.ChatCompletionRequest
Thinking *ThinkingConfig `json:"thinking,omitempty"`
}
// ThinkingStrategy encodes how ChatOptions.Thinking is mapped onto a provider's
// HTTP request. Apply returns (customBody, useRawHTTP):
// - (nil, false) means "send the standard OpenAI request unchanged" (the
// caller keeps using the SDK path).
// - a non-nil customBody must be sent verbatim over raw HTTP because it
// carries fields the OpenAI SDK would strip.
//
// When opts.Thinking is nil most strategies emit nothing, deferring to the
// model's own default; the exception is enableThinking{alwaysSend: true}
// (Aliyun Qwen), which must always pin the field.
type ThinkingStrategy interface {
Apply(req *openai.ChatCompletionRequest, opts *ChatOptions, isStream bool) (customBody any, useRawHTTP bool)
}
// noThinking sends no thinking-related fields at all.
type noThinking struct{}
func (noThinking) Apply(*openai.ChatCompletionRequest, *ChatOptions, bool) (any, bool) {
return nil, false
}
// enableThinking encodes thinking via Qwen's `enable_thinking` boolean.
//
// - alwaysSend: pin the field even when opts.Thinking is nil (Aliyun Qwen
// thinking models require it on every request; default value is false).
// - disableOnNonStream: force enable_thinking=false for non-stream requests
// (Qwen3 rejects thinking in non-stream mode).
type enableThinking struct {
alwaysSend bool
disableOnNonStream bool
}
func (s enableThinking) Apply(req *openai.ChatCompletionRequest, opts *ChatOptions, isStream bool) (any, bool) {
thinking := false
switch {
case opts != nil && opts.Thinking != nil:
thinking = *opts.Thinking
case !s.alwaysSend:
return nil, false
}
if s.disableOnNonStream && !isStream {
thinking = false
}
qwenReq := QwenChatCompletionRequest{ChatCompletionRequest: *req}
qwenReq.EnableThinking = &thinking
return qwenReq, true
}
// thinkingTypeField encodes thinking via the `{ "thinking": { "type": ... } }`
// object (LKEAP / Volcengine). Emits nothing when opts.Thinking is unset.
type thinkingTypeField struct{}
func (thinkingTypeField) Apply(req *openai.ChatCompletionRequest, opts *ChatOptions, _ bool) (any, bool) {
if opts == nil || opts.Thinking == nil {
return nil, false
}
r := ThinkingChatCompletionRequest{ChatCompletionRequest: *req}
thinkingType := "disabled"
if *opts.Thinking {
thinkingType = "enabled"
}
r.Thinking = &ThinkingConfig{Type: thinkingType}
return r, true
}
// chatTemplateKwargs encodes thinking via the standard request's
// `chat_template_kwargs.enable_thinking` (vLLM / NVIDIA / generic local
// deployments). Emits nothing when opts.Thinking is unset.
type chatTemplateKwargs struct{}
func (chatTemplateKwargs) Apply(req *openai.ChatCompletionRequest, opts *ChatOptions, _ bool) (any, bool) {
if opts == nil || opts.Thinking == nil {
return nil, false
}
req.ChatTemplateKwargs = map[string]interface{}{
"enable_thinking": *opts.Thinking,
}
return req, true
}
// parseThinkingOverride reads extra_config.thinking_control and returns the
// strategy it selects, or nil when unset (the provider adapter's default
// strategy then applies). An unrecognized non-empty value falls back to
// chat_template_kwargs, preserving the legacy default-mode behavior.
func parseThinkingOverride(extraConfig map[string]string) ThinkingStrategy {
if extraConfig == nil {
return nil
}
switch strings.ToLower(strings.TrimSpace(extraConfig[ExtraConfigThinkingControl])) {
case "":
return nil
case "none":
return noThinking{}
case "enable_thinking":
return enableThinking{}
case "thinking_type":
return thinkingTypeField{}
default:
// "chat_template_kwargs" and any unknown non-empty value.
return chatTemplateKwargs{}
}
}
// EffectiveThinkingControl reports the provider field that will carry
// ChatOptions.Thinking. It intentionally shares the same adapter/override
// resolution as the real request path so diagnostics do not guess from the
// frontend selection.
func EffectiveThinkingControl(config *ChatConfig) string {
if config == nil {
return "none"
}
if override := parseThinkingOverride(config.ExtraConfig); override != nil {
return thinkingStrategyName(override)
}
providerName := provider.ProviderName(config.Provider)
if providerName == "" {
providerName = provider.DetectProvider(config.BaseURL)
}
return thinkingStrategyName(resolveProvider(providerName, config.ModelName).Thinking())
}
func thinkingStrategyName(strategy ThinkingStrategy) string {
switch strategy.(type) {
case enableThinking:
return "enable_thinking"
case thinkingTypeField:
return "thinking_type"
case chatTemplateKwargs:
return "chat_template_kwargs"
default:
return "none"
}
}
+144
View File
@@ -0,0 +1,144 @@
package chat
import (
"encoding/json"
"testing"
"github.com/sashabaranov/go-openai"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func ptrBool(b bool) *bool { return &b }
// TestThinkingStrategy_NilThinking verifies the strategies that defer to the
// model default emit nothing when ChatOptions.Thinking is unset.
func TestThinkingStrategy_NilThinking(t *testing.T) {
req := openai.ChatCompletionRequest{Model: "test"}
strategies := []ThinkingStrategy{
noThinking{},
enableThinking{}, // not alwaysSend
thinkingTypeField{},
chatTemplateKwargs{},
}
for _, s := range strategies {
custom, raw := s.Apply(&req, nil, true)
assert.Nil(t, custom, "%T", s)
assert.False(t, raw, "%T", s)
}
}
// TestEnableThinking_QwenSemantics pins the Aliyun Qwen behavior: thinking is
// always sent, defaults to false, and is forced off on non-stream requests.
func TestEnableThinking_QwenSemantics(t *testing.T) {
s := enableThinking{alwaysSend: true, disableOnNonStream: true}
req := openai.ChatCompletionRequest{Model: "qwen3-32b"}
t.Run("non-stream forces false even when requested true", func(t *testing.T) {
custom, raw := s.Apply(&req, &ChatOptions{Thinking: ptrBool(true)}, false)
require.True(t, raw)
qwen, ok := custom.(QwenChatCompletionRequest)
require.True(t, ok)
require.NotNil(t, qwen.EnableThinking)
assert.False(t, *qwen.EnableThinking)
})
t.Run("stream honors requested true", func(t *testing.T) {
custom, raw := s.Apply(&req, &ChatOptions{Thinking: ptrBool(true)}, true)
require.True(t, raw)
qwen := custom.(QwenChatCompletionRequest)
require.NotNil(t, qwen.EnableThinking)
assert.True(t, *qwen.EnableThinking)
})
t.Run("stream defaults to false when unset", func(t *testing.T) {
custom, raw := s.Apply(&req, nil, true)
require.True(t, raw)
qwen := custom.(QwenChatCompletionRequest)
require.NotNil(t, qwen.EnableThinking)
assert.False(t, *qwen.EnableThinking)
})
}
// TestEnableThinking_ExtraConfigSemantics pins the extra_config "enable_thinking"
// override: only sent when explicitly requested.
func TestEnableThinking_ExtraConfigSemantics(t *testing.T) {
s := enableThinking{}
req := openai.ChatCompletionRequest{Model: "qwen3"}
custom, raw := s.Apply(&req, &ChatOptions{Thinking: ptrBool(true)}, true)
require.True(t, raw)
qwen := custom.(QwenChatCompletionRequest)
require.NotNil(t, qwen.EnableThinking)
assert.True(t, *qwen.EnableThinking)
custom, raw = s.Apply(&req, nil, true)
assert.Nil(t, custom)
assert.False(t, raw)
}
func TestThinkingTypeField(t *testing.T) {
s := thinkingTypeField{}
req := openai.ChatCompletionRequest{Model: "ds-v3"}
custom, raw := s.Apply(&req, &ChatOptions{Thinking: ptrBool(false)}, true)
require.True(t, raw)
typed, ok := custom.(ThinkingChatCompletionRequest)
require.True(t, ok)
require.NotNil(t, typed.Thinking)
assert.Equal(t, "disabled", typed.Thinking.Type)
custom, raw = s.Apply(&req, &ChatOptions{Thinking: ptrBool(true)}, true)
require.True(t, raw)
assert.Equal(t, "enabled", custom.(ThinkingChatCompletionRequest).Thinking.Type)
}
func TestChatTemplateKwargs(t *testing.T) {
s := chatTemplateKwargs{}
req := openai.ChatCompletionRequest{Model: "vllm"}
custom, raw := s.Apply(&req, &ChatOptions{Thinking: ptrBool(true)}, true)
require.True(t, raw)
out, ok := custom.(*openai.ChatCompletionRequest)
require.True(t, ok)
assert.Equal(t, true, out.ChatTemplateKwargs["enable_thinking"])
body, err := json.Marshal(custom)
require.NoError(t, err)
assert.Contains(t, string(body), "chat_template_kwargs")
}
func TestParseThinkingOverride(t *testing.T) {
cases := map[string]ThinkingStrategy{
"none": noThinking{},
"enable_thinking": enableThinking{},
"thinking_type": thinkingTypeField{},
"chat_template_kwargs": chatTemplateKwargs{},
"something-unknown": chatTemplateKwargs{}, // legacy default-mode fallback
}
for value, want := range cases {
got := parseThinkingOverride(map[string]string{ExtraConfigThinkingControl: value})
assert.IsType(t, want, got, "value=%q", value)
}
assert.Nil(t, parseThinkingOverride(nil))
assert.Nil(t, parseThinkingOverride(map[string]string{}))
assert.Nil(t, parseThinkingOverride(map[string]string{ExtraConfigThinkingControl: ""}))
}
func TestEffectiveThinkingControl(t *testing.T) {
assert.Equal(t, "enable_thinking", EffectiveThinkingControl(&ChatConfig{
Provider: "aliyun",
ModelName: "qwen3-32b",
}))
assert.Equal(t, "chat_template_kwargs", EffectiveThinkingControl(&ChatConfig{
Provider: "generic",
ModelName: "qwen3",
ExtraConfig: map[string]string{ExtraConfigThinkingControl: "chat_template_kwargs"},
}))
assert.Equal(t, "none", EffectiveThinkingControl(&ChatConfig{
Provider: "generic",
ModelName: "qwen3",
ExtraConfig: map[string]string{ExtraConfigThinkingControl: "none"},
}))
}
+86
View File
@@ -0,0 +1,86 @@
package chat
import (
"context"
"os"
"testing"
"time"
)
func TestWithLLMTimeout_NoParentDeadline_AppliesDefault(t *testing.T) {
ctx, cancel := withLLMTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
dl, ok := ctx.Deadline()
if !ok {
t.Fatalf("expected deadline to be set when parent has none")
}
if remaining := time.Until(dl); remaining <= 0 || remaining > 50*time.Millisecond {
t.Fatalf("unexpected remaining duration: %v", remaining)
}
}
func TestWithLLMTimeout_ShorterParentDeadline_Respected(t *testing.T) {
parent, parentCancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer parentCancel()
ctx, cancel := withLLMTimeout(parent, 10*time.Second)
defer cancel()
dl, _ := ctx.Deadline()
if remaining := time.Until(dl); remaining > 30*time.Millisecond {
t.Fatalf("parent shorter deadline should be respected, got remaining=%v", remaining)
}
}
func TestWithLLMTimeout_LongerParentDeadline_NotTruncated(t *testing.T) {
parent, parentCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer parentCancel()
ctx, cancel := withLLMTimeout(parent, 50*time.Millisecond)
defer cancel()
dl, ok := ctx.Deadline()
if !ok {
t.Fatalf("deadline must be set")
}
if remaining := time.Until(dl); remaining < 5*time.Second {
t.Fatalf("parent longer deadline should NOT be truncated by default, got remaining=%v", remaining)
}
}
func TestEnvDurationSeconds(t *testing.T) {
const key = "WEKNORA_TEST_TIMEOUT_SECONDS"
t.Run("unset returns fallback", func(t *testing.T) {
os.Unsetenv(key)
if got := envDurationSeconds(key, 7*time.Second); got != 7*time.Second {
t.Fatalf("got %v", got)
}
})
t.Run("valid value parsed", func(t *testing.T) {
t.Setenv(key, "42")
if got := envDurationSeconds(key, 1*time.Second); got != 42*time.Second {
t.Fatalf("got %v", got)
}
})
t.Run("invalid falls back", func(t *testing.T) {
t.Setenv(key, "not-a-number")
if got := envDurationSeconds(key, 9*time.Second); got != 9*time.Second {
t.Fatalf("got %v", got)
}
})
t.Run("non-positive falls back", func(t *testing.T) {
t.Setenv(key, "0")
if got := envDurationSeconds(key, 9*time.Second); got != 9*time.Second {
t.Fatalf("got %v", got)
}
t.Setenv(key, "-5")
if got := envDurationSeconds(key, 9*time.Second); got != 9*time.Second {
t.Fatalf("got %v", got)
}
})
}
+63
View File
@@ -0,0 +1,63 @@
package chat
import (
"context"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/models/requestbudget"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
// LLM 调用超时配置。仅作为"上层未设置 deadline 时"的兜底,避免 hung 请求
// 永久阻塞 worker。如果上层 ctx 已经设置了 deadline(无论比默认更短还是更长),
// 都会原样尊重,不再叠加默认超时。可通过环境变量覆盖:
// - WEKNORA_LLM_CHAT_TIMEOUT_SECONDS 非流式调用兜底超时(默认 600s)
// - WEKNORA_LLM_STREAM_TIMEOUT_SECONDS 流式调用兜底超时(默认 1800s)
var (
defaultChatTimeout = envDurationSeconds("WEKNORA_LLM_CHAT_TIMEOUT_SECONDS", 300*time.Second)
defaultStreamTimeout = envDurationSeconds("WEKNORA_LLM_STREAM_TIMEOUT_SECONDS", 600*time.Second)
)
// envDurationSeconds 读取以"秒"为单位的环境变量,解析失败或非正值时回退到 fallback。
func envDurationSeconds(key string, fallback time.Duration) time.Duration {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return fallback
}
return time.Duration(n) * time.Second
}
// withLLMTimeout 仅在上层 ctx 没有 deadline 时附加一个兜底超时;
// 如果上层已显式设置 deadline(无论更短或更长),则原样返回,
// 让调用方对自己的超时策略拥有最终决定权。
func withLLMTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
if _, ok := ctx.Deadline(); ok {
return ctx, func() {}
}
return context.WithTimeout(ctx, d)
}
// rawHTTPClient is a shared HTTP client for raw HTTP LLM calls with connection-level timeouts.
// Per-request timeout is enforced via context deadline (see defaultChatTimeout / defaultStreamTimeout)
// rather than http.Client.Timeout, so streaming calls are not prematurely terminated.
// Uses SSRFSafeDialContext to prevent DNS rebinding attacks at the connection layer.
var rawHTTPTransport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: secutils.SSRFSafeDialContext,
TLSHandshakeTimeout: 10 * time.Second,
IdleConnTimeout: 90 * time.Second,
MaxIdleConnsPerHost: 5,
}
var rawHTTPClient = secutils.NewSSRFSafeHTTPClientWithTransport(
secutils.SSRFSafeHTTPClientConfig{Timeout: 0, MaxRedirects: 10},
requestbudget.Transport{Base: rawHTTPTransport},
)
+46
View File
@@ -0,0 +1,46 @@
package chat
import (
"context"
"strings"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
)
// logUsage emits the standard "[LLM Usage]" line shared by every Chat
// implementation. It is a no-op when usage is nil so callers can pass through
// optional usage blocks without guarding at each call site.
func logUsage(ctx context.Context, model string, u *types.TokenUsage) {
if u == nil {
return
}
purpose, prefixFingerprint := types.LLMCallMetadataFromContext(ctx)
logger.Infof(ctx,
"[LLM Usage] model=%s, purpose=%s, prompt_prefix=%s, prompt_tokens=%d, completion_tokens=%d, "+
"total_tokens=%d, cached_tokens=%d, cache_read_tokens=%d, cache_write_tokens=%d, "+
"cache_miss_tokens=%d, cache_hit_rate=%.1f%%, cache_reported=%t, cache_status=%s%s",
model, purpose, prefixFingerprint, u.PromptTokens, u.CompletionTokens, u.TotalTokens,
u.CachedTokens, u.CacheReadTokens, u.CacheWriteTokens, u.CacheMissTokens,
u.PromptCacheHitRate(), u.CacheReported, u.CacheStatus, usageAttribution(ctx))
}
// usageAttribution renders the ", session_id=…, principal=…" suffix that
// attributes a usage line to the session and terminal principal that
// triggered the call. Calls that run outside a session or without a resolved
// principal (document parsing, title generation, background jobs) render an
// empty suffix, keeping their lines byte-identical to before.
func usageAttribution(ctx context.Context) string {
var b strings.Builder
if sessionID, ok := types.SessionIDFromContext(ctx); ok && sessionID != "" {
b.WriteString(", session_id=")
b.WriteString(sessionID)
}
if principal, ok := types.PrincipalFromContext(ctx); ok {
b.WriteString(", principal=")
b.WriteString(principal.Type)
b.WriteString(":")
b.WriteString(principal.ID)
}
return b.String()
}
@@ -0,0 +1,34 @@
package chat
import (
"context"
"testing"
"github.com/Tencent/WeKnora/internal/types"
)
func TestUsageAttributionEmptyOutsideSessions(t *testing.T) {
// Calls without session or principal context (document parsing, title
// generation, background jobs) must keep the usage line byte-identical.
if got := usageAttribution(context.Background()); got != "" {
t.Fatalf("expected empty suffix, got %q", got)
}
}
func TestUsageAttributionCarriesSessionAndPrincipal(t *testing.T) {
ctx := types.WithSessionID(context.Background(), "sess-123")
ctx = types.WithPrincipal(ctx, types.Principal{Type: types.PrincipalAPIExternalUser, ID: "10000:42"})
got := usageAttribution(ctx)
want := ", session_id=sess-123, principal=api_external_user:10000:42"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestUsageAttributionSessionOnly(t *testing.T) {
ctx := types.WithSessionID(context.Background(), "sess-9")
if got := usageAttribution(ctx); got != ", session_id=sess-9" {
t.Fatalf("got %q", got)
}
}