123 lines
3.2 KiB
Go
123 lines
3.2 KiB
Go
package sidekick
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
)
|
|
|
|
// LLMClient represents an abstraction over the LLM provider
|
|
type LLMClient interface {
|
|
Call(ctx context.Context, model ModelConfig, messages []Message, tools map[string]ToolDefinition) (Message, error)
|
|
}
|
|
|
|
// standardLLMClient is an OpenAI-compatible implementation
|
|
type standardLLMClient struct{}
|
|
|
|
func (s *standardLLMClient) Call(ctx context.Context, model ModelConfig, msgs []Message, ts map[string]ToolDefinition) (Message, error) {
|
|
client := &http.Client{
|
|
Timeout: model.Timeout,
|
|
}
|
|
|
|
reqBody := map[string]interface{}{
|
|
"model": model.ModelID,
|
|
"messages": msgs,
|
|
"temperature": model.Temperature,
|
|
}
|
|
if model.MaxTokens > 0 {
|
|
reqBody["max_tokens"] = model.MaxTokens
|
|
}
|
|
|
|
if len(ts) > 0 {
|
|
var tools []map[string]interface{}
|
|
for _, t := range ts {
|
|
tools = append(tools, t.ConvertToOpenAITool())
|
|
}
|
|
reqBody["tools"] = tools
|
|
}
|
|
|
|
jsonBody, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return Message{}, fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+model.APIKey)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return Message{}, fmt.Errorf("HTTP request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return Message{}, fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return Message{}, fmt.Errorf("API returned error (%d): %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var openAIResp struct {
|
|
Choices []struct {
|
|
Message Message `json:"message"`
|
|
} `json:"choices"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error,omitempty"`
|
|
}
|
|
|
|
if err := json.Unmarshal(body, &openAIResp); err != nil {
|
|
return Message{}, fmt.Errorf("failed to unmarshal response: %w", err)
|
|
}
|
|
|
|
if openAIResp.Error != nil {
|
|
return Message{}, fmt.Errorf("API error: %s", openAIResp.Error.Message)
|
|
}
|
|
|
|
if len(openAIResp.Choices) == 0 {
|
|
return Message{}, fmt.Errorf("API returned no choices")
|
|
}
|
|
|
|
return openAIResp.Choices[0].Message, nil
|
|
}
|
|
|
|
var defaultLLMClient LLMClient = &standardLLMClient{}
|
|
|
|
// SetLLMClient allows overriding the LLM client (e.g. for testing)
|
|
func SetLLMClient(client LLMClient) {
|
|
defaultLLMClient = client
|
|
}
|
|
|
|
// CallLLM invokes the configured LLM client
|
|
func CallLLM(ctx context.Context, model ModelConfig, msgs []Message, tools map[string]ToolDefinition) (Message, error) {
|
|
return defaultLLMClient.Call(ctx, model, msgs, tools)
|
|
}
|
|
|
|
// mockLLMClient is used as a fallback if no actual HTTP client is injected.
|
|
type mockLLMClient struct {
|
|
responses []Message
|
|
calls int
|
|
}
|
|
|
|
func (m *mockLLMClient) Call(ctx context.Context, model ModelConfig, msgs []Message, ts map[string]ToolDefinition) (Message, error) {
|
|
if m.calls < len(m.responses) {
|
|
resp := m.responses[m.calls]
|
|
m.calls++
|
|
return resp, nil
|
|
}
|
|
return Message{
|
|
Role: MessageRoleAssistant,
|
|
Content: `{"action": "RESPOND", "params": {"response": "Mock LLM Response"}}`,
|
|
}, nil
|
|
}
|