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.
This commit is contained in:
2026-03-05 21:21:41 +01:00
parent 02a03c3d41
commit 340132626c
3 changed files with 66 additions and 110 deletions

View File

@@ -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 (

View File

@@ -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=

View File

@@ -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
}