Implement Phase 9 structured LLM adapter spike

This commit is contained in:
2026-05-11 19:54:57 -05:00
parent aeb31f1c0d
commit 0b17a6fbeb
8 changed files with 730 additions and 25 deletions

View File

@@ -0,0 +1,221 @@
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
}
// TODO(phase9): integrate bounded scheduler/semaphore in the next Phase 9 prompt.
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)
}