From 340132626c3a0b75b42e793073f829b9831d0331 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Thu, 5 Mar 2026 21:21:41 +0100 Subject: [PATCH] refactor: replace hand-rolled Mistral HTTP client with mistral-go-sdk Use the Conversations API (POST /v1/conversations) via the SDK for Pass 1 agent calls instead of /agents/completions which doesn't support built-in web search connectors. Pass 2 uses the SDK's ChatComplete method. --- backend/go.mod | 3 +- backend/go.sum | 2 + backend/internal/pkg/ai/client.go | 171 +++++++++++------------------- 3 files changed, 66 insertions(+), 110 deletions(-) diff --git a/backend/go.mod b/backend/go.mod index 4eb7c9d..a6e8c05 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module marktvogt.de/backend -go 1.25.7 +go 1.26 require ( github.com/gin-gonic/gin v1.11.0 @@ -13,6 +13,7 @@ require ( golang.org/x/crypto v0.48.0 golang.org/x/oauth2 v0.35.0 golang.org/x/time v0.14.0 + somegit.dev/vikingowl/mistral-go-sdk v0.1.0 ) require ( diff --git a/backend/go.sum b/backend/go.sum index 0621191..a38e4bb 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -113,3 +113,5 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +somegit.dev/vikingowl/mistral-go-sdk v0.1.0 h1:VrTa6iTo3D4TK/URUgXLWNqdvT5/28R9tbXM/gQiGCM= +somegit.dev/vikingowl/mistral-go-sdk v0.1.0/go.mod h1:pN7nQdOIYYEMRdwye5cSfymtwhZJHd+caK6J69Z4XMY= diff --git a/backend/internal/pkg/ai/client.go b/backend/internal/pkg/ai/client.go index 16d1562..69407bf 100644 --- a/backend/internal/pkg/ai/client.go +++ b/backend/internal/pkg/ai/client.go @@ -1,70 +1,43 @@ package ai import ( - "bytes" "context" - "encoding/json" "fmt" - "io" - "log/slog" - "net/http" "time" + + "somegit.dev/vikingowl/mistral-go-sdk" + "somegit.dev/vikingowl/mistral-go-sdk/chat" + "somegit.dev/vikingowl/mistral-go-sdk/conversation" ) type Client struct { - apiKey string - agentSimple string // Pre-created agent ID for Pass 1 (extraction with web search) - modelComplex string // Model for Pass 2 (description + retry) - baseURL string - client *http.Client + sdk *mistral.Client + agentSimple string + modelComplex string } func New(apiKey, agentSimple, modelComplex string) *Client { if modelComplex == "" { modelComplex = "mistral-large-latest" } + + var sdk *mistral.Client + if apiKey != "" { + sdk = mistral.NewClient(apiKey, + mistral.WithTimeout(120*time.Second), + mistral.WithRetry(2, 1*time.Second), + ) + } + return &Client{ - apiKey: apiKey, + sdk: sdk, agentSimple: agentSimple, modelComplex: modelComplex, - baseURL: "https://api.mistral.ai/v1", - client: &http.Client{Timeout: 120 * time.Second}, } } func (c *Client) Enabled() bool { - return c.apiKey != "" && c.agentSimple != "" -} - -// --- API types --- - -type agentCompletionRequest struct { - AgentID string `json:"agent_id"` - Messages []chatMessage `json:"messages"` -} - -type chatRequest struct { - Model string `json:"model"` - Messages []chatMessage `json:"messages"` - ResponseFormat *respFormat `json:"response_format,omitempty"` -} - -type respFormat struct { - Type string `json:"type"` -} - -type chatMessage struct { - Role string `json:"role"` - Content string `json:"content"` -} - -type chatResponse struct { - Choices []struct { - Message struct { - Content string `json:"content"` - } `json:"message"` - } `json:"choices"` - Usage *UsageInfo `json:"usage,omitempty"` + return c.sdk != nil && c.agentSimple != "" } type UsageInfo struct { @@ -73,102 +46,82 @@ type UsageInfo struct { TotalTokens int `json:"total_tokens"` } -// PassResult holds the raw response and token usage from a single API call. type PassResult struct { Content string Usage *UsageInfo Model string } -// Pass1 calls the pre-created agent (with web search) for structured extraction. +// Pass1 uses the Conversations API to call the pre-created agent (with web search). func (c *Client) Pass1(ctx context.Context, prompt string) (PassResult, error) { - body := agentCompletionRequest{ + storeFalse := false + resp, err := c.sdk.StartConversation(ctx, &conversation.StartRequest{ AgentID: c.agentSimple, - Messages: []chatMessage{ - {Role: "user", Content: prompt}, - }, + Inputs: conversation.TextInputs(prompt), + Store: &storeFalse, + }) + if err != nil { + return PassResult{}, fmt.Errorf("pass1 conversation: %w", err) } - content, usage, err := c.doPostWithUsage(ctx, "/agents/completions", body) - if err != nil { - return PassResult{}, fmt.Errorf("pass1 agent call: %w", err) + content := extractConversationContent(resp) + if content == "" { + return PassResult{}, fmt.Errorf("pass1: no assistant message in response") } return PassResult{ Content: content, - Usage: usage, + Usage: convertConvUsage(resp.Usage), Model: "agent:" + c.agentSimple, }, nil } -// Pass2 calls chat completions (no agent, no web search) for description + retry. +// Pass2 uses chat completions for description generation + retry fields. func (c *Client) Pass2(ctx context.Context, systemPrompt, userPrompt string) (PassResult, error) { - body := chatRequest{ + resp, err := c.sdk.ChatComplete(ctx, &chat.CompletionRequest{ Model: c.modelComplex, - Messages: []chatMessage{ - {Role: "system", Content: systemPrompt}, - {Role: "user", Content: userPrompt}, + Messages: []chat.Message{ + &chat.SystemMessage{Content: chat.TextContent(systemPrompt)}, + &chat.UserMessage{Content: chat.TextContent(userPrompt)}, }, - ResponseFormat: &respFormat{Type: "json_object"}, + ResponseFormat: &chat.ResponseFormat{Type: "json_object"}, + }) + if err != nil { + return PassResult{}, fmt.Errorf("pass2 chat: %w", err) } - content, usage, err := c.doPostWithUsage(ctx, "/chat/completions", body) - if err != nil { - return PassResult{}, fmt.Errorf("pass2 chat call: %w", err) + if len(resp.Choices) == 0 { + return PassResult{}, fmt.Errorf("pass2: no choices in response") } return PassResult{ - Content: content, - Usage: usage, + Content: resp.Choices[0].Message.Content.String(), + Usage: convertChatUsage(resp.Usage), Model: c.modelComplex, }, nil } -// Legacy Research method for backward compat (uses Pass1 only). -func (c *Client) Research(ctx context.Context, prompt string) (string, error) { - result, err := c.Pass1(ctx, prompt) - if err != nil { - return "", err +func extractConversationContent(resp *conversation.Response) string { + for _, entry := range resp.Outputs { + if msg, ok := entry.(*conversation.MessageOutputEntry); ok { + return msg.Content.String() + } } - return result.Content, nil + return "" } -func (c *Client) doPostWithUsage(ctx context.Context, path string, body any) (string, *UsageInfo, error) { - jsonBody, err := json.Marshal(body) - if err != nil { - return "", nil, fmt.Errorf("marshaling request: %w", err) +func convertConvUsage(u conversation.UsageInfo) *UsageInfo { + return &UsageInfo{ + PromptTokens: u.PromptTokens, + CompletionTokens: u.CompletionTokens, + TotalTokens: u.TotalTokens, + } +} + +func convertChatUsage(u chat.UsageInfo) *UsageInfo { + return &UsageInfo{ + PromptTokens: u.PromptTokens, + CompletionTokens: u.CompletionTokens, + TotalTokens: u.TotalTokens, } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(jsonBody)) - if err != nil { - return "", nil, fmt.Errorf("creating request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+c.apiKey) - - resp, err := c.client.Do(req) - if err != nil { - return "", nil, fmt.Errorf("API request: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return "", nil, fmt.Errorf("reading response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - slog.Warn("Mistral API error", "status", resp.StatusCode, "body", string(respBody)) - return "", nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBody)) - } - - var chatResp chatResponse - if err := json.Unmarshal(respBody, &chatResp); err != nil { - return "", nil, fmt.Errorf("parsing response: %w", err) - } - if len(chatResp.Choices) == 0 { - return "", nil, fmt.Errorf("no choices in response") - } - - return chatResp.Choices[0].Message.Content, chatResp.Usage, nil }