Files
Assistant/adapters/models/openai_advisory.go

286 lines
9.5 KiB
Go

package models
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"unicode/utf8"
"git.cin.su/m/Assistant/contracts/cognitive"
)
const (
openAIResponsesURL = "https://api.openai.com/v1/responses"
openAIModel = "gpt-5.4-mini-2026-03-17"
openAIMaxOutputTokens = 4096
openAIResponseLimitBytes = 1 << 20
openAITimeout = 30 * time.Second
openAIInstructions = "Provide non-canonical advisory text only. State uncertainty when appropriate. Never claim that an action occurred, never request or expose secrets, and never emit tool or executable instructions."
)
type OpenAIAdvisoryAdapter struct {
apiKey []byte
client *http.Client
}
func NewOpenAIAdvisoryAdapter(apiKey []byte, transport http.RoundTripper) (*OpenAIAdvisoryAdapter, error) {
if len(apiKey) == 0 || bytes.IndexAny(apiKey, "\r\n") >= 0 {
return nil, errors.New("invalid OpenAI credential")
}
if transport == nil {
base, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return nil, errors.New("default HTTP transport is unavailable")
}
cloned := base.Clone()
cloned.Proxy = nil
transport = cloned
}
return &OpenAIAdvisoryAdapter{
apiKey: append([]byte(nil), apiKey...),
client: &http.Client{
Transport: transport,
Timeout: openAITimeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
}, nil
}
type openAIRequest struct {
Model string `json:"model"`
Instructions string `json:"instructions"`
Input string `json:"input"`
Store bool `json:"store"`
Reasoning openAIReasoning `json:"reasoning"`
MaxOutputTokens int `json:"max_output_tokens"`
}
type openAIReasoning struct {
Effort string `json:"effort"`
}
type openAIResponse struct {
Status string `json:"status"`
Model string `json:"model"`
Store *bool `json:"store"`
Output []json.RawMessage `json:"output"`
Usage *openAIUsage `json:"usage"`
IncompleteDetails *openAIIncompleteDetails `json:"incomplete_details"`
}
type openAIIncompleteDetails struct {
Reason string `json:"reason"`
}
type openAIUsage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
}
type openAIOutputHeader struct {
Type string `json:"type"`
}
type openAIMessage struct {
Type string `json:"type"`
Role string `json:"role"`
Status string `json:"status"`
Content []json.RawMessage `json:"content"`
}
type openAIContentHeader struct {
Type string `json:"type"`
}
type openAIOutputText struct {
Type string `json:"type"`
Text string `json:"text"`
Annotations []json.RawMessage `json:"annotations"`
}
type openAIRefusal struct {
Type string `json:"type"`
Refusal string `json:"refusal"`
}
type openAIReasoningItem struct {
Type string `json:"type"`
Summary []json.RawMessage `json:"summary"`
}
func (a *OpenAIAdvisoryAdapter) Advise(ctx context.Context, request cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
if err := ValidateAdvisoryRequest(request); err != nil {
return cognitive.AdvisoryResponse{}, err
}
if request.ProviderInstanceID == "" || request.ModelDescriptorID == "" || string(request.ProviderModelID) != openAIModel || len(request.Context) != 0 {
return cognitive.AdvisoryResponse{}, fmt.Errorf("%w: incompatible registry binding", ErrInvalidAdvisory)
}
payload, err := json.Marshal(openAIRequest{
Model: openAIModel,
Instructions: openAIInstructions,
Input: request.Message,
Store: false,
Reasoning: openAIReasoning{Effort: "low"},
MaxOutputTokens: openAIMaxOutputTokens,
})
if err != nil {
return cognitive.AdvisoryResponse{}, err
}
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, openAIResponsesURL, bytes.NewReader(payload))
if err != nil {
return cognitive.AdvisoryResponse{}, err
}
httpRequest.Header.Set("Authorization", "Bearer "+string(a.apiKey))
httpRequest.Header.Set("Content-Type", "application/json")
httpRequest.Header.Set("Accept", "application/json")
httpResponse, err := a.client.Do(httpRequest)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return advisoryFailure(request, cognitive.FinishTimeout), nil
}
var networkError net.Error
if errors.As(err, &networkError) && networkError.Timeout() {
return advisoryFailure(request, cognitive.FinishTimeout), nil
}
return advisoryFailure(request, cognitive.FinishProviderUnavailable), nil
}
defer httpResponse.Body.Close()
if httpResponse.StatusCode != http.StatusOK {
return advisoryFailure(request, mapOpenAIStatus(httpResponse.StatusCode)), nil
}
if contentType := httpResponse.Header.Get("Content-Type"); contentType != "" && !strings.HasPrefix(strings.ToLower(contentType), "application/json") {
return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil
}
body, err := io.ReadAll(io.LimitReader(httpResponse.Body, openAIResponseLimitBytes+1))
if err != nil || len(body) > openAIResponseLimitBytes {
return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil
}
var providerResponse openAIResponse
if err := json.Unmarshal(body, &providerResponse); err != nil || providerResponse.Model != openAIModel || (providerResponse.Store != nil && *providerResponse.Store) || providerResponse.Usage == nil || providerResponse.Usage.InputTokens < 0 || providerResponse.Usage.OutputTokens < 0 {
return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil
}
if providerResponse.Status == "incomplete" && providerResponse.IncompleteDetails != nil && providerResponse.IncompleteDetails.Reason == "max_output_tokens" {
return advisoryFailure(request, cognitive.FinishLengthLimit), nil
}
if providerResponse.Status != "completed" || providerResponse.IncompleteDetails != nil {
return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil
}
text, refused, ok := extractOpenAIText(providerResponse.Output)
if !ok {
return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil
}
if refused {
return advisoryFailure(request, cognitive.FinishContentRejected), nil
}
response := cognitive.AdvisoryResponse{
Version: cognitive.CreatorAdvisoryV1,
RequestID: request.RequestID,
ProviderBindingID: request.ProviderBindingID,
ProviderBindingRevision: request.ProviderBindingRevision,
AdvisoryText: text,
FinishReason: cognitive.FinishCompleted,
Usage: &cognitive.ProviderUsage{
InputUnits: providerResponse.Usage.InputTokens,
OutputUnits: providerResponse.Usage.OutputTokens,
},
}
if err := ValidateAdvisoryResponse(request, response); err != nil {
return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil
}
return response, nil
}
func mapOpenAIStatus(status int) cognitive.FinishReason {
switch status {
case http.StatusUnauthorized, http.StatusForbidden:
return cognitive.FinishAuthenticationFailed
case http.StatusPaymentRequired:
return cognitive.FinishBillingOrBudgetBlocked
case http.StatusNotFound:
return cognitive.FinishModelUnavailable
case http.StatusRequestEntityTooLarge:
return cognitive.FinishContextTooLarge
case http.StatusTooManyRequests:
return cognitive.FinishRateLimited
case http.StatusRequestTimeout, http.StatusGatewayTimeout:
return cognitive.FinishTimeout
default:
return cognitive.FinishProviderUnavailable
}
}
func extractOpenAIText(output []json.RawMessage) (string, bool, bool) {
var text string
refused := false
messages := 0
for _, raw := range output {
var header openAIOutputHeader
if json.Unmarshal(raw, &header) != nil {
return "", false, false
}
switch header.Type {
case "reasoning":
var item openAIReasoningItem
if json.Unmarshal(raw, &item) != nil || len(item.Summary) != 0 {
return "", false, false
}
case "message":
messages++
if messages != 1 {
return "", false, false
}
var message openAIMessage
if json.Unmarshal(raw, &message) != nil || message.Role != "assistant" || message.Status != "completed" || len(message.Content) != 1 {
return "", false, false
}
var contentHeader openAIContentHeader
if json.Unmarshal(message.Content[0], &contentHeader) != nil {
return "", false, false
}
switch contentHeader.Type {
case "output_text":
var content openAIOutputText
if json.Unmarshal(message.Content[0], &content) != nil || content.Text == "" || !utf8.ValidString(content.Text) || len(content.Annotations) != 0 {
return "", false, false
}
text = content.Text
case "refusal":
var content openAIRefusal
if json.Unmarshal(message.Content[0], &content) != nil || content.Refusal == "" {
return "", false, false
}
refused = true
default:
return "", false, false
}
default:
return "", false, false
}
}
if messages != 1 || (refused && text != "") || (!refused && text == "") {
return "", false, false
}
return text, refused, true
}
func advisoryFailure(request cognitive.AdvisoryRequest, reason cognitive.FinishReason) cognitive.AdvisoryResponse {
return cognitive.AdvisoryResponse{
Version: cognitive.CreatorAdvisoryV1,
RequestID: request.RequestID,
ProviderBindingID: request.ProviderBindingID,
ProviderBindingRevision: request.ProviderBindingRevision,
FinishReason: reason,
}
}