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:
@@ -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 (
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user