Files
Sidekick/pkg/sidekick/llm.go
T

252 lines
7.5 KiB
Go
Raw Normal View History

2026-03-21 16:20:16 +02:00
package sidekick
import (
2026-03-22 12:01:29 +02:00
"bufio"
"bytes"
"context"
"encoding/json"
2026-03-23 17:10:03 +02:00
"errors"
2026-03-22 12:01:29 +02:00
"fmt"
"io"
"net/http"
"strings"
2026-03-21 16:20:16 +02:00
)
2026-03-23 17:10:03 +02:00
var (
ErrLLMTimeout = errors.New("the request to the AI model timed out. Please try again later")
ErrLLMRateLimit = errors.New("rate limit exceeded. Please wait a moment and try again")
ErrLLMAuth = errors.New("authentication failed. Please check your API key")
ErrLLMServer = errors.New("the AI provider's server encountered an error. Please try again later")
ErrLLMInvalidResponse = errors.New("the AI model returned an invalid response structure")
)
2026-03-21 16:20:16 +02:00
// LLMClient represents an abstraction over the LLM provider
type LLMClient interface {
2026-03-22 12:01:29 +02:00
Call(ctx context.Context, model ModelConfig, messages []Message,
tools map[string]ToolDefinition, streamCallback, reasoningCallback func(string)) (Message, error)
2026-03-21 16:20:16 +02:00
}
2026-03-21 18:32:07 +02:00
// standardLLMClient is an OpenAI-compatible implementation
type standardLLMClient struct{}
2026-03-22 12:01:29 +02:00
func (s *standardLLMClient) Call(ctx context.Context, model ModelConfig, msgs []Message,
ts map[string]ToolDefinition, streamCallback, reasoningCallback func(string)) (Message, error) {
client := &http.Client{
Timeout: model.Timeout,
}
2026-03-21 18:32:07 +02:00
2026-03-22 12:01:29 +02:00
reqBody := map[string]interface{}{
"model": model.ModelID,
"messages": msgs,
"temperature": model.Temperature,
}
if model.MaxTokens > 0 {
reqBody["max_tokens"] = model.MaxTokens
}
2026-03-21 18:32:07 +02:00
2026-03-22 12:01:29 +02:00
if len(ts) > 0 {
var tools []map[string]interface{}
for _, t := range ts {
tools = append(tools, t.ConvertToOpenAITool())
}
reqBody["tools"] = tools
}
2026-03-21 18:32:07 +02:00
2026-03-22 12:01:29 +02:00
if streamCallback != nil {
reqBody["stream"] = true
}
2026-03-21 18:32:07 +02:00
2026-03-22 12:01:29 +02:00
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return Message{}, fmt.Errorf("failed to marshal request: %w", err)
}
2026-03-21 18:32:07 +02:00
2026-03-22 12:01:29 +02:00
req, err := http.NewRequestWithContext(ctx, "POST",
model.Endpoint+"/chat/completions", bytes.NewBuffer(jsonBody))
if err != nil {
return Message{}, fmt.Errorf("failed to create request: %w", err)
}
2026-03-21 18:32:07 +02:00
2026-03-22 12:01:29 +02:00
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+model.APIKey)
2026-03-21 18:32:07 +02:00
2026-03-22 12:01:29 +02:00
resp, err := client.Do(req)
if err != nil {
2026-03-23 17:10:03 +02:00
if errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "timeout") {
return Message{}, ErrLLMTimeout
}
2026-03-22 12:01:29 +02:00
return Message{}, fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
2026-03-21 18:32:07 +02:00
2026-03-22 12:01:29 +02:00
if resp.StatusCode != http.StatusOK {
2026-03-23 17:10:03 +02:00
switch resp.StatusCode {
case http.StatusUnauthorized, http.StatusForbidden:
return Message{}, ErrLLMAuth
case http.StatusTooManyRequests:
return Message{}, ErrLLMRateLimit
case http.StatusInternalServerError, http.StatusBadGateway, http.StatusServiceUnavailable:
return Message{}, ErrLLMServer
}
2026-03-22 12:01:29 +02:00
body, _ := io.ReadAll(resp.Body)
return Message{}, fmt.Errorf("API returned error (%d): %s", resp.StatusCode, string(body))
}
2026-03-21 18:32:07 +02:00
2026-03-22 12:01:29 +02:00
if streamCallback != nil {
return s.handleStream(resp.Body, streamCallback, reasoningCallback)
}
2026-03-21 18:32:07 +02:00
2026-03-22 12:01:29 +02:00
body, err := io.ReadAll(resp.Body)
if err != nil {
return Message{}, fmt.Errorf("failed to read response body: %w", err)
}
2026-03-21 18:32:07 +02:00
2026-03-22 12:01:29 +02:00
var openAIResp struct {
Choices []struct {
Message Message `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
2026-03-21 18:32:07 +02:00
2026-03-22 12:01:29 +02:00
if err := json.Unmarshal(body, &openAIResp); err != nil {
2026-03-23 17:10:03 +02:00
return Message{}, fmt.Errorf("%w: failed to parse API response: %v", ErrLLMInvalidResponse, err)
2026-03-22 12:01:29 +02:00
}
2026-03-21 18:32:07 +02:00
2026-03-22 12:01:29 +02:00
if openAIResp.Error != nil {
return Message{}, fmt.Errorf("API error: %s", openAIResp.Error.Message)
}
if len(openAIResp.Choices) == 0 {
2026-03-23 17:10:03 +02:00
return Message{}, fmt.Errorf("%w: API returned no choices", ErrLLMInvalidResponse)
2026-03-22 12:01:29 +02:00
}
return openAIResp.Choices[0].Message, nil
2026-03-21 18:32:07 +02:00
}
var defaultLLMClient LLMClient = &standardLLMClient{}
2026-03-21 16:20:16 +02:00
// SetLLMClient allows overriding the LLM client (e.g. for testing)
func SetLLMClient(client LLMClient) {
2026-03-22 12:01:29 +02:00
defaultLLMClient = client
2026-03-21 16:20:16 +02:00
}
// CallLLM invokes the configured LLM client
2026-03-22 12:01:29 +02:00
func CallLLM(ctx context.Context, model ModelConfig, msgs []Message,
tools map[string]ToolDefinition, streamCallback, reasoningCallback func(string)) (Message, error) {
return defaultLLMClient.Call(ctx, model, msgs, tools, streamCallback, reasoningCallback)
2026-03-21 16:20:16 +02:00
}
// mockLLMClient is used as a fallback if no actual HTTP client is injected.
type mockLLMClient struct {
2026-03-22 12:01:29 +02:00
responses []Message
calls int
2026-03-21 16:20:16 +02:00
}
2026-03-22 12:01:29 +02:00
func (m *mockLLMClient) Call(ctx context.Context, model ModelConfig, msgs []Message,
ts map[string]ToolDefinition, streamCallback, reasoningCallback func(string)) (Message, error) {
2026-03-21 16:20:16 +02:00
if m.calls < len(m.responses) {
resp := m.responses[m.calls]
m.calls++
2026-03-22 12:01:29 +02:00
if streamCallback != nil {
streamCallback(resp.Content)
}
2026-03-21 16:20:16 +02:00
return resp, nil
}
2026-03-22 12:01:29 +02:00
msg := Message{
Role: MessageRoleAssistant,
2026-03-21 16:20:16 +02:00
Content: `{"action": "RESPOND", "params": {"response": "Mock LLM Response"}}`,
2026-03-22 12:01:29 +02:00
}
if streamCallback != nil {
streamCallback(msg.Content)
}
return msg, nil
}
func (s *standardLLMClient) handleStream(body io.ReadCloser, streamCallback,
reasoningCallback func(string)) (Message, error) {
defer body.Close()
var fullContent strings.Builder
2026-03-22 17:16:58 +02:00
var fullReasoning strings.Builder
2026-03-22 12:01:29 +02:00
var finalMsg Message
finalMsg.Role = MessageRoleAssistant
type deltaToolCall struct {
Index int `json:"index"`
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
toolCallsMap := make(map[int]*ToolCall)
maxIndex := -1
scanner := bufio.NewScanner(body)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
break
}
var chunk struct {
Choices []struct {
Delta struct {
2026-03-22 17:16:58 +02:00
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ToolCalls []deltaToolCall `json:"tool_calls"`
2026-03-22 12:01:29 +02:00
} `json:"delta"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(data), &chunk); err == nil && len(chunk.Choices) > 0 {
delta := chunk.Choices[0].Delta
2026-03-22 17:16:58 +02:00
rContent := delta.ReasoningContent
if rContent == "" {
rContent = delta.Reasoning
}
if rContent != "" && reasoningCallback != nil {
fullReasoning.WriteString(rContent)
reasoningCallback(rContent)
}
if delta.Content != "" && streamCallback != nil {
2026-03-22 12:01:29 +02:00
fullContent.WriteString(delta.Content)
streamCallback(delta.Content)
}
for _, tc := range delta.ToolCalls {
if _, exists := toolCallsMap[tc.Index]; !exists {
toolCallsMap[tc.Index] = &ToolCall{ID: tc.ID, Type: tc.Type}
if tc.Index > maxIndex {
maxIndex = tc.Index
}
}
toolCallsMap[tc.Index].Function.Name += tc.Function.Name
toolCallsMap[tc.Index].Function.Arguments += tc.Function.Arguments
}
}
}
if err := scanner.Err(); err != nil {
return Message{}, fmt.Errorf("error reading stream: %w", err)
}
finalMsg.Content = fullContent.String()
2026-03-22 17:16:58 +02:00
finalMsg.Reasoning = fullReasoning.String()
2026-03-22 12:01:29 +02:00
if maxIndex >= 0 {
for i := 0; i <= maxIndex; i++ {
if tc, ok := toolCallsMap[i]; ok {
finalMsg.ToolCalls = append(finalMsg.ToolCalls, *tc)
}
}
}
return finalMsg, nil
2026-03-21 16:20:16 +02:00
}