initial commit

This commit is contained in:
2026-03-12 19:17:00 +01:00
commit 9030f2001e
25 changed files with 5089 additions and 0 deletions

View File

@@ -0,0 +1,192 @@
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"github.com/paramah/ai_devs4/s01e03/internal/domain"
)
// LMStudioProvider implements domain.LLMProvider for local LM Studio
type LMStudioProvider struct {
baseURL string
model string
client *http.Client
verbose bool
}
// NewLMStudioProvider creates a new LM Studio provider
func NewLMStudioProvider(baseURL, model string, verbose bool) *LMStudioProvider {
return &LMStudioProvider{
baseURL: baseURL,
model: model,
client: &http.Client{},
verbose: verbose,
}
}
type lmStudioMessage struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolCalls []lmStudioToolCall `json:"tool_calls,omitempty"`
}
type lmStudioToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
type lmStudioRequest struct {
Model string `json:"model"`
Messages []lmStudioMessage `json:"messages"`
Tools []domain.Tool `json:"tools,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
}
type lmStudioResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ToolCalls []lmStudioToolCall `json:"tool_calls,omitempty"`
} `json:"message"`
} `json:"choices"`
Error json.RawMessage `json:"error,omitempty"`
}
// Complete sends a request to LM Studio with function calling support
func (p *LMStudioProvider) Complete(ctx context.Context, request domain.LLMRequest) (*domain.LLMResponse, error) {
// Convert domain messages to LM Studio format
messages := make([]lmStudioMessage, len(request.Messages))
for i, msg := range request.Messages {
// Convert tool calls if present
var toolCalls []lmStudioToolCall
if len(msg.ToolCalls) > 0 {
toolCalls = make([]lmStudioToolCall, len(msg.ToolCalls))
for j, tc := range msg.ToolCalls {
toolCalls[j] = lmStudioToolCall{
ID: tc.ID,
Type: tc.Type,
Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
},
}
}
}
messages[i] = lmStudioMessage{
Role: msg.Role,
Content: msg.Content,
ToolCallID: msg.ToolCallID,
ToolCalls: toolCalls,
}
}
reqBody := lmStudioRequest{
Model: p.model,
Messages: messages,
Tools: request.Tools,
Temperature: 0.7,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshaling request: %w", err)
}
if p.verbose {
var prettyJSON bytes.Buffer
if err := json.Indent(&prettyJSON, jsonData, "", " "); err == nil {
log.Printf("\n========== LM STUDIO REQUEST ==========\nURL: %s/v1/chat/completions\nBody:\n%s\n=======================================\n", p.baseURL, prettyJSON.String())
}
}
url := p.baseURL + "/v1/chat/completions"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
if p.verbose {
var prettyJSON bytes.Buffer
if err := json.Indent(&prettyJSON, body, "", " "); err == nil {
log.Printf("\n========== LM STUDIO RESPONSE ==========\nStatus: %d\nBody:\n%s\n========================================\n", resp.StatusCode, prettyJSON.String())
}
}
// Check HTTP status
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var apiResp lmStudioResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("unmarshaling response: %w\nResponse body: %s", err, string(body))
}
// Check for error in response
if len(apiResp.Error) > 0 {
var errStr string
if err := json.Unmarshal(apiResp.Error, &errStr); err == nil {
return nil, fmt.Errorf("API error: %s", errStr)
}
var errObj struct {
Message string `json:"message"`
}
if err := json.Unmarshal(apiResp.Error, &errObj); err == nil {
return nil, fmt.Errorf("API error: %s", errObj.Message)
}
return nil, fmt.Errorf("API error: %s", string(apiResp.Error))
}
if len(apiResp.Choices) == 0 {
return nil, fmt.Errorf("no choices in response")
}
// Convert tool calls to domain format
toolCalls := make([]domain.ToolCall, len(apiResp.Choices[0].Message.ToolCalls))
for i, tc := range apiResp.Choices[0].Message.ToolCalls {
toolCalls[i] = domain.ToolCall{
ID: tc.ID,
Type: tc.Type,
Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
},
}
}
return &domain.LLMResponse{
Content: apiResp.Choices[0].Message.Content,
ToolCalls: toolCalls,
}, nil
}

View File

@@ -0,0 +1,178 @@
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"github.com/paramah/ai_devs4/s01e03/internal/domain"
)
// OpenRouterProvider implements domain.LLMProvider for OpenRouter API
type OpenRouterProvider struct {
apiKey string
model string
baseURL string
client *http.Client
verbose bool
}
// NewOpenRouterProvider creates a new OpenRouter provider
func NewOpenRouterProvider(apiKey, model string, verbose bool) *OpenRouterProvider {
return &OpenRouterProvider{
apiKey: apiKey,
model: model,
baseURL: "https://openrouter.ai/api/v1/chat/completions",
client: &http.Client{},
verbose: verbose,
}
}
type openRouterMessage struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolCalls []openRouterToolCall `json:"tool_calls,omitempty"`
}
type openRouterToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
type openRouterRequest struct {
Model string `json:"model"`
Messages []openRouterMessage `json:"messages"`
Tools []domain.Tool `json:"tools,omitempty"`
}
type openRouterResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ToolCalls []openRouterToolCall `json:"tool_calls,omitempty"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
// Complete sends a request to OpenRouter API with function calling support
func (p *OpenRouterProvider) Complete(ctx context.Context, request domain.LLMRequest) (*domain.LLMResponse, error) {
// Convert domain messages to OpenRouter format
messages := make([]openRouterMessage, len(request.Messages))
for i, msg := range request.Messages {
// Convert tool calls if present
var toolCalls []openRouterToolCall
if len(msg.ToolCalls) > 0 {
toolCalls = make([]openRouterToolCall, len(msg.ToolCalls))
for j, tc := range msg.ToolCalls {
toolCalls[j] = openRouterToolCall{
ID: tc.ID,
Type: tc.Type,
Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
},
}
}
}
messages[i] = openRouterMessage{
Role: msg.Role,
Content: msg.Content,
ToolCallID: msg.ToolCallID,
ToolCalls: toolCalls,
}
}
reqBody := openRouterRequest{
Model: p.model,
Messages: messages,
Tools: request.Tools,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshaling request: %w", err)
}
if p.verbose {
var prettyJSON bytes.Buffer
if err := json.Indent(&prettyJSON, jsonData, "", " "); err == nil {
log.Printf("\n========== LLM REQUEST ==========\nURL: %s\nBody:\n%s\n================================\n", p.baseURL, prettyJSON.String())
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.apiKey)
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
if p.verbose {
var prettyJSON bytes.Buffer
if err := json.Indent(&prettyJSON, body, "", " "); err == nil {
log.Printf("\n========== LLM RESPONSE ==========\nStatus: %d\nBody:\n%s\n==================================\n", resp.StatusCode, prettyJSON.String())
}
}
var apiResp openRouterResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("unmarshaling response: %w", err)
}
if apiResp.Error != nil {
return nil, fmt.Errorf("API error: %s", apiResp.Error.Message)
}
if len(apiResp.Choices) == 0 {
return nil, fmt.Errorf("no choices in response")
}
// Convert tool calls to domain format
toolCalls := make([]domain.ToolCall, len(apiResp.Choices[0].Message.ToolCalls))
for i, tc := range apiResp.Choices[0].Message.ToolCalls {
toolCalls[i] = domain.ToolCall{
ID: tc.ID,
Type: tc.Type,
Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{
Name: tc.Function.Name,
Arguments: tc.Function.Arguments,
},
}
}
return &domain.LLMResponse{
Content: apiResp.Choices[0].Message.Content,
ToolCalls: toolCalls,
}, nil
}

View File

@@ -0,0 +1,184 @@
package packages
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"github.com/paramah/ai_devs4/s01e03/internal/domain"
)
// APIClient implements domain.PackageClient
type APIClient struct {
baseURL string
apiKey string
client *http.Client
verbose bool
}
// NewAPIClient creates a new package API client
func NewAPIClient(baseURL, apiKey string, verbose bool) *APIClient {
return &APIClient{
baseURL: baseURL,
apiKey: apiKey,
client: &http.Client{},
verbose: verbose,
}
}
type checkRequest struct {
APIKey string `json:"apikey"`
Action string `json:"action"`
PackageID string `json:"packageid"`
}
type checkResponse struct {
Status string `json:"status"`
Location string `json:"location,omitempty"`
Error string `json:"error,omitempty"`
}
type redirectRequest struct {
APIKey string `json:"apikey"`
Action string `json:"action"`
PackageID string `json:"packageid"`
Destination string `json:"destination"`
Code string `json:"code"`
}
type redirectResponse struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
Confirmation string `json:"confirmation,omitempty"`
Error string `json:"error,omitempty"`
}
// Check checks the status of a package
func (c *APIClient) Check(ctx context.Context, packageID string) (*domain.PackageStatus, error) {
reqBody := checkRequest{
APIKey: c.apiKey,
Action: "check",
PackageID: packageID,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshaling request: %w", err)
}
if c.verbose {
var prettyJSON bytes.Buffer
if err := json.Indent(&prettyJSON, jsonData, "", " "); err == nil {
log.Printf("\n========== PACKAGE API CHECK REQUEST ==========\nURL: %s\nBody:\n%s\n===============================================\n", c.baseURL, prettyJSON.String())
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
if c.verbose {
var prettyJSON bytes.Buffer
if err := json.Indent(&prettyJSON, body, "", " "); err == nil {
log.Printf("\n========== PACKAGE API CHECK RESPONSE ==========\nStatus: %d\nBody:\n%s\n================================================\n", resp.StatusCode, prettyJSON.String())
}
}
var apiResp checkResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("unmarshaling response: %w", err)
}
if apiResp.Error != "" {
return nil, fmt.Errorf("API error: %s", apiResp.Error)
}
return &domain.PackageStatus{
PackageID: packageID,
Status: apiResp.Status,
Location: apiResp.Location,
}, nil
}
// Redirect redirects a package to a new destination and returns the confirmation message
func (c *APIClient) Redirect(ctx context.Context, packageID, destination, code string) (string, error) {
reqBody := redirectRequest{
APIKey: c.apiKey,
Action: "redirect",
PackageID: packageID,
Destination: destination,
Code: code,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshaling request: %w", err)
}
if c.verbose {
var prettyJSON bytes.Buffer
if err := json.Indent(&prettyJSON, jsonData, "", " "); err == nil {
log.Printf("\n========== PACKAGE API REDIRECT REQUEST ==========\nURL: %s\nBody:\n%s\n==================================================\n", c.baseURL, prettyJSON.String())
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return "", fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading response: %w", err)
}
if c.verbose {
var prettyJSON bytes.Buffer
if err := json.Indent(&prettyJSON, body, "", " "); err == nil {
log.Printf("\n========== PACKAGE API REDIRECT RESPONSE ==========\nStatus: %d\nBody:\n%s\n===================================================\n", resp.StatusCode, prettyJSON.String())
}
}
var apiResp redirectResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return "", fmt.Errorf("unmarshaling response: %w", err)
}
if apiResp.Error != "" {
return "", fmt.Errorf("API error: %s", apiResp.Error)
}
// Return the confirmation code
if apiResp.Confirmation != "" {
return apiResp.Confirmation, nil
}
// Fallback to message if confirmation is not present
return apiResp.Message, nil
}

View File

@@ -0,0 +1,74 @@
package session
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/paramah/ai_devs4/s01e03/internal/domain"
)
// FileStorage implements domain.SessionStorage using JSON files
type FileStorage struct {
baseDir string
}
// NewFileStorage creates a new file-based session storage
func NewFileStorage(baseDir string) *FileStorage {
return &FileStorage{
baseDir: baseDir,
}
}
// Get retrieves a session from file storage
func (s *FileStorage) Get(ctx context.Context, sessionID string) (*domain.Session, error) {
filePath := s.getFilePath(sessionID)
// Check if file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
// Return empty session if file doesn't exist
return &domain.Session{
ID: sessionID,
Messages: []domain.Message{},
}, nil
}
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("reading session file: %w", err)
}
var session domain.Session
if err := json.Unmarshal(data, &session); err != nil {
return nil, fmt.Errorf("unmarshaling session: %w", err)
}
return &session, nil
}
// Save saves a session to file storage
func (s *FileStorage) Save(ctx context.Context, session *domain.Session) error {
filePath := s.getFilePath(session.ID)
// Ensure directory exists
if err := os.MkdirAll(s.baseDir, 0755); err != nil {
return fmt.Errorf("creating cache directory: %w", err)
}
data, err := json.MarshalIndent(session, "", " ")
if err != nil {
return fmt.Errorf("marshaling session: %w", err)
}
if err := os.WriteFile(filePath, data, 0644); err != nil {
return fmt.Errorf("writing session file: %w", err)
}
return nil
}
func (s *FileStorage) getFilePath(sessionID string) string {
return filepath.Join(s.baseDir, fmt.Sprintf("%s.json", sessionID))
}

View File

@@ -0,0 +1,127 @@
package verify
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"log"
"math/big"
"net/http"
)
// Client handles verification with the hub
type Client struct {
verifyURL string
apiKey string
client *http.Client
verbose bool
}
// NewClient creates a new verify client
func NewClient(verifyURL, apiKey string, verbose bool) *Client {
return &Client{
verifyURL: verifyURL,
apiKey: apiKey,
client: &http.Client{},
verbose: verbose,
}
}
// VerifyRequest represents the request to the verify endpoint
type VerifyRequest struct {
APIKey string `json:"apikey"`
Task string `json:"task"`
Answer VerifyAnswer `json:"answer"`
}
// VerifyAnswer represents the answer part of verify request
type VerifyAnswer struct {
URL string `json:"url"`
SessionID string `json:"sessionID"`
}
// VerifyResponse represents the response from verify endpoint
type VerifyResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Error string `json:"error,omitempty"`
}
// Register registers the proxy with the hub
func (c *Client) Register(ctx context.Context, tunnelURL, sessionID string) error {
reqBody := VerifyRequest{
APIKey: c.apiKey,
Task: "proxy",
Answer: VerifyAnswer{
URL: tunnelURL,
SessionID: sessionID,
},
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("marshaling request: %w", err)
}
if c.verbose {
var prettyJSON bytes.Buffer
if err := json.Indent(&prettyJSON, jsonData, "", " "); err == nil {
log.Printf("\n========== VERIFY REQUEST ==========\nURL: %s\nBody:\n%s\n====================================\n", c.verifyURL, prettyJSON.String())
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.verifyURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("reading response: %w", err)
}
if c.verbose {
var prettyJSON bytes.Buffer
if err := json.Indent(&prettyJSON, body, "", " "); err == nil {
log.Printf("\n========== VERIFY RESPONSE ==========\nStatus: %d\nBody:\n%s\n=====================================\n", resp.StatusCode, prettyJSON.String())
}
}
var apiResp VerifyResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return fmt.Errorf("unmarshaling response: %w (body: %s)", err, string(body))
}
if apiResp.Error != "" {
return fmt.Errorf("API error: %s", apiResp.Error)
}
return nil
}
// GenerateSessionID generates a random alphanumeric session ID
func GenerateSessionID(length int) (string, error) {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
result := make([]byte, length)
for i := range result {
num, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
if err != nil {
return "", fmt.Errorf("generating random number: %w", err)
}
result[i] = charset[num.Int64()]
}
return string(result), nil
}

View File

@@ -0,0 +1,125 @@
package weather
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"github.com/paramah/ai_devs4/s01e03/internal/domain"
)
// APIClient implements domain.WeatherClient using wttr.in
type APIClient struct {
baseURL string
client *http.Client
}
// NewAPIClient creates a new weather API client
// apiKey parameter is ignored as wttr.in doesn't require authentication
func NewAPIClient(baseURL, apiKey string) *APIClient {
if baseURL == "" {
baseURL = "https://wttr.in"
}
return &APIClient{
baseURL: baseURL,
client: &http.Client{},
}
}
type wttrResponse struct {
CurrentCondition []struct {
TempC string `json:"temp_C"`
Humidity string `json:"humidity"`
WeatherDesc []struct {
Value string `json:"value"`
} `json:"weatherDesc"`
} `json:"current_condition"`
NearestArea []struct {
AreaName []struct {
Value string `json:"value"`
} `json:"areaName"`
} `json:"nearest_area"`
}
// GetWeather gets weather information for a location using wttr.in
func (c *APIClient) GetWeather(ctx context.Context, location string) (*domain.WeatherInfo, error) {
// Build URL - wttr.in format: https://wttr.in/Location?format=j1
u, err := url.Parse(c.baseURL + "/" + url.PathEscape(location))
if err != nil {
return nil, fmt.Errorf("parsing URL: %w", err)
}
q := u.Query()
q.Set("format", "j1")
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
// wttr.in prefers this header to return JSON
req.Header.Set("User-Agent", "curl/7.0")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("sending request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
var apiResp wttrResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("unmarshaling response: %w", err)
}
if len(apiResp.CurrentCondition) == 0 {
return nil, fmt.Errorf("no weather data available for location: %s", location)
}
current := apiResp.CurrentCondition[0]
// Parse temperature
temp, err := strconv.ParseFloat(current.TempC, 64)
if err != nil {
return nil, fmt.Errorf("parsing temperature: %w", err)
}
// Parse humidity
humidity, err := strconv.Atoi(current.Humidity)
if err != nil {
return nil, fmt.Errorf("parsing humidity: %w", err)
}
// Get description
description := "unknown"
if len(current.WeatherDesc) > 0 {
description = current.WeatherDesc[0].Value
}
// Get location name
locationName := location
if len(apiResp.NearestArea) > 0 && len(apiResp.NearestArea[0].AreaName) > 0 {
locationName = apiResp.NearestArea[0].AreaName[0].Value
}
return &domain.WeatherInfo{
Location: locationName,
Temperature: temp,
Description: description,
Humidity: humidity,
}, nil
}