Files
Cyrene_v2/backend/services/ai-core/llm/llm.go
T
Aska 7c02d31194 feat(ai-core): wire function calling into the LLM pipeline
- llm: ChatStreamWithTools sends OpenAI-format tools; streamed tool_call
  deltas are reassembled by index into complete ToolCalls on the Done token
- model: LLMMessage gains ToolCallID/ToolCalls; StreamToken gains ToolCalls
- orchestrator: new ToolExecutor interface and streamWithTools loop —
  execute requested tools, feed results back, up to 3 rounds, final round
  forces a plain text answer; normal chats still stream token by token.
  Also adds Pipeline.FinishRound (assistant reply recording + post-round
  hook), wired to transports in the next commit.
- main: inject the plugin registry via toolExecAdapter

Includes an httptest-based test covering delta reassembly, tool
execution, and result feedback.
2026-08-20 19:40:58 +08:00

463 lines
13 KiB
Go

// Package llm provides LLM abstraction with streaming, model routing, and response caching.
package llm
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
)
// Config holds LLM connection configuration.
type Config struct {
BaseURL string
APIKey string
Model string
FallbackModel string
MaxRetries int
Timeout time.Duration
}
// DefaultConfig returns a sensible default Config.
func DefaultConfig() Config {
return Config{
BaseURL: "https://api.openai.com/v1",
Model: "gpt-4o",
FallbackModel: "gpt-4o-mini",
MaxRetries: 3,
Timeout: 180 * time.Second,
}
}
// Adapter handles communication with an OpenAI-compatible LLM API.
type Adapter struct {
cfg Config
client *http.Client
onCall func(call CallRecord) // Hook for logging each LLM call
}
// NewAdapter creates a new LLM adapter.
func NewAdapter(cfg Config) *Adapter {
return &Adapter{
cfg: cfg,
client: &http.Client{
Timeout: cfg.Timeout,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
},
},
}
}
// SetCallLogger injects a callback that is invoked after each LLM call.
func (a *Adapter) SetCallLogger(fn func(call CallRecord)) {
a.onCall = fn
}
// CallRecord holds metadata about a single LLM API call.
type CallRecord struct {
Time time.Time
Model string
Purpose string
PromptTokens int
CompletionTokens int
DurationMs int64
Success bool
Error string
}
// ModelName returns the configured model name.
func (a *Adapter) ModelName() string { return a.cfg.Model }
// Chat sends a chat completion request and returns the full response.
func (a *Adapter) Chat(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error) {
start := time.Now()
body := map[string]any{
"model": a.cfg.Model,
"messages": wireMessages(messages),
}
resp := &model.LLMResponse{Model: a.cfg.Model}
err := a.doRequest(ctx, "POST", "/chat/completions", body, func(data map[string]any) error {
if choices, ok := data["choices"].([]any); ok && len(choices) > 0 {
choice := choices[0].(map[string]any)
if msg, ok := choice["message"].(map[string]any); ok {
if content, ok := msg["content"].(string); ok { resp.Content = content }
}
if reason, ok := choice["finish_reason"].(string); ok { resp.FinishReason = reason }
}
if usage, ok := data["usage"].(map[string]any); ok {
if total, ok := usage["total_tokens"].(float64); ok { resp.TokensUsed = int(total) }
}
return nil
})
a.recordCall(start, "chat", resp.TokensUsed, 0, err)
return resp, err
}
// ToolSpec describes a callable tool exposed to the LLM (function calling).
type ToolSpec struct {
Name string
Description string
// Parameters is a JSON Schema object describing the tool's input.
Parameters map[string]any
}
// ChatStream sends a chat completion request and returns a token channel.
func (a *Adapter) ChatStream(ctx context.Context, messages []model.LLMMessage) (<-chan model.StreamToken, error) {
return a.ChatStreamWithTools(ctx, messages, nil)
}
// ChatStreamWithTools is ChatStream with function-calling support.
// When the model requests tool calls, they are delivered on the final
// Done token (StreamToken.ToolCalls) instead of regular content tokens.
func (a *Adapter) ChatStreamWithTools(ctx context.Context, messages []model.LLMMessage, tools []ToolSpec) (<-chan model.StreamToken, error) {
start := time.Now()
body := map[string]any{
"model": a.cfg.Model,
"messages": wireMessages(messages),
"stream": true,
}
if len(tools) > 0 {
body["tools"] = wireTools(tools)
}
ch := make(chan model.StreamToken, 64)
go func() {
defer close(ch)
err := a.doStreamRequest(ctx, "POST", "/chat/completions", body, ch)
a.recordCall(start, "chat_stream", 0, 0, err)
if err != nil {
select {
case ch <- model.StreamToken{Content: fmt.Sprintf("[Error: %v]", err), Done: true}:
default:
}
}
}()
return ch, nil
}
func (a *Adapter) recordCall(start time.Time, purpose string, promptTok, compTok int, err error) {
if a.onCall != nil {
a.onCall(CallRecord{
Time: start,
Model: a.cfg.Model,
Purpose: purpose,
PromptTokens: promptTok,
CompletionTokens: compTok,
DurationMs: time.Since(start).Milliseconds(),
Success: err == nil,
Error: func() string { if err != nil { return err.Error() }; return "" }(),
})
}
}
// doRequest sends a non-streaming request and parses the response.
func (a *Adapter) doRequest(ctx context.Context, method, path string, body map[string]any, parser func(map[string]any) error) error {
jsonBody, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, method, a.cfg.BaseURL+path, bytes.NewReader(jsonBody))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+a.cfg.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := a.client.Do(req)
if err != nil {
return fmt.Errorf("http request: %w", err)
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBytes[:min(len(respBytes), 200)]))
}
var data map[string]any
if err := json.Unmarshal(respBytes, &data); err != nil {
return fmt.Errorf("parse response: %w", err)
}
return parser(data)
}
// doStreamRequest sends a streaming request and sends tokens to the channel.
func (a *Adapter) doStreamRequest(ctx context.Context, method, path string, body map[string]any, ch chan<- model.StreamToken) error {
jsonBody, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, method, a.cfg.BaseURL+path, bytes.NewReader(jsonBody))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+a.cfg.APIKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
resp, err := a.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errMsg, _ := io.ReadAll(resp.Body)
return fmt.Errorf("stream API error %d: %s", resp.StatusCode, string(errMsg[:min(len(errMsg), 200)]))
}
scanner := bufio.NewScanner(resp.Body)
toolAcc := newToolCallAccumulator()
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
ch <- model.StreamToken{Done: true, ToolCalls: toolAcc.calls()}
return nil
}
var frame map[string]any
if err := json.Unmarshal([]byte(data), &frame); err != nil {
continue
}
if choices, ok := frame["choices"].([]any); ok && len(choices) > 0 {
choice := choices[0].(map[string]any)
if delta, ok := choice["delta"].(map[string]any); ok {
if content, ok := delta["content"].(string); ok {
ch <- model.StreamToken{Content: content}
}
toolAcc.feed(delta)
}
if reason, ok := choice["finish_reason"].(string); ok && reason != "" {
ch <- model.StreamToken{FinishReason: reason, Done: true, ToolCalls: toolAcc.calls()}
return nil
}
}
}
return scanner.Err()
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// wireMessages converts LLMMessages to the OpenAI wire format,
// expanding ToolCalls into the nested function-call structure.
func wireMessages(messages []model.LLMMessage) []map[string]any {
out := make([]map[string]any, len(messages))
for i, m := range messages {
wm := map[string]any{"role": m.Role, "content": m.Content}
if m.Name != "" {
wm["name"] = m.Name
}
if m.ToolCallID != "" {
wm["tool_call_id"] = m.ToolCallID
}
if len(m.ToolCalls) > 0 {
tcs := make([]map[string]any, len(m.ToolCalls))
for j, tc := range m.ToolCalls {
args, _ := json.Marshal(tc.Arguments)
tcs[j] = map[string]any{
"id": tc.ID,
"type": "function",
"function": map[string]any{
"name": tc.Name,
"arguments": string(args),
},
}
}
wm["tool_calls"] = tcs
}
out[i] = wm
}
return out
}
// wireTools converts ToolSpecs to the OpenAI tools array format.
func wireTools(tools []ToolSpec) []map[string]any {
out := make([]map[string]any, len(tools))
for i, t := range tools {
params := t.Parameters
if params == nil {
params = map[string]any{"type": "object", "properties": map[string]any{}}
}
out[i] = map[string]any{
"type": "function",
"function": map[string]any{
"name": t.Name,
"description": t.Description,
"parameters": params,
},
}
}
return out
}
// toolCallAccumulator reassembles streamed tool_call deltas (indexed fragments
// with partial id/name/arguments) into complete ToolCalls.
type toolCallAccumulator struct {
order []int
byIdx map[int]*model.ToolCall
rawArg map[int]*strings.Builder
}
func newToolCallAccumulator() *toolCallAccumulator {
return &toolCallAccumulator{byIdx: map[int]*model.ToolCall{}, rawArg: map[int]*strings.Builder{}}
}
// feed consumes one streaming delta and merges any tool_calls fragments.
func (a *toolCallAccumulator) feed(delta map[string]any) {
tcs, ok := delta["tool_calls"].([]any)
if !ok {
return
}
for _, item := range tcs {
tcMap, ok := item.(map[string]any)
if !ok {
continue
}
idx := 0
if f, ok := tcMap["index"].(float64); ok {
idx = int(f)
}
tc, exists := a.byIdx[idx]
if !exists {
tc = &model.ToolCall{}
a.byIdx[idx] = tc
a.rawArg[idx] = &strings.Builder{}
a.order = append(a.order, idx)
}
if id, ok := tcMap["id"].(string); ok && id != "" {
tc.ID = id
}
if fn, ok := tcMap["function"].(map[string]any); ok {
if name, ok := fn["name"].(string); ok && name != "" {
tc.Name = name
}
if args, ok := fn["arguments"].(string); ok {
a.rawArg[idx].WriteString(args)
}
}
}
}
// calls returns the accumulated tool calls in index order, or nil if none.
func (a *toolCallAccumulator) calls() []model.ToolCall {
if len(a.order) == 0 {
return nil
}
calls := make([]model.ToolCall, 0, len(a.order))
for _, idx := range a.order {
tc := a.byIdx[idx]
if raw := a.rawArg[idx].String(); raw != "" {
_ = json.Unmarshal([]byte(raw), &tc.Arguments)
}
calls = append(calls, *tc)
}
return calls
}
// ModelSelector routes requests to different models based on purpose.
type ModelSelector struct {
adapters map[model.LLMPurpose]*Adapter
default_ *Adapter
}
// NewModelSelector creates a selector with purpose-specific adapters.
func NewModelSelector(cfg Config) *ModelSelector {
base := NewAdapter(cfg)
return &ModelSelector{
adapters: map[model.LLMPurpose]*Adapter{},
default_: base,
}
}
// SetAdapter assigns an adapter for a specific purpose.
func (s *ModelSelector) SetAdapter(purpose model.LLMPurpose, a *Adapter) {
s.adapters[purpose] = a
}
// Select returns the adapter for the given purpose, falling back to default.
func (s *ModelSelector) Select(purpose model.LLMPurpose) *Adapter {
if a, ok := s.adapters[purpose]; ok {
return a
}
return s.default_
}
// DefaultAdapter returns the default adapter.
func (s *ModelSelector) DefaultAdapter() *Adapter {
return s.default_
}
// SemanticCache caches common greetings to avoid redundant LLM calls.
type SemanticCache struct {
entries map[string]CachedResponse
mu sync.RWMutex
}
// CachedResponse holds a pre-computed response for a specific input.
type CachedResponse struct {
Content string `json:"content"`
Segments []model.ReviewSegment `json:"segments"`
ExpireAt time.Time `json:"expire_at"`
}
// NewSemanticCache creates a cache preloaded with common greetings.
func NewSemanticCache() *SemanticCache {
c := &SemanticCache{entries: make(map[string]CachedResponse)}
ttl := 24 * time.Hour
c.Set("你好", "你好呀~ 有什么可以帮你的吗?", nil, ttl)
c.Set("早", "早上好♪ 今天有什么计划吗?", nil, ttl)
c.Set("晚安", "晚安~ 做个好梦!", nil, ttl)
c.Set("谢谢", "不客气~ 随时可以找我帮忙哦", nil, ttl)
c.Set("hello", "Hello~ How can I help you today?", nil, ttl)
return c
}
// Set adds a cached response.
func (c *SemanticCache) Set(input, content string, segments []model.ReviewSegment, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.entries[strings.ToLower(strings.TrimSpace(input))] = CachedResponse{
Content: content,
Segments: segments,
ExpireAt: time.Now().Add(ttl),
}
}
// Get retrieves a cached response, or nil if not found/expired.
func (c *SemanticCache) Get(input string) *CachedResponse {
c.mu.RLock()
defer c.mu.RUnlock()
key := strings.ToLower(strings.TrimSpace(input))
if entry, ok := c.entries[key]; ok && time.Now().Before(entry.ExpireAt) {
return &entry
}
return nil
}