Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
417641dbf4 | ||
|
|
0ee9e4a8a1 | ||
|
|
3b009033d6 | ||
|
|
00965f20e0 |
@@ -0,0 +1,269 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
)
|
||||
|
||||
const (
|
||||
OpenAIAdvisoryBindingID capability.CapabilityBindingID = "openai-responses-gpt-5.4-mini-2026-03-17"
|
||||
OpenAIAdvisoryBindingRevision int64 = 1
|
||||
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.ProviderBindingID != OpenAIAdvisoryBindingID || request.ProviderBindingRevision != OpenAIAdvisoryBindingRevision || len(request.Context) != 0 {
|
||||
return cognitive.AdvisoryResponse{}, fmt.Errorf("%w: OpenAI Mode A 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, cognitive.FinishProviderUnavailable), 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 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }
|
||||
|
||||
func validOpenAIAdvisoryRequest() cognitive.AdvisoryRequest {
|
||||
return cognitive.AdvisoryRequest{
|
||||
Version: cognitive.CreatorAdvisoryV1,
|
||||
RequestID: "request-1",
|
||||
ActorID: identity.ActorID("actor-private"),
|
||||
PrincipalID: identity.PrincipalID("principal-private"),
|
||||
ScopeID: authority.ScopeID("scope-private"),
|
||||
Message: "What should I verify?",
|
||||
Locale: "en",
|
||||
MaxOutputBytes: MaxAdvisoryResponseBytes,
|
||||
ProviderBindingID: OpenAIAdvisoryBindingID,
|
||||
ProviderBindingRevision: OpenAIAdvisoryBindingRevision,
|
||||
}
|
||||
}
|
||||
|
||||
func providerHTTPResponse(status int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
}
|
||||
|
||||
func validOpenAIResponse(text string) string {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"status": "completed",
|
||||
"model": openAIModel,
|
||||
"store": false,
|
||||
"output": []any{
|
||||
map[string]any{"type": "reasoning", "summary": []any{}},
|
||||
map[string]any{"type": "message", "role": "assistant", "status": "completed", "content": []any{
|
||||
map[string]any{"type": "output_text", "text": text, "annotations": []any{}},
|
||||
}},
|
||||
},
|
||||
"usage": map[string]any{"input_tokens": 12, "output_tokens": 7},
|
||||
})
|
||||
return string(body)
|
||||
}
|
||||
|
||||
func TestOpenAIAdvisorySendsOnlyModeAPayload(t *testing.T) {
|
||||
calls := 0
|
||||
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
if request.Method != http.MethodPost || request.URL.String() != openAIResponsesURL || request.Host != "api.openai.com" {
|
||||
t.Fatalf("unexpected target: %s %s host=%s", request.Method, request.URL, request.Host)
|
||||
}
|
||||
if request.Header.Get("Authorization") != "Bearer test-key" || request.Header.Get("Content-Type") != "application/json" {
|
||||
t.Fatal("provider authentication or content type is missing")
|
||||
}
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(payload) != 6 || payload["model"] != openAIModel || payload["input"] != "What should I verify?" || payload["store"] != false || payload["instructions"] != openAIInstructions || payload["max_output_tokens"] != float64(openAIMaxOutputTokens) {
|
||||
t.Fatalf("unexpected provider payload keys/values: %v", payload)
|
||||
}
|
||||
reasoning, ok := payload["reasoning"].(map[string]any)
|
||||
if !ok || len(reasoning) != 1 || reasoning["effort"] != "low" {
|
||||
t.Fatalf("unexpected reasoning payload: %v", payload["reasoning"])
|
||||
}
|
||||
serialized := string(body)
|
||||
for _, forbidden := range []string{"actor-private", "principal-private", "scope-private", "request-1", "tools", "previous_response_id", "conversation", "background"} {
|
||||
if strings.Contains(serialized, forbidden) {
|
||||
t.Fatalf("provider payload leaked forbidden field %q", forbidden)
|
||||
}
|
||||
}
|
||||
return providerHTTPResponse(http.StatusOK, validOpenAIResponse("Check the current evidence.")), nil
|
||||
})
|
||||
adapter, err := NewOpenAIAdvisoryAdapter([]byte("test-key"), transport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 1 || response.FinishReason != cognitive.FinishCompleted || response.AdvisoryText != "Check the current evidence." || response.Usage == nil || response.Usage.InputUnits != 12 || response.Usage.OutputUnits != 7 {
|
||||
t.Fatalf("unexpected response: calls=%d response=%+v", calls, response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAdvisoryRejectsContextBeforeTransport(t *testing.T) {
|
||||
called := false
|
||||
adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
called = true
|
||||
return nil, errors.New("must not call")
|
||||
}))
|
||||
request := validOpenAIAdvisoryRequest()
|
||||
request.Context = []cognitive.ContextExcerpt{{ID: "excerpt", SourceKind: "task", SourceID: "task", Revision: 1, Sensitivity: cognitive.SensitivityPublic, Text: "public"}}
|
||||
if _, err := adapter.Advise(context.Background(), request); err == nil || called {
|
||||
t.Fatalf("error=%v called=%t", err, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAdvisoryDoesNotFollowRedirectOrRetry(t *testing.T) {
|
||||
calls := 0
|
||||
adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
response := providerHTTPResponse(http.StatusFound, "{}")
|
||||
response.Header.Set("Location", "https://attacker.invalid/steal")
|
||||
return response, nil
|
||||
}))
|
||||
response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest())
|
||||
if err != nil || calls != 1 || response.FinishReason != cognitive.FinishProviderUnavailable {
|
||||
t.Fatalf("error=%v calls=%d response=%+v", err, calls, response)
|
||||
}
|
||||
}
|
||||
|
||||
type timeoutError struct{}
|
||||
|
||||
func (timeoutError) Error() string { return "timeout" }
|
||||
func (timeoutError) Timeout() bool { return true }
|
||||
func (timeoutError) Temporary() bool { return true }
|
||||
|
||||
func TestOpenAIAdvisoryMapsTimeoutAndDoesNotRetry(t *testing.T) {
|
||||
calls := 0
|
||||
adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
return nil, timeoutError{}
|
||||
}))
|
||||
response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest())
|
||||
if err != nil || calls != 1 || response.FinishReason != cognitive.FinishTimeout {
|
||||
t.Fatalf("error=%v calls=%d response=%+v", err, calls, response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAdvisoryMapsRefusal(t *testing.T) {
|
||||
body := `{"status":"completed","model":"gpt-5.4-mini-2026-03-17","store":false,"output":[{"type":"message","role":"assistant","status":"completed","content":[{"type":"refusal","refusal":"cannot comply"}]}],"usage":{"input_tokens":3,"output_tokens":0}}`
|
||||
adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return providerHTTPResponse(http.StatusOK, body), nil
|
||||
}))
|
||||
response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest())
|
||||
if err != nil || response.FinishReason != cognitive.FinishContentRejected || response.AdvisoryText != "" {
|
||||
t.Fatalf("error=%v response=%+v", err, response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAdvisoryMapsOutputLimit(t *testing.T) {
|
||||
body := `{"status":"incomplete","model":"gpt-5.4-mini-2026-03-17","store":false,"incomplete_details":{"reason":"max_output_tokens"},"output":[],"usage":{"input_tokens":3,"output_tokens":4096}}`
|
||||
adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return providerHTTPResponse(http.StatusOK, body), nil
|
||||
}))
|
||||
response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest())
|
||||
if err != nil || response.FinishReason != cognitive.FinishLengthLimit || response.AdvisoryText != "" {
|
||||
t.Fatalf("error=%v response=%+v", err, response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAdvisoryRejectsExecutableOrUntrustedOutput(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"tool call": `{"status":"completed","model":"gpt-5.4-mini-2026-03-17","store":false,"output":[{"type":"function_call","name":"mutate"}],"usage":{"input_tokens":1,"output_tokens":1}}`,
|
||||
"citation": `{"status":"completed","model":"gpt-5.4-mini-2026-03-17","store":false,"output":[{"type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"answer","annotations":[{"type":"url_citation"}]}]}],"usage":{"input_tokens":1,"output_tokens":1}}`,
|
||||
"wrong model": strings.Replace(validOpenAIResponse("answer"), openAIModel, "moving-alias", 1),
|
||||
"oversize": validOpenAIResponse(strings.Repeat("a", MaxAdvisoryResponseBytes+1)),
|
||||
"malformed": `{`,
|
||||
}
|
||||
for name, body := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
adapter, _ := NewOpenAIAdvisoryAdapter([]byte("test-key"), roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return providerHTTPResponse(http.StatusOK, body), nil
|
||||
}))
|
||||
response, err := adapter.Advise(context.Background(), validOpenAIAdvisoryRequest())
|
||||
if err != nil || response.FinishReason != cognitive.FinishInvalidProviderResponse || response.AdvisoryText != "" {
|
||||
t.Fatalf("error=%v response=%+v", err, response)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAdvisoryRejectsInvalidCredential(t *testing.T) {
|
||||
for _, key := range [][]byte{nil, []byte("line\nbreak")} {
|
||||
if _, err := NewOpenAIAdvisoryAdapter(key, nil); err == nil {
|
||||
t.Fatalf("credential %q was accepted", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/models"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
)
|
||||
|
||||
type stubAdvisoryProvider struct {
|
||||
called bool
|
||||
request cognitive.AdvisoryRequest
|
||||
response cognitive.AdvisoryResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubAdvisoryProvider) Advise(_ context.Context, request cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
|
||||
s.called = true
|
||||
s.request = request
|
||||
return s.response, s.err
|
||||
}
|
||||
|
||||
func TestDeterministicAdvisoryRejectsUnknownJSONField(t *testing.T) {
|
||||
body := `{"Version":"creator-advisory-v1","Unexpected":"provider override"}`
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/advisories/deterministic", strings.NewReader(body))
|
||||
@@ -36,3 +53,90 @@ func TestDeterministicAdvisoryReturnsUnavailableContract(t *testing.T) {
|
||||
t.Fatalf("unexpected response: %+v", advisory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAdvisoryIsUnavailableBeforeBodyParsingWhenDisabled(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/advisories/openai", strings.NewReader(`not-json`))
|
||||
response := httptest.NewRecorder()
|
||||
openAIAdvisory(nil)(response, request)
|
||||
if response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d, want 503", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAdvisoryStrictlyDecodesBeforeProvider(t *testing.T) {
|
||||
provider := &stubAdvisoryProvider{}
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/advisories/openai", strings.NewReader(`{"Version":"creator-advisory-v1","Unexpected":"provider override"}`))
|
||||
response := httptest.NewRecorder()
|
||||
openAIAdvisory(provider)(response, request)
|
||||
if response.Code != http.StatusBadRequest || provider.called {
|
||||
t.Fatalf("status=%d provider_called=%t", response.Code, provider.called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIAdvisoryReturnsProviderNeutralResponse(t *testing.T) {
|
||||
provider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{
|
||||
Version: cognitive.CreatorAdvisoryV1,
|
||||
RequestID: "request-1",
|
||||
ProviderBindingID: models.OpenAIAdvisoryBindingID,
|
||||
ProviderBindingRevision: models.OpenAIAdvisoryBindingRevision,
|
||||
AdvisoryText: "advice",
|
||||
FinishReason: cognitive.FinishCompleted,
|
||||
}}
|
||||
body := `{"Version":"creator-advisory-v1","RequestID":"request-1","ActorID":"actor","PrincipalID":"principal","ScopeID":"scope","Message":"help","Context":[],"Locale":"en","MaxOutputBytes":4096,"ProviderBindingID":"openai-responses-gpt-5.4-mini-2026-03-17","ProviderBindingRevision":1}`
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/advisories/openai", strings.NewReader(body))
|
||||
response := httptest.NewRecorder()
|
||||
openAIAdvisory(provider)(response, request)
|
||||
if response.Code != http.StatusOK || !provider.called || provider.request.Message != "help" {
|
||||
t.Fatalf("status=%d provider_called=%t", response.Code, provider.called)
|
||||
}
|
||||
var advisory cognitive.AdvisoryResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&advisory); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if advisory.AdvisoryText != "advice" || advisory.FinishReason != cognitive.FinishCompleted {
|
||||
t.Fatalf("unexpected response: %+v", advisory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOpenAIAdvisoryProviderDefaultsDisabled(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "")
|
||||
provider, err := loadOpenAIAdvisoryProvider("")
|
||||
if err != nil || provider != nil {
|
||||
t.Fatalf("provider=%v error=%v", provider, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOpenAIAdvisoryProviderRejectsDirectEnvironmentCredential(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "must-not-be-read")
|
||||
if _, err := loadOpenAIAdvisoryProvider(""); err == nil || strings.Contains(err.Error(), "must-not-be-read") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOpenAIAdvisoryProviderUsesBoundedSecretFile(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "")
|
||||
directory := t.TempDir()
|
||||
validPath := filepath.Join(directory, "valid")
|
||||
if err := os.WriteFile(validPath, []byte("test-project-key\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider, err := loadOpenAIAdvisoryProvider(validPath)
|
||||
if err != nil || provider == nil {
|
||||
t.Fatalf("provider=%v error=%v", provider, err)
|
||||
}
|
||||
for name, content := range map[string][]byte{
|
||||
"empty": nil,
|
||||
"embedded whitespace": []byte("invalid key"),
|
||||
"oversized": []byte(strings.Repeat("x", maxOpenAISecretBytes+1)),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := filepath.Join(directory, name)
|
||||
if err := os.WriteFile(path, content, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadOpenAIAdvisoryProvider(path); err == nil {
|
||||
t.Fatal("invalid secret file was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.cin.su/m/Assistant/adapters/models"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/runtime/health"
|
||||
@@ -11,15 +15,26 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
const maxOpenAISecretBytes = 8 << 10
|
||||
|
||||
type advisoryProvider interface {
|
||||
Advise(context.Context, cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error)
|
||||
}
|
||||
|
||||
func main() {
|
||||
addr := os.Getenv("LISTEN_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8083"
|
||||
}
|
||||
openAIProvider, err := loadOpenAIAdvisoryProvider(os.Getenv("OPENAI_API_KEY_FILE"))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/healthz", health.Handler{Runtime: "cognitive-worker", SemanticGeneration: 1, ArchitectureGeneration: 1})
|
||||
mux.HandleFunc("POST /v1/proposals/deterministic", deterministicProposal)
|
||||
mux.HandleFunc("POST /v1/advisories/deterministic", deterministicAdvisory)
|
||||
mux.HandleFunc("POST /v1/advisories/openai", openAIAdvisory(openAIProvider))
|
||||
log.Printf("cognitive-worker listening on %s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -27,16 +42,8 @@ func main() {
|
||||
}
|
||||
|
||||
func deterministicAdvisory(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 80*1024)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
var request cognitive.AdvisoryRequest
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(w, "invalid advisory request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
http.Error(w, "invalid advisory request", http.StatusBadRequest)
|
||||
request, ok := decodeAdvisoryRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := models.DeterministicAdvisoryUnavailable(request)
|
||||
@@ -48,6 +55,78 @@ func deterministicAdvisory(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func openAIAdvisory(provider advisoryProvider) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if provider == nil {
|
||||
http.Error(w, "provider unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
request, ok := decodeAdvisoryRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := provider.Advise(r.Context(), request)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid advisory request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeAdvisoryRequest(w http.ResponseWriter, r *http.Request) (cognitive.AdvisoryRequest, bool) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 80*1024)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
var request cognitive.AdvisoryRequest
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(w, "invalid advisory request", http.StatusBadRequest)
|
||||
return cognitive.AdvisoryRequest{}, false
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
http.Error(w, "invalid advisory request", http.StatusBadRequest)
|
||||
return cognitive.AdvisoryRequest{}, false
|
||||
}
|
||||
return request, true
|
||||
}
|
||||
|
||||
func loadOpenAIAdvisoryProvider(secretPath string) (advisoryProvider, error) {
|
||||
if os.Getenv("OPENAI_API_KEY") != "" {
|
||||
return nil, errors.New("direct OpenAI credential environment is forbidden; use OPENAI_API_KEY_FILE")
|
||||
}
|
||||
if secretPath == "" {
|
||||
return nil, nil
|
||||
}
|
||||
file, err := os.Open(secretPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open OpenAI credential file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(file, maxOpenAISecretBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read OpenAI credential file: %w", err)
|
||||
}
|
||||
defer clear(raw)
|
||||
if len(raw) > maxOpenAISecretBytes {
|
||||
return nil, errors.New("OpenAI credential file is oversized")
|
||||
}
|
||||
key := bytes.TrimSpace(raw)
|
||||
if len(key) == 0 {
|
||||
return nil, errors.New("OpenAI credential file is empty")
|
||||
}
|
||||
for _, character := range key {
|
||||
if character < 0x21 || character > 0x7e {
|
||||
return nil, errors.New("OpenAI credential file contains invalid characters")
|
||||
}
|
||||
}
|
||||
provider, err := models.NewOpenAIAdvisoryAdapter(key, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("OpenAI credential file is invalid")
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func deterministicProposal(w http.ResponseWriter, r *http.Request) {
|
||||
var request models.DeterministicRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"task_file": "dev/tasks/ARCH-000010-accept-openai-advisory-binding.json",
|
||||
"task_id": "ARCH-000010",
|
||||
"task_file": "dev/tasks/DEV-000051-openai-advisory-runtime-gate.json",
|
||||
"task_id": "DEV-000051",
|
||||
"task_revision": 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000050",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0007",
|
||||
"base_repo_revision": "00965f20e0c9a0e15a9444c72c4208f328834e08",
|
||||
"risk_tier": "E",
|
||||
"intent": "Implement the fixed OpenAI Responses API Creator advisory adapter with standard-library HTTP and adversarial fake-transport tests, without wiring runtime configuration, adding a real credential, enabling external egress or changing the provider-neutral contract.",
|
||||
"requirement_ids": ["ADR-0006", "ADR-0007", "INV-EPI-001"],
|
||||
"allowed_change_scope": {"modules": [], "paths": ["adapters/models/**", "docs/PROJECT_STATUS.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000050-openai-advisory-adapter.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "cmd/**", "database/**", "deploy/**", "security/**", "tests/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0},
|
||||
"contracts": {"consume": ["creator-advisory-v1", "ADR-0007"], "may_change": []},
|
||||
"semantic_owner": "none-provider-adapter",
|
||||
"canonical_state": [],
|
||||
"state_transitions": [],
|
||||
"side_effects_and_privileges": ["adapter can construct one fixed-origin HTTPS request when invoked by a later authorized runtime task", "this task invokes only local fake transports and grants no runtime/provider credential or production egress"],
|
||||
"persistence_and_migrations": [],
|
||||
"failure_retry_restart": ["one call and no retry", "deadline maps to TIMEOUT", "transport/status failure maps to PROVIDER_UNAVAILABLE", "refusal maps to CONTENT_REJECTED", "invalid or executable provider response maps to INVALID_PROVIDER_RESPONSE", "no request or response is persisted"],
|
||||
"assumptions": ["Mode A permits message-only payload and forbids all context excerpts", "fixed binding is openai-responses-gpt-5.4-mini-2026-03-17 revision 1", "docker-test remains prohibited"],
|
||||
"acceptance_scenarios": ["request uses only fixed origin/path/model/instructions/message/store=false/reasoning low and output limit", "authority/routing fields and context are absent from provider JSON", "redirects and ambient proxies are disabled", "unknown/tool output, citation annotations, wrong model and oversize/malformed responses fail closed", "refusal, timeout and unavailable mappings are explicit", "tests prove no automatic retry and no body logging", "no SDK or third-party dependency is added", "runtime remains deterministic fake only"],
|
||||
"required_tests": ["unit", "adversarial fake transport", "go test ./...", "go vet ./...", "architecture preflight", "exact diff scope", "CI"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000051",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0007",
|
||||
"base_repo_revision": "0ee9e4a8a11e4bf216007d288b07d74b4049cac5",
|
||||
"risk_tier": "E",
|
||||
"intent": "Add a fail-closed optional OpenAI advisory endpoint to cognitive-worker that can only initialize from a bounded secret file and remains unavailable by default, with no production compose, credential, egress or active Creator Web binding change.",
|
||||
"requirement_ids": ["ADR-0006", "ADR-0007", "DEV-000050", "INV-EPI-001"],
|
||||
"allowed_change_scope": {"modules": [], "paths": ["cmd/cognitive-worker/**", "docs/PROJECT_STATUS.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000051-openai-advisory-runtime-gate.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "contracts/**", "modules/**", "adapters/**", "cmd/core-runtime/**", "cmd/creator-web/**", "cmd/commit-runtime/**", "cmd/continuity-runtime/**", "database/**", "deploy/**", "security/**", "tests/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0},
|
||||
"contracts": {"consume": ["creator-advisory-v1", "OpenAIAdvisoryAdapter", "ADR-0007"], "may_change": []},
|
||||
"semantic_owner": "none-cognitive-runtime-gate",
|
||||
"canonical_state": [],
|
||||
"state_transitions": [],
|
||||
"side_effects_and_privileges": ["cognitive-worker code may read one explicitly configured OpenAI secret file and invoke the fixed adapter when a later deployment mounts it", "current production compose and Creator Web binding remain unchanged, so no provider call is activated"],
|
||||
"persistence_and_migrations": [],
|
||||
"failure_retry_restart": ["missing configuration keeps endpoint unavailable", "configured unreadable/empty/oversized secret fails startup", "invalid request fails before provider", "provider failures remain typed and are not retried", "restart loses no advisory state"],
|
||||
"assumptions": ["the OpenAI secret file is absent from current production", "Creator Web continues to call deterministic endpoint", "docker-test remains prohibited"],
|
||||
"acceptance_scenarios": ["default startup has no provider and deterministic fake remains usable", "OpenAI endpoint returns 503 before parsing/requesting when provider is disabled", "only a bounded secret file can initialize the adapter", "direct environment credential is unsupported", "strict request decoding and provider-neutral response encoding are preserved", "health/log responses expose no secret", "no deploy or Creator Web path changes activate egress"],
|
||||
"required_tests": ["unit", "handler adversarial", "secret-file boundary", "go test ./...", "go vet ./...", "architecture preflight", "exact diff scope", "CI"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"]
|
||||
}
|
||||
@@ -30,4 +30,8 @@ ADR-0006 and DEV-000046 establish the provider-neutral, non-canonical Creator ad
|
||||
|
||||
DEV-000049 adds and executes a repeatable production audit for that boundary. It proves that an authenticated 16 KiB message reaches only the deterministic unavailable binding, unauthenticated and browser-supplied authority fields fail closed, the request sentinel is absent from cognitive-worker logs, and the cognitive worker has no database, Commit/Executor, Creator or provider credential environment/mount.
|
||||
|
||||
ADR-0007 accepts OpenAI Responses API with immutable snapshot `gpt-5.4-mini-2026-03-17` and Mode A per-request retention disclosure as the first real provider binding class. DEV-000050 implements its standard-library adapter and adversarial fake-transport tests: fixed origin/payload, message-only egress, no tools or provider memory, redirect/proxy denial, no retry, strict output mapping and fail-closed timeout/refusal/unavailable/invalid states. The adapter is not wired into the runtime; production remains on `deterministic-fake` with no provider credential or egress.
|
||||
|
||||
DEV-000051 adds an optional cognitive-worker runtime gate for that adapter. It is disabled when `OPENAI_API_KEY_FILE` is absent, rejects direct environment credentials, bounds and validates the secret file, and returns 503 before parsing an OpenAI request while disabled. Production configuration and Creator Web routing are unchanged, so the gate is not active and no provider egress occurs.
|
||||
|
||||
These are explicit scope decisions, not hidden incomplete steps in DEV-000043. No production secret is stored in the repository.
|
||||
|
||||
Reference in New Issue
Block a user