286 lines
9.5 KiB
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,
|
|
}
|
|
}
|