220 lines
5.4 KiB
Go
220 lines
5.4 KiB
Go
package llm
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"reflect"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/audita/internal/framework/contracts"
|
|
|
|
"github.com/jxnl/instructor-go/pkg/instructor"
|
|
openai "github.com/sashabaranov/go-openai"
|
|
)
|
|
|
|
const (
|
|
defaultMaxRetries = 3
|
|
)
|
|
|
|
type Mode string
|
|
|
|
const (
|
|
ModeJSON Mode = "json"
|
|
ModeToolCall Mode = "tool_call"
|
|
)
|
|
|
|
type InstructorClientConfig struct {
|
|
BaseURL string
|
|
Model string
|
|
APIKey string
|
|
MaxRetries int
|
|
Mode Mode
|
|
HTTPClient *http.Client
|
|
RequestTimeout time.Duration
|
|
}
|
|
|
|
// chatCompletionClient is intentionally narrow to avoid leaking provider types
|
|
// outside this adapter package.
|
|
type chatCompletionClient interface {
|
|
CreateChatCompletion(ctx context.Context, request openai.ChatCompletionRequest, responseType any) (response openai.ChatCompletionResponse, err error)
|
|
}
|
|
|
|
// InstructorClient adapts instructor-go behind Audita's internal structured
|
|
// LLM interface.
|
|
type InstructorClient struct {
|
|
cfg InstructorClientConfig
|
|
client chatCompletionClient
|
|
}
|
|
|
|
var _ contracts.StructuredLLMClient = (*InstructorClient)(nil)
|
|
|
|
func NewInstructorClient(cfg InstructorClientConfig) (*InstructorClient, error) {
|
|
normalized, err := normalizeConfig(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
openaiConfig := openai.DefaultConfig(normalized.APIKey)
|
|
openaiConfig.BaseURL = normalized.BaseURL
|
|
openaiConfig.HTTPClient = resolvedHTTPClient(normalized.HTTPClient, normalized.RequestTimeout)
|
|
|
|
mode, err := toInstructorMode(normalized.Mode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
client := instructor.FromOpenAI(
|
|
openai.NewClientWithConfig(openaiConfig),
|
|
instructor.WithMode(mode),
|
|
instructor.WithMaxRetries(normalized.MaxRetries),
|
|
)
|
|
|
|
return &InstructorClient{
|
|
cfg: normalized,
|
|
client: client,
|
|
}, nil
|
|
}
|
|
|
|
func (c *InstructorClient) CompleteStructured(
|
|
ctx context.Context,
|
|
req contracts.StructuredCompletionRequest,
|
|
out any,
|
|
) (contracts.StructuredCompletionResponse, error) {
|
|
if err := validateOutputTarget(out); err != nil {
|
|
return contracts.StructuredCompletionResponse{}, err
|
|
}
|
|
|
|
model := strings.TrimSpace(req.Model)
|
|
if model == "" {
|
|
model = c.cfg.Model
|
|
}
|
|
if model == "" {
|
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("structured completion model must not be empty")
|
|
}
|
|
|
|
messages, err := toOpenAIMessages(req.Messages)
|
|
if err != nil {
|
|
return contracts.StructuredCompletionResponse{}, err
|
|
}
|
|
|
|
chatRequest := openai.ChatCompletionRequest{
|
|
Model: model,
|
|
Messages: messages,
|
|
}
|
|
|
|
resp, err := c.client.CreateChatCompletion(ctx, chatRequest, out)
|
|
if err != nil {
|
|
return contracts.StructuredCompletionResponse{}, sanitizeError(err, c.cfg.APIKey)
|
|
}
|
|
|
|
content, err := json.Marshal(out)
|
|
if err != nil {
|
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("marshal structured completion output: %w", err)
|
|
}
|
|
|
|
return contracts.StructuredCompletionResponse{
|
|
Content: content,
|
|
Provider: "openai-compatible",
|
|
Model: model,
|
|
PromptTokens: resp.Usage.PromptTokens,
|
|
CompletionTokens: resp.Usage.CompletionTokens,
|
|
TotalTokens: resp.Usage.TotalTokens,
|
|
}, nil
|
|
}
|
|
|
|
func normalizeConfig(cfg InstructorClientConfig) (InstructorClientConfig, error) {
|
|
cfg.BaseURL = strings.TrimSpace(cfg.BaseURL)
|
|
cfg.Model = strings.TrimSpace(cfg.Model)
|
|
cfg.APIKey = strings.TrimSpace(cfg.APIKey)
|
|
if cfg.MaxRetries == 0 {
|
|
cfg.MaxRetries = defaultMaxRetries
|
|
}
|
|
if cfg.MaxRetries < 0 {
|
|
return InstructorClientConfig{}, fmt.Errorf("max retries must be zero or greater")
|
|
}
|
|
if cfg.BaseURL == "" {
|
|
return InstructorClientConfig{}, fmt.Errorf("base URL must not be empty")
|
|
}
|
|
if cfg.Model == "" {
|
|
return InstructorClientConfig{}, fmt.Errorf("model must not be empty")
|
|
}
|
|
if cfg.Mode == "" {
|
|
cfg.Mode = ModeJSON
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func toInstructorMode(mode Mode) (instructor.Mode, error) {
|
|
switch mode {
|
|
case ModeJSON:
|
|
return instructor.ModeJSON, nil
|
|
case ModeToolCall:
|
|
return instructor.ModeToolCall, nil
|
|
default:
|
|
return "", fmt.Errorf("unsupported LLM mode %q", mode)
|
|
}
|
|
}
|
|
|
|
func resolvedHTTPClient(base *http.Client, timeout time.Duration) *http.Client {
|
|
if base == nil {
|
|
if timeout <= 0 {
|
|
return http.DefaultClient
|
|
}
|
|
return &http.Client{Timeout: timeout}
|
|
}
|
|
|
|
if timeout <= 0 {
|
|
return base
|
|
}
|
|
|
|
cloned := *base
|
|
cloned.Timeout = timeout
|
|
return &cloned
|
|
}
|
|
|
|
func toOpenAIMessages(messages []contracts.LLMMessage) ([]openai.ChatCompletionMessage, error) {
|
|
result := make([]openai.ChatCompletionMessage, len(messages))
|
|
for i, message := range messages {
|
|
role := strings.TrimSpace(message.Role)
|
|
content := strings.TrimSpace(message.Content)
|
|
if role == "" {
|
|
return nil, fmt.Errorf("message[%d] role must not be empty", i)
|
|
}
|
|
if content == "" {
|
|
return nil, fmt.Errorf("message[%d] content must not be empty", i)
|
|
}
|
|
result[i] = openai.ChatCompletionMessage{
|
|
Role: role,
|
|
Content: content,
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func validateOutputTarget(out any) error {
|
|
if out == nil {
|
|
return fmt.Errorf("output target must not be nil")
|
|
}
|
|
value := reflect.ValueOf(out)
|
|
if value.Kind() != reflect.Ptr || value.IsNil() {
|
|
return fmt.Errorf("output target must be a non-nil pointer")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sanitizeError(err error, apiKey string) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
msg := err.Error()
|
|
key := strings.TrimSpace(apiKey)
|
|
if key != "" {
|
|
msg = strings.ReplaceAll(msg, key, "[REDACTED]")
|
|
msg = strings.ReplaceAll(msg, "Bearer "+key, "Bearer [REDACTED]")
|
|
}
|
|
return fmt.Errorf("%s", msg)
|
|
}
|