feat: gate OpenAI advisory runtime
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user