365 lines
11 KiB
Go
365 lines
11 KiB
Go
package llm
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
|
)
|
|
|
|
const openAICompatibleProviderName = "openai-compatible"
|
|
|
|
// OpenAICompatibleClientConfig configures the direct HTTP structured-output adapter.
|
|
type OpenAICompatibleClientConfig struct {
|
|
BaseURL string
|
|
Model string
|
|
APIKey string
|
|
MaxRetries int
|
|
HTTPClient *http.Client
|
|
RequestTimeout time.Duration
|
|
}
|
|
|
|
// OpenAICompatibleClient sends OpenAI-compatible chat-completion requests with
|
|
// response_format.type=json_schema.
|
|
type OpenAICompatibleClient struct {
|
|
baseURL string
|
|
model string
|
|
apiKey string
|
|
maxRetries int
|
|
httpClient *http.Client
|
|
requestTimeout time.Duration
|
|
}
|
|
|
|
var _ contracts.StructuredLLMClient = (*OpenAICompatibleClient)(nil)
|
|
|
|
func NewOpenAICompatibleClient(cfg OpenAICompatibleClientConfig) (*OpenAICompatibleClient, error) {
|
|
normalized, err := normalizeOpenAICompatibleConfig(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
client := normalized.HTTPClient
|
|
if client == nil {
|
|
client = http.DefaultClient
|
|
}
|
|
|
|
return &OpenAICompatibleClient{
|
|
baseURL: normalized.BaseURL,
|
|
model: normalized.Model,
|
|
apiKey: normalized.APIKey,
|
|
maxRetries: normalized.MaxRetries,
|
|
httpClient: client,
|
|
requestTimeout: normalized.RequestTimeout,
|
|
}, nil
|
|
}
|
|
|
|
func (c *OpenAICompatibleClient) CompleteStructured(
|
|
ctx context.Context,
|
|
req contracts.StructuredCompletionRequest,
|
|
out any,
|
|
) (contracts.StructuredCompletionResponse, error) {
|
|
if c == nil {
|
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("openai-compatible client must not be nil")
|
|
}
|
|
if err := validateOutputTarget(out); err != nil {
|
|
return contracts.StructuredCompletionResponse{}, err
|
|
}
|
|
|
|
model := strings.TrimSpace(req.Model)
|
|
if model == "" {
|
|
model = c.model
|
|
}
|
|
if model == "" {
|
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("structured completion model must not be empty")
|
|
}
|
|
|
|
schemaName := strings.TrimSpace(req.ResponseSchemaName)
|
|
if schemaName == "" {
|
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("structured completion response schema name must not be empty")
|
|
}
|
|
if len(bytes.TrimSpace(req.ResponseSchema)) == 0 || !json.Valid(req.ResponseSchema) {
|
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("structured completion response schema JSON must be valid")
|
|
}
|
|
|
|
messages, err := toOpenAICompatibleMessages(req.Messages)
|
|
if err != nil {
|
|
return contracts.StructuredCompletionResponse{}, err
|
|
}
|
|
|
|
endpoint := buildChatCompletionsURL(c.baseURL)
|
|
var lastErr error
|
|
for attempt := 0; attempt <= c.maxRetries; attempt++ {
|
|
content, metadata, callErr := c.completeStructuredOnce(ctx, endpoint, model, messages, schemaName, req.ResponseSchema)
|
|
if callErr == nil {
|
|
if decodeErr := json.Unmarshal(content, out); decodeErr != nil {
|
|
callErr = retryableError{err: fmt.Errorf("decode structured output: %w", decodeErr)}
|
|
} else {
|
|
return contracts.StructuredCompletionResponse{
|
|
Content: content,
|
|
Provider: openAICompatibleProviderName,
|
|
Model: firstNonEmpty(metadata.Model, model),
|
|
PromptTokens: metadata.PromptTokens,
|
|
CompletionTokens: metadata.CompletionTokens,
|
|
TotalTokens: metadata.TotalTokens,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
if ctx.Err() != nil {
|
|
return contracts.StructuredCompletionResponse{}, ctx.Err()
|
|
}
|
|
lastErr = c.redactError(callErr)
|
|
if !canRetry(ctx, attempt, c.maxRetries, callErr) {
|
|
return contracts.StructuredCompletionResponse{}, lastErr
|
|
}
|
|
}
|
|
|
|
if lastErr == nil {
|
|
lastErr = fmt.Errorf("structured completion failed")
|
|
}
|
|
return contracts.StructuredCompletionResponse{}, lastErr
|
|
}
|
|
|
|
type openAICompatibleMessage struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
type openAICompatibleRequest struct {
|
|
Model string `json:"model"`
|
|
Messages []openAICompatibleMessage `json:"messages"`
|
|
ResponseFormat openAICompatibleStructuredOutputShape `json:"response_format"`
|
|
}
|
|
|
|
type openAICompatibleStructuredOutputShape struct {
|
|
Type string `json:"type"`
|
|
JSONSchema openAICompatibleSchemaEnvelope `json:"json_schema"`
|
|
}
|
|
|
|
type openAICompatibleSchemaEnvelope struct {
|
|
Name string `json:"name"`
|
|
Strict bool `json:"strict"`
|
|
Schema json.RawMessage `json:"schema"`
|
|
}
|
|
|
|
type openAICompatibleChatCompletionsResponse struct {
|
|
Model string `json:"model"`
|
|
Choices []struct {
|
|
Message struct {
|
|
Content json.RawMessage `json:"content"`
|
|
} `json:"message"`
|
|
} `json:"choices"`
|
|
Usage *openAICompatibleUsage `json:"usage,omitempty"`
|
|
}
|
|
|
|
type openAICompatibleUsage struct {
|
|
PromptTokens int `json:"prompt_tokens"`
|
|
CompletionTokens int `json:"completion_tokens"`
|
|
TotalTokens int `json:"total_tokens"`
|
|
}
|
|
|
|
type openAICompatibleResponseMetadata struct {
|
|
Model string
|
|
PromptTokens int
|
|
CompletionTokens int
|
|
TotalTokens int
|
|
}
|
|
|
|
func normalizeOpenAICompatibleConfig(cfg OpenAICompatibleClientConfig) (OpenAICompatibleClientConfig, error) {
|
|
cfg.BaseURL = strings.TrimSpace(cfg.BaseURL)
|
|
cfg.Model = strings.TrimSpace(cfg.Model)
|
|
cfg.APIKey = strings.TrimSpace(cfg.APIKey)
|
|
if cfg.MaxRetries < 0 {
|
|
return OpenAICompatibleClientConfig{}, fmt.Errorf("max retries must be zero or greater")
|
|
}
|
|
if cfg.BaseURL == "" {
|
|
return OpenAICompatibleClientConfig{}, fmt.Errorf("base URL must not be empty")
|
|
}
|
|
if _, err := url.ParseRequestURI(cfg.BaseURL); err != nil {
|
|
return OpenAICompatibleClientConfig{}, fmt.Errorf("base URL must be valid: %w", err)
|
|
}
|
|
if cfg.Model == "" {
|
|
return OpenAICompatibleClientConfig{}, fmt.Errorf("model must not be empty")
|
|
}
|
|
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
|
|
return cfg, nil
|
|
}
|
|
|
|
func (c *OpenAICompatibleClient) completeStructuredOnce(
|
|
ctx context.Context,
|
|
endpoint string,
|
|
model string,
|
|
messages []openAICompatibleMessage,
|
|
responseSchemaName string,
|
|
responseSchemaJSON json.RawMessage,
|
|
) (json.RawMessage, openAICompatibleResponseMetadata, error) {
|
|
requestCtx := ctx
|
|
var cancel context.CancelFunc
|
|
if c.requestTimeout > 0 {
|
|
requestCtx, cancel = context.WithTimeout(ctx, c.requestTimeout)
|
|
defer cancel()
|
|
}
|
|
|
|
requestBody := openAICompatibleRequest{
|
|
Model: model,
|
|
Messages: messages,
|
|
ResponseFormat: openAICompatibleStructuredOutputShape{
|
|
Type: "json_schema",
|
|
JSONSchema: openAICompatibleSchemaEnvelope{
|
|
Name: responseSchemaName,
|
|
Strict: true,
|
|
Schema: responseSchemaJSON,
|
|
},
|
|
},
|
|
}
|
|
|
|
payload, err := json.Marshal(requestBody)
|
|
if err != nil {
|
|
return nil, openAICompatibleResponseMetadata{}, fmt.Errorf("marshal provider request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(requestCtx, http.MethodPost, endpoint, bytes.NewReader(payload))
|
|
if err != nil {
|
|
return nil, openAICompatibleResponseMetadata{}, fmt.Errorf("build provider request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
if c.apiKey != "" {
|
|
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
}
|
|
|
|
httpResp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, openAICompatibleResponseMetadata{}, retryableError{err: fmt.Errorf("provider request failed: %w", err)}
|
|
}
|
|
defer func() {
|
|
_ = httpResp.Body.Close()
|
|
}()
|
|
|
|
rawResp, err := io.ReadAll(httpResp.Body)
|
|
if err != nil {
|
|
return nil, openAICompatibleResponseMetadata{}, retryableError{err: fmt.Errorf("read provider response: %w", err)}
|
|
}
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
statusErr := parseProviderErrorBody(httpResp.StatusCode, rawResp)
|
|
if httpResp.StatusCode == http.StatusTooManyRequests || httpResp.StatusCode >= 500 {
|
|
return nil, openAICompatibleResponseMetadata{}, retryableError{err: statusErr}
|
|
}
|
|
return nil, openAICompatibleResponseMetadata{}, statusErr
|
|
}
|
|
|
|
return decodeChatCompletionsResponse(rawResp)
|
|
}
|
|
|
|
func toOpenAICompatibleMessages(messages []contracts.LLMMessage) ([]openAICompatibleMessage, error) {
|
|
if len(messages) == 0 {
|
|
return nil, fmt.Errorf("structured completion messages must not be empty")
|
|
}
|
|
|
|
result := make([]openAICompatibleMessage, 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] = openAICompatibleMessage{
|
|
Role: role,
|
|
Content: content,
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func buildChatCompletionsURL(baseURL string) string {
|
|
return strings.TrimRight(baseURL, "/") + "/chat/completions"
|
|
}
|
|
|
|
func decodeChatCompletionsResponse(raw []byte) (json.RawMessage, openAICompatibleResponseMetadata, error) {
|
|
var parsed openAICompatibleChatCompletionsResponse
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
|
return nil, openAICompatibleResponseMetadata{}, retryableError{err: fmt.Errorf("decode provider response envelope: %w", err)}
|
|
}
|
|
if len(parsed.Choices) == 0 {
|
|
return nil, openAICompatibleResponseMetadata{}, retryableError{err: fmt.Errorf("provider response missing choices")}
|
|
}
|
|
|
|
content, err := extractAssistantContentJSON(parsed.Choices[0].Message.Content)
|
|
if err != nil {
|
|
return nil, openAICompatibleResponseMetadata{}, retryableError{err: err}
|
|
}
|
|
|
|
metadata := openAICompatibleResponseMetadata{
|
|
Model: parsed.Model,
|
|
}
|
|
if parsed.Usage != nil {
|
|
metadata.PromptTokens = parsed.Usage.PromptTokens
|
|
metadata.CompletionTokens = parsed.Usage.CompletionTokens
|
|
metadata.TotalTokens = parsed.Usage.TotalTokens
|
|
}
|
|
return content, metadata, nil
|
|
}
|
|
|
|
func extractAssistantContentJSON(raw json.RawMessage) (json.RawMessage, error) {
|
|
trimmedRaw := bytes.TrimSpace(raw)
|
|
if len(trimmedRaw) == 0 || bytes.Equal(trimmedRaw, []byte("null")) {
|
|
return nil, fmt.Errorf("provider response missing assistant message content")
|
|
}
|
|
|
|
var textContent string
|
|
if err := json.Unmarshal(trimmedRaw, &textContent); err == nil {
|
|
textContent = strings.TrimSpace(textContent)
|
|
if textContent == "" {
|
|
return nil, fmt.Errorf("provider response assistant message content is empty")
|
|
}
|
|
if !json.Valid([]byte(textContent)) {
|
|
return nil, fmt.Errorf("provider response assistant message content is not valid JSON")
|
|
}
|
|
return json.RawMessage(textContent), nil
|
|
}
|
|
|
|
if json.Valid(trimmedRaw) {
|
|
return append(json.RawMessage(nil), trimmedRaw...), nil
|
|
}
|
|
return nil, fmt.Errorf("provider response assistant message content is not valid JSON")
|
|
}
|
|
|
|
func parseProviderErrorBody(status int, body []byte) error {
|
|
trimmed := strings.TrimSpace(string(body))
|
|
if trimmed == "" {
|
|
return fmt.Errorf("provider returned status %d", status)
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(body, &payload); err == nil {
|
|
if nested, ok := payload["error"].(map[string]any); ok {
|
|
if msg, ok := nested["message"].(string); ok && strings.TrimSpace(msg) != "" {
|
|
return fmt.Errorf("provider returned status %d: %s", status, strings.TrimSpace(msg))
|
|
}
|
|
}
|
|
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
|
|
return fmt.Errorf("provider returned status %d: %s", status, strings.TrimSpace(msg))
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("provider returned status %d: %s", status, trimmed)
|
|
}
|
|
|
|
func (c *OpenAICompatibleClient) redactError(err error) error {
|
|
secrets := []string{c.apiKey}
|
|
if c.apiKey != "" {
|
|
secrets = append(secrets, "Bearer "+c.apiKey)
|
|
}
|
|
return ErrorWithSecretsRedacted(err, secrets)
|
|
}
|