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 }