diff --git a/adapters/models/advisory.go b/adapters/models/advisory.go index 5c6b3ca..57cffb4 100644 --- a/adapters/models/advisory.go +++ b/adapters/models/advisory.go @@ -97,7 +97,11 @@ func DeterministicAdvisoryUnavailable(request cognitive.AdvisoryRequest) (cognit func validFinishReason(reason cognitive.FinishReason) bool { switch reason { - case cognitive.FinishCompleted, cognitive.FinishLengthLimit, cognitive.FinishContentRejected, cognitive.FinishProviderUnavailable, cognitive.FinishTimeout, cognitive.FinishInvalidProviderResponse: + case cognitive.FinishCompleted, cognitive.FinishLengthLimit, cognitive.FinishContentRejected, + cognitive.FinishProviderUnavailable, cognitive.FinishModelUnavailable, cognitive.FinishRateLimited, + cognitive.FinishAuthenticationFailed, cognitive.FinishBillingOrBudgetBlocked, + cognitive.FinishContextTooLarge, cognitive.FinishUnsupportedCapability, cognitive.FinishTimeout, + cognitive.FinishInvalidProviderResponse, cognitive.FinishInvalidStructuredOutput, cognitive.FinishDegraded: return true default: return false diff --git a/adapters/models/local_provider.go b/adapters/models/local_provider.go new file mode 100644 index 0000000..b193140 --- /dev/null +++ b/adapters/models/local_provider.go @@ -0,0 +1,31 @@ +package models + +import ( + "context" + + "git.cin.su/m/Assistant/contracts/capability" + "git.cin.su/m/Assistant/contracts/cognitive" +) + +type ProviderProbe struct { + Health capability.HealthState + Version string + Failure cognitive.FinishReason +} + +type DiscoveredModel struct { + ProviderModelID capability.ProviderModelID + Fingerprint capability.ModelFingerprint + MaxContextBytes int64 + Modalities []capability.ModelModality + Capabilities []capability.ModelCapability + SizeBytes int64 + Runtime string + Quantization string +} + +type LocalProvider interface { + Probe(context.Context) ProviderProbe + Discover(context.Context) ([]DiscoveredModel, ProviderProbe) + Advise(context.Context, cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) +} diff --git a/adapters/models/ollama.go b/adapters/models/ollama.go new file mode 100644 index 0000000..6dedf57 --- /dev/null +++ b/adapters/models/ollama.go @@ -0,0 +1,169 @@ +package models + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "time" + + "git.cin.su/m/Assistant/contracts/capability" + "git.cin.su/m/Assistant/contracts/cognitive" +) + +type OllamaAdapter struct { + base *url.URL + client *http.Client +} + +func NewOllamaAdapter(raw string, transport http.RoundTripper) (*OllamaAdapter, error) { + base, err := url.Parse(raw) + if err != nil || base.Scheme != "http" || base.Host == "" || base.Path != "" || base.User != nil || base.RawQuery != "" || base.Fragment != "" { + return nil, errors.New("invalid Ollama endpoint") + } + if transport == nil { + transport = http.DefaultTransport + } + return &OllamaAdapter{base: base, client: &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil +} + +type ollamaTags struct { + Models []struct { + Name string `json:"name"` + Model string `json:"model"` + Size int64 `json:"size"` + Digest string `json:"digest"` + Details struct { + Format string `json:"format"` + Family string `json:"family"` + ParameterSize string `json:"parameter_size"` + Quantization string `json:"quantization_level"` + ContextLength int64 `json:"context_length"` + } `json:"details"` + Capabilities []string `json:"capabilities"` + } `json:"models"` +} +type ollamaVersion struct { + Version string `json:"version"` +} + +func (a *OllamaAdapter) Probe(ctx context.Context) ProviderProbe { + var version ollamaVersion + status, err := a.request(ctx, http.MethodGet, "/api/version", nil, &version) + if err != nil { + return probeFailure(err) + } + if status != http.StatusOK || version.Version == "" { + return ProviderProbe{Health: capability.HealthUnavailable, Failure: mapCompatibleStatus(status)} + } + return ProviderProbe{Health: capability.HealthAvailable, Version: version.Version} +} + +func (a *OllamaAdapter) Discover(ctx context.Context) ([]DiscoveredModel, ProviderProbe) { + probe := a.Probe(ctx) + if probe.Health != capability.HealthAvailable { + return nil, probe + } + var tags ollamaTags + status, err := a.request(ctx, http.MethodGet, "/api/tags", nil, &tags) + if err != nil { + return nil, probeFailure(err) + } + if status != http.StatusOK { + return nil, ProviderProbe{Health: capability.HealthUnavailable, Failure: mapCompatibleStatus(status)} + } + result := make([]DiscoveredModel, 0, len(tags.Models)) + for _, item := range tags.Models { + if item.Name == "" || item.Digest == "" { + continue + } + capabilities := []capability.ModelCapability{capability.CapabilityUnknown} + for _, declared := range item.Capabilities { + if declared == "completion" { + capabilities = []capability.ModelCapability{capability.CapabilityAdvisory} + break + } + } + result = append(result, DiscoveredModel{ProviderModelID: capability.ProviderModelID(item.Name), Fingerprint: capability.ModelFingerprint(item.Digest), MaxContextBytes: item.Details.ContextLength * 4, Modalities: []capability.ModelModality{capability.ModalityText}, Capabilities: capabilities, SizeBytes: item.Size, Runtime: "ollama/" + probe.Version, Quantization: item.Details.Quantization}) + } + return result, probe +} + +type ollamaChatRequest struct { + Model string `json:"model"` + Messages []compatibleMessage `json:"messages"` + Stream bool `json:"stream"` + Think bool `json:"think"` + Options struct { + Temperature int `json:"temperature"` + NumCtx int64 `json:"num_ctx,omitempty"` + } `json:"options"` +} +type ollamaChatResponse struct { + Message compatibleMessage `json:"message"` + Done bool `json:"done"` + DoneReason string `json:"done_reason"` + PromptEvalCount int64 `json:"prompt_eval_count"` + EvalCount int64 `json:"eval_count"` +} + +func (a *OllamaAdapter) Advise(ctx context.Context, request cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) { + if err := ValidateAdvisoryRequest(request); err != nil || request.ProviderModelID == "" || len(request.Context) != 0 { + return cognitive.AdvisoryResponse{}, ErrInvalidAdvisory + } + payload := ollamaChatRequest{Model: string(request.ProviderModelID), Messages: []compatibleMessage{{Role: "system", Content: "Provide non-canonical advisory text only. Never claim that an action occurred."}, {Role: "user", Content: request.Message}}, Stream: false, Think: false} + var result ollamaChatResponse + status, err := a.request(ctx, http.MethodPost, "/api/chat", payload, &result) + if err != nil { + return advisoryFailure(request, probeFailure(err).Failure), nil + } + if status != http.StatusOK { + return advisoryFailure(request, mapCompatibleStatus(status)), nil + } + if !result.Done || result.Message.Content == "" { + return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil + } + response := cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: request.ProviderBindingRevision, AdvisoryText: result.Message.Content, FinishReason: cognitive.FinishCompleted, Usage: &cognitive.ProviderUsage{InputUnits: result.PromptEvalCount, OutputUnits: result.EvalCount}} + if ValidateAdvisoryResponse(request, response) != nil { + return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil + } + return response, nil +} + +func (a *OllamaAdapter) request(ctx context.Context, method, path string, input, output any) (int, error) { + var body io.Reader + if input != nil { + encoded, err := json.Marshal(input) + if err != nil { + return 0, err + } + body = bytes.NewReader(encoded) + } + target := *a.base + target.Path = path + request, err := http.NewRequestWithContext(ctx, method, target.String(), body) + if err != nil { + return 0, err + } + request.Header.Set("Accept", "application/json") + if input != nil { + request.Header.Set("Content-Type", "application/json") + } + response, err := a.client.Do(request) + if err != nil { + return 0, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) + return response.StatusCode, nil + } + decoder := json.NewDecoder(io.LimitReader(response.Body, 2<<20)) + if err := decoder.Decode(output); err != nil { + return response.StatusCode, err + } + return response.StatusCode, nil +} diff --git a/adapters/models/openai_advisory.go b/adapters/models/openai_advisory.go index 6ca1ed9..2579b06 100644 --- a/adapters/models/openai_advisory.go +++ b/adapters/models/openai_advisory.go @@ -13,19 +13,16 @@ import ( "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." + 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 { @@ -124,8 +121,8 @@ func (a *OpenAIAdvisoryAdapter) Advise(ctx context.Context, request cognitive.Ad 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) + 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, @@ -159,7 +156,7 @@ func (a *OpenAIAdvisoryAdapter) Advise(ctx context.Context, request cognitive.Ad } defer httpResponse.Body.Close() if httpResponse.StatusCode != http.StatusOK { - return advisoryFailure(request, cognitive.FinishProviderUnavailable), nil + 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 @@ -204,6 +201,25 @@ func (a *OpenAIAdvisoryAdapter) Advise(ctx context.Context, request cognitive.Ad 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 diff --git a/adapters/models/openai_advisory_test.go b/adapters/models/openai_advisory_test.go index 0cf5543..ea6dcf5 100644 --- a/adapters/models/openai_advisory_test.go +++ b/adapters/models/openai_advisory_test.go @@ -10,6 +10,7 @@ import ( "testing" "git.cin.su/m/Assistant/contracts/authority" + "git.cin.su/m/Assistant/contracts/capability" "git.cin.su/m/Assistant/contracts/cognitive" "git.cin.su/m/Assistant/contracts/identity" ) @@ -28,8 +29,11 @@ func validOpenAIAdvisoryRequest() cognitive.AdvisoryRequest { Message: "What should I verify?", Locale: "en", MaxOutputBytes: MaxAdvisoryResponseBytes, - ProviderBindingID: OpenAIAdvisoryBindingID, - ProviderBindingRevision: OpenAIAdvisoryBindingRevision, + ProviderBindingID: "binding-under-test", + ProviderBindingRevision: 7, + ProviderInstanceID: capability.ProviderInstanceID("provider-under-test"), + ModelDescriptorID: capability.ModelDescriptorID("model-under-test"), + ProviderModelID: capability.ProviderModelID(openAIModel), } } @@ -41,6 +45,12 @@ func providerHTTPResponse(status int, body string) *http.Response { } } +func TestOpenAIStatusTaxonomyPreservesRateLimit(t *testing.T) { + if got := mapOpenAIStatus(http.StatusTooManyRequests); got != cognitive.FinishRateLimited { + t.Fatalf("status 429 mapped to %s", got) + } +} + func validOpenAIResponse(text string) string { body, _ := json.Marshal(map[string]any{ "status": "completed", diff --git a/adapters/models/openai_compatible_local.go b/adapters/models/openai_compatible_local.go new file mode 100644 index 0000000..d75c56a --- /dev/null +++ b/adapters/models/openai_compatible_local.go @@ -0,0 +1,194 @@ +package models + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "git.cin.su/m/Assistant/contracts/capability" + "git.cin.su/m/Assistant/contracts/cognitive" +) + +type OpenAICompatibleLocalAdapter struct { + base *url.URL + client *http.Client +} + +func NewOpenAICompatibleLocalAdapter(raw string, transport http.RoundTripper) (*OpenAICompatibleLocalAdapter, error) { + base, err := url.Parse(raw) + if err != nil || base.Scheme != "http" || base.Host == "" || strings.TrimSuffix(base.Path, "/") != "/v1" || base.RawQuery != "" || base.Fragment != "" || base.User != nil { + return nil, errors.New("invalid local compatible endpoint") + } + if transport == nil { + transport = http.DefaultTransport + } + return &OpenAICompatibleLocalAdapter{base: base, client: &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil +} + +type compatibleModelsResponse struct { + Data []struct { + ID string `json:"id"` + Created int64 `json:"created"` + OwnedBy string `json:"owned_by"` + Meta struct { + Context int64 `json:"n_ctx"` + TrainingContext int64 `json:"n_ctx_train"` + Parameters int64 `json:"n_params"` + Size int64 `json:"size"` + FileType string `json:"ftype"` + } `json:"meta"` + } `json:"data"` +} + +func (a *OpenAICompatibleLocalAdapter) Probe(ctx context.Context) ProviderProbe { + _, probe := a.Discover(ctx) + return probe +} + +func (a *OpenAICompatibleLocalAdapter) Discover(ctx context.Context) ([]DiscoveredModel, ProviderProbe) { + var response compatibleModelsResponse + status, err := a.requestJSON(ctx, http.MethodGet, "/models", nil, &response) + if err != nil { + return nil, probeFailure(err) + } + if status != http.StatusOK { + return nil, ProviderProbe{Health: capability.HealthUnavailable, Failure: mapCompatibleStatus(status)} + } + models := make([]DiscoveredModel, 0, len(response.Data)) + for _, item := range response.Data { + if item.ID == "" { + continue + } + canonical, _ := json.Marshal(item) + digest := sha256.Sum256(canonical) + contextBytes := item.Meta.Context * 4 + models = append(models, DiscoveredModel{ProviderModelID: capability.ProviderModelID(item.ID), Fingerprint: capability.ModelFingerprint(hex.EncodeToString(digest[:])), MaxContextBytes: contextBytes, Modalities: []capability.ModelModality{capability.ModalityText}, Capabilities: []capability.ModelCapability{capability.CapabilityUnknown}, SizeBytes: item.Meta.Size, Runtime: item.OwnedBy, Quantization: item.Meta.FileType}) + } + return models, ProviderProbe{Health: capability.HealthAvailable} +} + +type compatibleChatRequest struct { + Model string `json:"model"` + Messages []compatibleMessage `json:"messages"` + Temperature int `json:"temperature"` + Stream bool `json:"stream"` + MaxTokens int `json:"max_tokens"` +} +type compatibleMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} +type compatibleChatResponse struct { + Choices []struct { + FinishReason string `json:"finish_reason"` + Message compatibleMessage `json:"message"` + } `json:"choices"` + Usage struct { + Prompt int64 `json:"prompt_tokens"` + Completion int64 `json:"completion_tokens"` + } `json:"usage"` +} + +func (a *OpenAICompatibleLocalAdapter) Advise(ctx context.Context, request cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) { + if err := ValidateAdvisoryRequest(request); err != nil || request.ProviderModelID == "" || len(request.Context) != 0 { + return cognitive.AdvisoryResponse{}, ErrInvalidAdvisory + } + payload := compatibleChatRequest{Model: string(request.ProviderModelID), Messages: []compatibleMessage{{Role: "system", Content: "Provide non-canonical advisory text only. Never claim that an action occurred."}, {Role: "user", Content: request.Message}}, Temperature: 0, Stream: false, MaxTokens: request.MaxOutputBytes / 4} + var result compatibleChatResponse + status, err := a.requestJSON(ctx, http.MethodPost, "/chat/completions", payload, &result) + if err != nil { + return advisoryFailure(request, probeFailure(err).Failure), nil + } + if status != http.StatusOK { + return advisoryFailure(request, mapCompatibleStatus(status)), nil + } + if len(result.Choices) != 1 || result.Choices[0].Message.Content == "" { + return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil + } + finish := cognitive.FinishCompleted + if result.Choices[0].FinishReason == "length" { + finish = cognitive.FinishLengthLimit + } + if finish != cognitive.FinishCompleted { + return advisoryFailure(request, finish), nil + } + response := cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: request.ProviderBindingRevision, AdvisoryText: result.Choices[0].Message.Content, FinishReason: finish, Usage: &cognitive.ProviderUsage{InputUnits: result.Usage.Prompt, OutputUnits: result.Usage.Completion}} + if ValidateAdvisoryResponse(request, response) != nil { + return advisoryFailure(request, cognitive.FinishInvalidProviderResponse), nil + } + return response, nil +} + +func (a *OpenAICompatibleLocalAdapter) requestJSON(ctx context.Context, method, path string, input, output any) (int, error) { + var body io.Reader + if input != nil { + encoded, err := json.Marshal(input) + if err != nil { + return 0, err + } + body = bytes.NewReader(encoded) + } + target := *a.base + target.Path = strings.TrimSuffix(a.base.Path, "/") + path + request, err := http.NewRequestWithContext(ctx, method, target.String(), body) + if err != nil { + return 0, err + } + request.Header.Set("Accept", "application/json") + if input != nil { + request.Header.Set("Content-Type", "application/json") + } + response, err := a.client.Do(request) + if err != nil { + return 0, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) + return response.StatusCode, nil + } + decoder := json.NewDecoder(io.LimitReader(response.Body, 2<<20)) + if err := decoder.Decode(output); err != nil { + return response.StatusCode, fmt.Errorf("decode provider response: %w", err) + } + return response.StatusCode, nil +} + +func probeFailure(err error) ProviderProbe { + finish := cognitive.FinishProviderUnavailable + if errors.Is(err, context.DeadlineExceeded) { + finish = cognitive.FinishTimeout + } + var network net.Error + if errors.As(err, &network) && network.Timeout() { + finish = cognitive.FinishTimeout + } + return ProviderProbe{Health: capability.HealthUnavailable, Failure: finish} +} + +func mapCompatibleStatus(status int) cognitive.FinishReason { + switch status { + case 401, 403: + return cognitive.FinishAuthenticationFailed + case 404: + return cognitive.FinishModelUnavailable + case 408, 504: + return cognitive.FinishTimeout + case 413: + return cognitive.FinishContextTooLarge + case 429: + return cognitive.FinishRateLimited + default: + return cognitive.FinishProviderUnavailable + } +} diff --git a/adapters/postgres/capability_repository.go b/adapters/postgres/capability_repository.go index a5de490..92f5321 100644 --- a/adapters/postgres/capability_repository.go +++ b/adapters/postgres/capability_repository.go @@ -7,6 +7,7 @@ import ( "git.cin.su/m/Assistant/contracts/capability" "git.cin.su/m/Assistant/contracts/identity" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -128,3 +129,135 @@ func (r *CapabilityRepository) LoadActiveBinding(ctx context.Context, capability } return binding, nil } + +func (r *CapabilityRepository) SaveProviderInstance(ctx context.Context, value capability.ProviderInstance) error { + _, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_provider_instances + (provider_instance_id,revision,provider_kind,endpoint_ref,credential_ref,enabled,qualification,health,observed_at_unix_millis) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT DO NOTHING`, value.ID, value.Revision, value.Kind, + value.EndpointRef, value.CredentialRef, value.Enabled, value.Qualification, value.Health, value.ObservedAtUnixMillis) + return err +} + +func (r *CapabilityRepository) SaveModelDescriptor(ctx context.Context, value capability.ModelDescriptor) error { + _, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_model_descriptors + (model_descriptor_id,revision,provider_instance_id,provider_model_id,fingerprint,max_context_bytes,modalities,capabilities,qualification,health,observed_at_unix_millis) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT DO NOTHING`, value.ID, value.Revision, + value.ProviderInstanceID, value.ProviderModelID, value.Fingerprint, value.MaxContextBytes, value.Modalities, + value.Capabilities, value.Qualification, value.Health, value.ObservedAtUnixMillis) + return err +} + +func (r *CapabilityRepository) SaveCognitiveBinding(ctx context.Context, value capability.CognitiveBinding) error { + _, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_bindings + (binding_id,revision,capability_id,owner_principal_id,provider_instance_id,provider_revision,model_descriptor_id,model_revision,data_policy,qualification,activation,health,limit_policy_ref) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT DO NOTHING`, value.ID, value.Revision, + value.CapabilityID, value.OwnerPrincipalID, value.ProviderInstanceID, value.ProviderRevision, value.ModelDescriptorID, + value.ModelRevision, value.DataPolicy, value.Qualification, value.Activation, value.Health, value.LimitPolicyRef) + return err +} + +func (r *CapabilityRepository) LoadCognitiveCandidates(ctx context.Context, capabilityID capability.CapabilityID, owner identity.PrincipalID) ([]capability.ResolvedCognitiveBinding, error) { + rows, err := r.pool.Query(ctx, `WITH latest_bindings AS ( + SELECT DISTINCT ON (binding_id) * FROM capability.cognitive_bindings ORDER BY binding_id,revision DESC + ) SELECT + b.binding_id,b.revision,b.capability_id,b.owner_principal_id,b.provider_instance_id,b.provider_revision,b.model_descriptor_id,b.model_revision,b.data_policy,b.qualification,b.activation,b.health,b.limit_policy_ref, + p.provider_instance_id,p.revision,p.provider_kind,p.endpoint_ref,COALESCE(p.credential_ref,''),p.enabled,p.qualification,COALESCE(ph.health,p.health),GREATEST(p.observed_at_unix_millis,COALESCE(ph.observed_at_unix_millis,0)), + m.model_descriptor_id,m.revision,m.provider_instance_id,m.provider_model_id,m.fingerprint,m.max_context_bytes,m.modalities,m.capabilities,m.qualification,COALESCE(ma.health,m.health),GREATEST(m.observed_at_unix_millis,COALESCE(ma.observed_at_unix_millis,0)) + FROM latest_bindings b + JOIN capability.cognitive_provider_instances p ON p.provider_instance_id=b.provider_instance_id AND p.revision=b.provider_revision + JOIN capability.cognitive_model_descriptors m ON m.model_descriptor_id=b.model_descriptor_id AND m.revision=b.model_revision + LEFT JOIN capability.cognitive_provider_health ph ON ph.provider_instance_id=p.provider_instance_id + LEFT JOIN capability.cognitive_model_availability ma ON ma.provider_instance_id=p.provider_instance_id AND ma.model_descriptor_id=m.model_descriptor_id + WHERE b.capability_id=$1 AND b.owner_principal_id=$2 ORDER BY b.binding_id`, capabilityID, owner) + if err != nil { + return nil, err + } + defer rows.Close() + var result []capability.ResolvedCognitiveBinding + for rows.Next() { + var value capability.ResolvedCognitiveBinding + err = rows.Scan(&value.Binding.ID, &value.Binding.Revision, &value.Binding.CapabilityID, &value.Binding.OwnerPrincipalID, &value.Binding.ProviderInstanceID, &value.Binding.ProviderRevision, &value.Binding.ModelDescriptorID, &value.Binding.ModelRevision, &value.Binding.DataPolicy, &value.Binding.Qualification, &value.Binding.Activation, &value.Binding.Health, &value.Binding.LimitPolicyRef, + &value.Provider.ID, &value.Provider.Revision, &value.Provider.Kind, &value.Provider.EndpointRef, &value.Provider.CredentialRef, &value.Provider.Enabled, &value.Provider.Qualification, &value.Provider.Health, &value.Provider.ObservedAtUnixMillis, + &value.Model.ID, &value.Model.Revision, &value.Model.ProviderInstanceID, &value.Model.ProviderModelID, &value.Model.Fingerprint, &value.Model.MaxContextBytes, &value.Model.Modalities, &value.Model.Capabilities, &value.Model.Qualification, &value.Model.Health, &value.Model.ObservedAtUnixMillis) + if err != nil { + return nil, err + } + if err = r.loadCognitiveEvidence(ctx, &value); err != nil { + return nil, err + } + result = append(result, value) + } + return result, rows.Err() +} + +func (r *CapabilityRepository) loadCognitiveEvidence(ctx context.Context, value *capability.ResolvedCognitiveBinding) error { + var evaluation capability.EvaluatedCapability + err := r.pool.QueryRow(ctx, `SELECT evaluation_id,binding_id,binding_revision,capability_id,result,quality_score_millis,critical_failure_millis,benchmark_version,artifact_id,observed_at_unix_millis FROM capability.cognitive_evaluated_capabilities WHERE binding_id=$1 AND binding_revision=$2 ORDER BY observed_at_unix_millis DESC LIMIT 1`, value.Binding.ID, value.Binding.Revision).Scan(&evaluation.ID, &evaluation.BindingID, &evaluation.BindingRevision, &evaluation.CapabilityID, &evaluation.Result, &evaluation.QualityScoreMillis, &evaluation.CriticalFailureMillis, &evaluation.BenchmarkVersion, &evaluation.ArtifactID, &evaluation.ObservedAtUnixMillis) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + if err == nil { + value.Evaluation = &evaluation + } + var performance capability.PerformanceEvidence + err = r.pool.QueryRow(ctx, `SELECT evidence_id,binding_id,binding_revision,model_descriptor_id,model_revision,hardware_profile,runtime,quantization,context_units,thinking_enabled,generation_tps_millis,prompt_tps_millis,ttft_micros,latency_micros,memory_bytes,reliability_millis,benchmark_version,artifact_id,observed_at_unix_millis FROM capability.cognitive_performance_evidence WHERE binding_id=$1 AND binding_revision=$2 ORDER BY observed_at_unix_millis DESC LIMIT 1`, value.Binding.ID, value.Binding.Revision).Scan(&performance.ID, &performance.BindingID, &performance.BindingRevision, &performance.ModelDescriptorID, &performance.ModelRevision, &performance.HardwareProfile, &performance.Runtime, &performance.Quantization, &performance.ContextUnits, &performance.ThinkingEnabled, &performance.GenerationTPSMillis, &performance.PromptTPSMillis, &performance.TTFTMicros, &performance.LatencyMicros, &performance.MemoryBytes, &performance.ReliabilityMillis, &performance.BenchmarkVersion, &performance.ArtifactID, &performance.ObservedAtUnixMillis) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + if err == nil { + value.Performance = &performance + } + return nil +} + +func (r *CapabilityRepository) SaveCognitiveUsageObservation(ctx context.Context, value capability.CognitiveUsageObservation) error { + _, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_usage_observations + (observation_id,request_id,binding_id,binding_revision,provider_instance_id,model_descriptor_id,observed_at_unix_millis,latency_millis,input_units,output_units,estimated_cost_micros,actual_cost_micros,result_class,failure_type) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT DO NOTHING`, value.ObservationID, value.RequestID, + value.BindingID, value.BindingRevision, value.ProviderInstanceID, value.ModelDescriptorID, value.ObservedAtUnixMillis, + value.LatencyMillis, value.InputUnits, value.OutputUnits, value.EstimatedCostMicros, value.ActualCostMicros, value.ResultClass, value.FailureType) + return err +} + +func (r *CapabilityRepository) SaveCognitiveDiscovery(ctx context.Context, value capability.CognitiveDiscoveryRegistration) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_provider_instances (provider_instance_id,revision,provider_kind,endpoint_ref,credential_ref,enabled,qualification,health,observed_at_unix_millis) VALUES ($1,$2,$3,$4,NULLIF($5,''),$6,$7,$8,$9) ON CONFLICT DO NOTHING`, value.Provider.ID, value.Provider.Revision, value.Provider.Kind, value.Provider.EndpointRef, value.Provider.CredentialRef, value.Provider.Enabled, value.Provider.Qualification, value.Provider.Health, value.Provider.ObservedAtUnixMillis) + if err != nil { + return err + } + _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_provider_health (provider_instance_id,health,observed_at_unix_millis,failure_class) VALUES ($1,$2,$3,$4) ON CONFLICT (provider_instance_id) DO UPDATE SET health=EXCLUDED.health,observed_at_unix_millis=EXCLUDED.observed_at_unix_millis,failure_class=EXCLUDED.failure_class WHERE capability.cognitive_provider_health.observed_at_unix_millis <= EXCLUDED.observed_at_unix_millis`, value.ProviderHealth.ProviderInstanceID, value.ProviderHealth.Health, value.ProviderHealth.ObservedAtUnixMillis, value.ProviderHealth.FailureClass) + if err != nil { + return err + } + for index, model := range value.Models { + _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_model_descriptors (model_descriptor_id,revision,provider_instance_id,provider_model_id,fingerprint,max_context_bytes,modalities,capabilities,qualification,health,observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT DO NOTHING`, model.ID, model.Revision, model.ProviderInstanceID, model.ProviderModelID, model.Fingerprint, model.MaxContextBytes, model.Modalities, model.Capabilities, model.Qualification, model.Health, model.ObservedAtUnixMillis) + if err != nil { + return err + } + availability := value.ModelAvailability[index] + _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_model_availability (provider_instance_id,model_descriptor_id,installed,loaded,invokable,health,observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (provider_instance_id,model_descriptor_id) DO UPDATE SET installed=EXCLUDED.installed,loaded=EXCLUDED.loaded,invokable=EXCLUDED.invokable,health=EXCLUDED.health,observed_at_unix_millis=EXCLUDED.observed_at_unix_millis WHERE capability.cognitive_model_availability.observed_at_unix_millis <= EXCLUDED.observed_at_unix_millis`, availability.ProviderInstanceID, availability.ModelDescriptorID, availability.Installed, availability.Loaded, availability.Invokable, availability.Health, availability.ObservedAtUnixMillis) + if err != nil { + return err + } + binding := value.Bindings[index] + _, err = tx.Exec(ctx, `INSERT INTO capability.cognitive_bindings (binding_id,revision,capability_id,owner_principal_id,provider_instance_id,provider_revision,model_descriptor_id,model_revision,data_policy,qualification,activation,health,limit_policy_ref) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT DO NOTHING`, binding.ID, binding.Revision, binding.CapabilityID, binding.OwnerPrincipalID, binding.ProviderInstanceID, binding.ProviderRevision, binding.ModelDescriptorID, binding.ModelRevision, binding.DataPolicy, binding.Qualification, binding.Activation, binding.Health, binding.LimitPolicyRef) + if err != nil { + return err + } + } + return tx.Commit(ctx) +} + +func (r *CapabilityRepository) SaveEvaluatedCapability(ctx context.Context, value capability.EvaluatedCapability) error { + _, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_evaluated_capabilities (evaluation_id,binding_id,binding_revision,capability_id,result,quality_score_millis,critical_failure_millis,benchmark_version,artifact_id,observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT DO NOTHING`, value.ID, value.BindingID, value.BindingRevision, value.CapabilityID, value.Result, value.QualityScoreMillis, value.CriticalFailureMillis, value.BenchmarkVersion, value.ArtifactID, value.ObservedAtUnixMillis) + return err +} + +func (r *CapabilityRepository) SavePerformanceEvidence(ctx context.Context, value capability.PerformanceEvidence) error { + _, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_performance_evidence (evidence_id,binding_id,binding_revision,model_descriptor_id,model_revision,hardware_profile,runtime,quantization,context_units,thinking_enabled,generation_tps_millis,prompt_tps_millis,ttft_micros,latency_micros,memory_bytes,reliability_millis,benchmark_version,artifact_id,observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) ON CONFLICT DO NOTHING`, value.ID, value.BindingID, value.BindingRevision, value.ModelDescriptorID, value.ModelRevision, value.HardwareProfile, value.Runtime, value.Quantization, value.ContextUnits, value.ThinkingEnabled, value.GenerationTPSMillis, value.PromptTPSMillis, value.TTFTMicros, value.LatencyMicros, value.MemoryBytes, value.ReliabilityMillis, value.BenchmarkVersion, value.ArtifactID, value.ObservedAtUnixMillis) + return err +} diff --git a/architecture/adrs/ADR-0008-cognitive-evaluation-ownership.md b/architecture/adrs/ADR-0008-cognitive-evaluation-ownership.md new file mode 100644 index 0000000..af83318 --- /dev/null +++ b/architecture/adrs/ADR-0008-cognitive-evaluation-ownership.md @@ -0,0 +1,60 @@ +# ADR-0008 — Cognitive evaluation ownership and evidence boundary + +Status: Accepted +Date: 2026-08-17 +Accepted under: DEV-000056 user-directed architecture amendment + +## Problem + +Provider/model discovery, binding qualification and deterministic routing need benchmark evidence without making Cognitive Workers canonical owners, putting provider DTOs into Core, or redefining general observations and artifacts as Capability state. + +## Evidence + +- The Architecture Registry assigns `Capability`, `CapabilityBinding` and `CapabilityHealth` to the Capability module. +- The Core Semantic Model assigns artifact metadata/lineage to Artifact and general observations/claims/provenance to Epistemics. +- Routing needs an owner-local, revision-bound evaluation decision and cannot parse arbitrary benchmark files on each request. + +## Alternatives considered + +1. Store provider benchmark JSON directly in routing code — rejected because it is untyped, non-canonical and not revision-bound. +2. Make Cognitive Worker own evaluation state — rejected because Cognitive Workers have no canonical ownership. +3. Add a new Model Registry service/owner — rejected as unnecessary topology and ownership expansion. +4. Let Capability own a normalized qualification assessment that references an immutable Artifact — accepted. + +## Decision + +1. Capability owns provider/model/binding registry revisions, lifecycle, health, routing eligibility, `EvaluatedCapability`, normalized `PerformanceEvidence` used for routing, and cognitive usage observations. +2. An imported performance record must reference an immutable `ArtifactID` and record benchmark version, binding/model revisions, observation time and which measurements were actually observed. Missing measurements remain explicitly unobserved and are never treated as zero. +3. Artifact owns benchmark payload metadata and lineage. The Capability record is a typed owner-local assessment/reference, not a replacement for the source artifact. +4. Epistemics remains owner of generalized observations, claims, beliefs and provenance. This ADR does not move those semantics into Capability. +5. Provider discovery may append `DISCOVERED` and `INACTIVE` revisions and current operational health. It cannot validate, activate, promote, download or delete models. +6. Deterministic routing selects binding revisions, not model names. Only explicitly eligible `ACTIVE` or allowed `CANARY` bindings participate. +7. Provider-specific discovery and invocation DTOs remain in adapters. Core receives only typed provider-neutral registration records. + +## Rationale + +This keeps canonical routing eligibility with the existing Capability owner, preserves immutable external evidence through Artifact references, and avoids both cross-owner database joins and a new generic Model Manager. + +## Consequences + +- No new canonical owner, database role, privileged capability or runtime service is introduced. +- Capability may normalize only measurements needed for qualification/routing and must retain the source `ArtifactID`. +- Import is an explicit registration action; Router never reads arbitrary files during a request. +- A new model digest or provider/model revision receives no inherited qualification or activation. + +## Validity conditions + +- Artifact references resolve to immutable/versioned content outside request-time routing. +- Missing evidence is represented explicitly. +- Discovery and health changes remain independent from qualification and activation. +- Model output never receives Authority or a canonical mutation path. + +## Migration and rollback + +New evaluation records are additive. Rollback disables their use in routing; bindings remain governed by explicit lifecycle and health. No Authority or Commit state requires repair. + +## Revisit triggers + +- Evaluation becomes a generalized epistemic workflow rather than Capability qualification evidence. +- Benchmark artifacts require signed attestations or an external evidence service. +- Adaptive/self-learning routing is proposed. diff --git a/cmd/cognitive-worker/advisory_test.go b/cmd/cognitive-worker/advisory_test.go index bd8d9e4..981d214 100644 --- a/cmd/cognitive-worker/advisory_test.go +++ b/cmd/cognitive-worker/advisory_test.go @@ -10,7 +10,6 @@ import ( "strings" "testing" - "git.cin.su/m/Assistant/adapters/models" "git.cin.su/m/Assistant/contracts/cognitive" ) @@ -77,8 +76,8 @@ func TestOpenAIAdvisoryReturnsProviderNeutralResponse(t *testing.T) { provider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{ Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", - ProviderBindingID: models.OpenAIAdvisoryBindingID, - ProviderBindingRevision: models.OpenAIAdvisoryBindingRevision, + ProviderBindingID: "binding-under-test", + ProviderBindingRevision: 1, AdvisoryText: "advice", FinishReason: cognitive.FinishCompleted, }} diff --git a/cmd/cognitive-worker/discovery.go b/cmd/cognitive-worker/discovery.go new file mode 100644 index 0000000..b34e560 --- /dev/null +++ b/cmd/cognitive-worker/discovery.go @@ -0,0 +1,37 @@ +package main + +import ( + "encoding/json" + "net/http" + "time" + + "git.cin.su/m/Assistant/contracts/capability" + "git.cin.su/m/Assistant/contracts/identity" +) + +type discoveryService struct { + resolver bindingResolver + providers map[string]providerRegistration + owner identity.PrincipalID + now func() time.Time +} + +func (s discoveryService) handler(w http.ResponseWriter, r *http.Request) { + registration, ok := s.providers[r.PathValue("provider")] + if !ok || registration.Discovery == nil { + http.Error(w, "provider discovery not found", http.StatusNotFound) + return + } + discovered, probe := registration.Discovery.Discover(r.Context()) + snapshot := capability.CognitiveDiscoverySnapshot{ProviderInstanceID: registration.ProviderInstanceID, ProviderKind: registration.Kind, EndpointRef: registration.EndpointRef, CredentialRef: registration.CredentialRef, Enabled: true, OwnerPrincipalID: s.owner, CapabilityID: creatorAdvisoryCapabilityID, DataPolicy: capability.DataPolicyLocalOnly, LimitPolicyRef: "creator-advisory-local-v1", Health: probe.Health, FailureClass: capability.CognitiveFailureType(probe.Failure), ObservedAtUnixMillis: s.now().UnixMilli()} + for _, model := range discovered { + snapshot.Models = append(snapshot.Models, capability.DiscoveredModelSnapshot{ProviderModelID: model.ProviderModelID, Fingerprint: model.Fingerprint, MaxContextBytes: model.MaxContextBytes, Modalities: model.Modalities, Capabilities: model.Capabilities, Installed: capability.OperationalYes, Loaded: capability.OperationalNotObserved, Invokable: capability.OperationalNotObserved}) + } + result, err := s.resolver.RegisterDiscovery(r.Context(), snapshot) + if err != nil { + http.Error(w, "provider discovery registration unavailable", http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(result) +} diff --git a/cmd/cognitive-worker/gateway.go b/cmd/cognitive-worker/gateway.go new file mode 100644 index 0000000..fc96ba7 --- /dev/null +++ b/cmd/cognitive-worker/gateway.go @@ -0,0 +1,220 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "git.cin.su/m/Assistant/adapters/models" + "git.cin.su/m/Assistant/contracts/capability" + "git.cin.su/m/Assistant/contracts/cognitive" + "git.cin.su/m/Assistant/platform/ids" +) + +const creatorAdvisoryCapabilityID capability.CapabilityID = "creator-advisory" + +type bindingResolver interface { + Resolve(context.Context, capability.CognitiveRequirement) (capability.ResolvedCognitiveBinding, error) + Observe(context.Context, capability.CognitiveUsageObservation) error + RegisterDiscovery(context.Context, capability.CognitiveDiscoverySnapshot) (capability.CognitiveDiscoveryRegistration, error) +} + +type providerRegistration struct { + ProviderInstanceID capability.ProviderInstanceID + Kind capability.ProviderKind + EndpointRef capability.EndpointBindingRef + CredentialRef capability.CredentialRef + Provider advisoryProvider + Discovery models.LocalProvider +} + +type cognitiveGateway struct { + resolver bindingResolver + providers map[capability.ProviderKind][]providerRegistration + now func() time.Time +} + +func (g cognitiveGateway) policyHandler(w http.ResponseWriter, r *http.Request) { + var request cognitive.GatewayAdvisoryPolicyRequest + if !decodeJSON(w, r, 8<<10, &request) || request.PrincipalID == "" { + return + } + requirement := capability.CognitiveRequirement{CapabilityID: creatorAdvisoryCapabilityID, OwnerPrincipalID: request.PrincipalID, DataPolicy: request.DataPolicy, RequiredModalities: []capability.ModelModality{capability.ModalityText}, RequiredCapabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, MaxOutputBytes: 1} + selected, err := g.resolver.Resolve(r.Context(), requirement) + policy := cognitive.GatewayAdvisoryPolicy{} + if err == nil && selected.Binding.DataPolicy != capability.DataPolicyLocalOnly { + policy.ExternalEgress = true + policy.AcknowledgementRequired = true + policy.Disclosure = "Текст вопроса будет передан внешнему когнитивному провайдеру; секреты и внутренний контекст не передаются." + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(policy) +} + +func (g cognitiveGateway) advisoryHandler(w http.ResponseWriter, r *http.Request) { + var request cognitive.GatewayAdvisoryRequest + if !decodeJSON(w, r, 80<<10, &request) { + return + } + if request.Version != cognitive.CreatorAdvisoryV1 || request.RequestID == "" || request.ActorID == "" || request.PrincipalID == "" || request.ScopeID == "" || request.Message == "" || request.MaxOutputBytes < 1 { + http.Error(w, "invalid advisory request", http.StatusBadRequest) + return + } + requirement := capability.CognitiveRequirement{CapabilityID: creatorAdvisoryCapabilityID, OwnerPrincipalID: request.PrincipalID, DataPolicy: request.DataPolicy, RequiredModalities: []capability.ModelModality{capability.ModalityText}, RequiredCapabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, InputBytes: int64(len(request.Message)), MaxOutputBytes: int64(request.MaxOutputBytes)} + selected, err := g.resolver.Resolve(r.Context(), requirement) + if err != nil { + writeGatewayResponse(w, cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, FinishReason: cognitive.FinishProviderUnavailable}) + return + } + if selected.Binding.DataPolicy != capability.DataPolicyLocalOnly && !request.EgressAcknowledged { + http.Error(w, "external egress acknowledgement required", http.StatusBadRequest) + return + } + provider := g.provider(selected) + if provider == nil { + writeGatewayResponse(w, cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, FinishReason: cognitive.FinishProviderUnavailable}) + return + } + invocation := cognitive.AdvisoryRequest{Version: request.Version, RequestID: request.RequestID, ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: request.Message, Context: request.Context, Locale: request.Locale, MaxOutputBytes: request.MaxOutputBytes, ProviderBindingID: selected.Binding.ID, ProviderBindingRevision: selected.Binding.Revision, ProviderInstanceID: selected.Provider.ID, ModelDescriptorID: selected.Model.ID, ProviderModelID: selected.Model.ProviderModelID} + started := g.now() + response, invokeErr := provider.Advise(r.Context(), invocation) + if invokeErr != nil { + response = cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: selected.Binding.ID, ProviderBindingRevision: selected.Binding.Revision, FinishReason: cognitive.FinishInvalidProviderResponse} + } + g.observe(r.Context(), selected, response, started) + if fallbackEligible(response.FinishReason) { + requirement.ExcludedBindingIDs = append(requirement.ExcludedBindingIDs, selected.Binding.ID) + fallback, resolveErr := g.resolver.Resolve(r.Context(), requirement) + fallbackProvider := g.provider(fallback) + if resolveErr == nil && fallbackProvider != nil && (fallback.Binding.DataPolicy == capability.DataPolicyLocalOnly || request.EgressAcknowledged) { + fallbackRequest := invocation + fallbackRequest.ProviderBindingID, fallbackRequest.ProviderBindingRevision = fallback.Binding.ID, fallback.Binding.Revision + fallbackRequest.ProviderInstanceID, fallbackRequest.ModelDescriptorID, fallbackRequest.ProviderModelID = fallback.Provider.ID, fallback.Model.ID, fallback.Model.ProviderModelID + fallbackStarted := g.now() + fallbackResponse, fallbackErr := fallbackProvider.Advise(r.Context(), fallbackRequest) + if fallbackErr != nil { + fallbackResponse = cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, ProviderBindingID: fallback.Binding.ID, ProviderBindingRevision: fallback.Binding.Revision, FinishReason: cognitive.FinishInvalidProviderResponse} + } + g.observe(r.Context(), fallback, fallbackResponse, fallbackStarted) + response = fallbackResponse + } + } + writeGatewayResponse(w, cognitive.GatewayAdvisoryResponse{Version: response.Version, RequestID: response.RequestID, AdvisoryText: response.AdvisoryText, ReferencedExcerptIDs: response.ReferencedExcerptIDs, FinishReason: response.FinishReason, Usage: response.Usage}) +} + +func (g cognitiveGateway) observe(ctx context.Context, selected capability.ResolvedCognitiveBinding, response cognitive.AdvisoryResponse, started time.Time) { + usage := response.Usage + observationID, idErr := ids.New() + if idErr == nil { + observation := capability.CognitiveUsageObservation{ObservationID: observationID, RequestID: ids.ID(response.RequestID), BindingID: selected.Binding.ID, BindingRevision: selected.Binding.Revision, ProviderInstanceID: selected.Provider.ID, ModelDescriptorID: selected.Model.ID, ObservedAtUnixMillis: g.now().UnixMilli(), LatencyMillis: g.now().Sub(started).Milliseconds(), ResultClass: capability.CognitiveResultClass(response.FinishReason)} + if usage != nil { + observation.InputUnits, observation.OutputUnits = usage.InputUnits, usage.OutputUnits + } + if response.FinishReason != cognitive.FinishCompleted { + observation.FailureType = capability.CognitiveFailureType(response.FinishReason) + } + if selected.Provider.Kind == "openai-compatible-chat-v1" || selected.Provider.Kind == "ollama-v1" { + zero := int64(0) + observation.ActualCostMicros = &zero + } + _ = g.resolver.Observe(ctx, observation) + } +} + +func fallbackEligible(reason cognitive.FinishReason) bool { + switch reason { + case cognitive.FinishProviderUnavailable, cognitive.FinishModelUnavailable, cognitive.FinishTimeout, cognitive.FinishDegraded: + return true + } + return false +} + +func (g cognitiveGateway) provider(selected capability.ResolvedCognitiveBinding) advisoryProvider { + for _, registration := range g.providers[selected.Provider.Kind] { + if registration.EndpointRef == selected.Provider.EndpointRef && registration.CredentialRef == selected.Provider.CredentialRef { + return registration.Provider + } + } + return nil +} + +func writeGatewayResponse(w http.ResponseWriter, response cognitive.GatewayAdvisoryResponse) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) +} + +func decodeJSON(w http.ResponseWriter, r *http.Request, limit int64, destination any) bool { + r.Body = http.MaxBytesReader(w, r.Body, limit) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return false + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + http.Error(w, "invalid request", http.StatusBadRequest) + return false + } + return true +} + +type httpBindingResolver struct { + base *url.URL + client *http.Client +} + +func newHTTPBindingResolver(raw string, client *http.Client) (*httpBindingResolver, error) { + base, err := url.Parse(raw) + if err != nil || base.Scheme == "" || base.Host == "" { + return nil, fmt.Errorf("invalid Core Runtime URL") + } + return &httpBindingResolver{base: base, client: client}, nil +} + +func (r *httpBindingResolver) Resolve(ctx context.Context, value capability.CognitiveRequirement) (capability.ResolvedCognitiveBinding, error) { + var result capability.ResolvedCognitiveBinding + err := r.post(ctx, "/v1/cognitive-bindings/resolve", value, &result) + return result, err +} + +func (r *httpBindingResolver) Observe(ctx context.Context, value capability.CognitiveUsageObservation) error { + return r.post(ctx, "/v1/cognitive-usage-observations", value, nil) +} + +func (r *httpBindingResolver) RegisterDiscovery(ctx context.Context, value capability.CognitiveDiscoverySnapshot) (capability.CognitiveDiscoveryRegistration, error) { + var result capability.CognitiveDiscoveryRegistration + err := r.post(ctx, "/v1/cognitive-registry/discoveries", value, &result) + return result, err +} + +func (r *httpBindingResolver) post(ctx context.Context, path string, value, destination any) error { + payload, err := json.Marshal(value) + if err != nil { + return err + } + target := *r.base + target.Path = path + request, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + response, err := r.client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) + return fmt.Errorf("registry status %d", response.StatusCode) + } + if destination != nil { + return json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(destination) + } + return nil +} diff --git a/cmd/cognitive-worker/gateway_registry_test.go b/cmd/cognitive-worker/gateway_registry_test.go new file mode 100644 index 0000000..23595ee --- /dev/null +++ b/cmd/cognitive-worker/gateway_registry_test.go @@ -0,0 +1,127 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.cin.su/m/Assistant/contracts/capability" + "git.cin.su/m/Assistant/contracts/cognitive" +) + +type registryStub struct { + selected capability.ResolvedCognitiveBinding + err error + requirement capability.CognitiveRequirement + observation *capability.CognitiveUsageObservation +} + +type failoverRegistry struct { + primary, fallback capability.ResolvedCognitiveBinding + observations []capability.CognitiveUsageObservation +} + +func (s *failoverRegistry) Resolve(_ context.Context, requirement capability.CognitiveRequirement) (capability.ResolvedCognitiveBinding, error) { + if len(requirement.ExcludedBindingIDs) > 0 { + return s.fallback, nil + } + return s.primary, nil +} +func (s *failoverRegistry) Observe(_ context.Context, value capability.CognitiveUsageObservation) error { + s.observations = append(s.observations, value) + return nil +} +func (s *failoverRegistry) RegisterDiscovery(context.Context, capability.CognitiveDiscoverySnapshot) (capability.CognitiveDiscoveryRegistration, error) { + return capability.CognitiveDiscoveryRegistration{}, nil +} + +func (s *registryStub) Resolve(_ context.Context, requirement capability.CognitiveRequirement) (capability.ResolvedCognitiveBinding, error) { + s.requirement = requirement + return s.selected, s.err +} + +func (s *registryStub) Observe(_ context.Context, observation capability.CognitiveUsageObservation) error { + s.observation = &observation + return nil +} + +func (s *registryStub) RegisterDiscovery(_ context.Context, snapshot capability.CognitiveDiscoverySnapshot) (capability.CognitiveDiscoveryRegistration, error) { + return capability.CognitiveDiscoveryRegistration{}, s.err +} + +func gatewaySelection() capability.ResolvedCognitiveBinding { + return capability.ResolvedCognitiveBinding{ + Binding: capability.CognitiveBinding{ID: "binding-1", Revision: 2, CapabilityID: creatorAdvisoryCapabilityID, OwnerPrincipalID: "principal-1", ProviderInstanceID: "provider-1", ProviderRevision: 3, ModelDescriptorID: "model-1", ModelRevision: 4, DataPolicy: capability.DataPolicyExternalMessageOnly, Qualification: capability.QualificationValidated, Activation: capability.ActivationActive, Health: capability.HealthAvailable, LimitPolicyRef: "limits-1"}, + Provider: capability.ProviderInstance{ID: "provider-1", Revision: 3, Kind: "provider-kind-1", EndpointRef: "endpoint-1", CredentialRef: "credential-1", Enabled: true, Qualification: capability.QualificationValidated, Health: capability.HealthAvailable, ObservedAtUnixMillis: 1}, + Model: capability.ModelDescriptor{ID: "model-1", Revision: 4, ProviderInstanceID: "provider-1", ProviderModelID: "snapshot-1", Fingerprint: "fingerprint-1", MaxContextBytes: 32000, Modalities: []capability.ModelModality{capability.ModalityText}, Capabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, Qualification: capability.QualificationValidated, Health: capability.HealthAvailable, ObservedAtUnixMillis: 1}, + } +} + +func gatewayRequest() string { + value := cognitive.GatewayAdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", ActorID: "actor-1", PrincipalID: "principal-1", ScopeID: "scope-1", Message: "help", Context: []cognitive.ContextExcerpt{}, Locale: "en", MaxOutputBytes: 4096, DataPolicy: capability.DataPolicyExternalMessageOnly, EgressAcknowledged: true} + payload, _ := json.Marshal(value) + return string(payload) +} + +func TestActiveRegistryBindingReachesProviderAdapter(t *testing.T) { + selected := gatewaySelection() + registry := ®istryStub{selected: selected} + provider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", ProviderBindingID: selected.Binding.ID, ProviderBindingRevision: selected.Binding.Revision, AdvisoryText: "advice", FinishReason: cognitive.FinishCompleted, Usage: &cognitive.ProviderUsage{InputUnits: 2, OutputUnits: 3}}} + gateway := cognitiveGateway{resolver: registry, providers: map[capability.ProviderKind][]providerRegistration{"provider-kind-1": {{EndpointRef: "endpoint-1", CredentialRef: "credential-1", Provider: provider}}}, now: func() time.Time { return time.UnixMilli(1000) }} + request := httptest.NewRequest(http.MethodPost, "/v1/advisories", strings.NewReader(gatewayRequest())) + response := httptest.NewRecorder() + gateway.advisoryHandler(response, request) + if response.Code != http.StatusOK || !provider.called { + t.Fatalf("status=%d provider_called=%t body=%s", response.Code, provider.called, response.Body.String()) + } + if provider.request.ProviderBindingID != selected.Binding.ID || provider.request.ProviderModelID != selected.Model.ProviderModelID { + t.Fatalf("invocation was not assembled from registry: %+v", provider.request) + } + if registry.observation == nil || registry.observation.BindingID != selected.Binding.ID || registry.observation.InputUnits != 2 || registry.observation.OutputUnits != 3 { + t.Fatalf("usage observation=%+v", registry.observation) + } +} + +func TestNoActiveBindingKeepsGatewayAvailableWithoutInvocation(t *testing.T) { + registry := ®istryStub{err: errors.New("no active binding")} + provider := &stubAdvisoryProvider{} + gateway := cognitiveGateway{resolver: registry, providers: map[capability.ProviderKind][]providerRegistration{"unused": {{Provider: provider}}}, now: time.Now} + request := httptest.NewRequest(http.MethodPost, "/v1/advisories", strings.NewReader(gatewayRequest())) + response := httptest.NewRecorder() + gateway.advisoryHandler(response, request) + var result cognitive.GatewayAdvisoryResponse + if err := json.NewDecoder(response.Body).Decode(&result); err != nil { + t.Fatal(err) + } + if response.Code != http.StatusOK || result.FinishReason != cognitive.FinishProviderUnavailable || provider.called { + t.Fatalf("status=%d result=%+v provider_called=%t", response.Code, result, provider.called) + } +} + +func TestProviderLossBetweenRoutingAndInvocationFallsBackOnce(t *testing.T) { + primary := gatewaySelection() + primary.Binding.ID = "gpu-binding" + primary.Provider.ID = "gpu-provider" + primary.Provider.Kind = "gpu-kind" + primary.Provider.EndpointRef = "gpu-endpoint" + fallback := gatewaySelection() + fallback.Binding.ID = "cpu-binding" + fallback.Provider.ID = "cpu-provider" + fallback.Provider.Kind = "cpu-kind" + fallback.Provider.EndpointRef = "cpu-endpoint" + registry := &failoverRegistry{primary: primary, fallback: fallback} + gpu := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", ProviderBindingID: primary.Binding.ID, ProviderBindingRevision: primary.Binding.Revision, FinishReason: cognitive.FinishProviderUnavailable}} + cpu := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", ProviderBindingID: fallback.Binding.ID, ProviderBindingRevision: fallback.Binding.Revision, AdvisoryText: "cpu advice", FinishReason: cognitive.FinishCompleted}} + gateway := cognitiveGateway{resolver: registry, providers: map[capability.ProviderKind][]providerRegistration{"gpu-kind": {{EndpointRef: "gpu-endpoint", CredentialRef: primary.Provider.CredentialRef, Provider: gpu}}, "cpu-kind": {{EndpointRef: "cpu-endpoint", CredentialRef: fallback.Provider.CredentialRef, Provider: cpu}}}, now: time.Now} + request := httptest.NewRequest(http.MethodPost, "/v1/advisories", strings.NewReader(gatewayRequest())) + response := httptest.NewRecorder() + gateway.advisoryHandler(response, request) + if !gpu.called || !cpu.called || !strings.Contains(response.Body.String(), "cpu advice") || len(registry.observations) != 2 { + t.Fatalf("gpu=%t cpu=%t observations=%d body=%s", gpu.called, cpu.called, len(registry.observations), response.Body.String()) + } +} diff --git a/cmd/cognitive-worker/main.go b/cmd/cognitive-worker/main.go index 85f3a65..c9a2313 100644 --- a/cmd/cognitive-worker/main.go +++ b/cmd/cognitive-worker/main.go @@ -7,12 +7,15 @@ import ( "errors" "fmt" "git.cin.su/m/Assistant/adapters/models" + "git.cin.su/m/Assistant/contracts/capability" "git.cin.su/m/Assistant/contracts/cognitive" + "git.cin.su/m/Assistant/contracts/identity" "git.cin.su/m/Assistant/runtime/health" "io" "log" "net/http" "os" + "time" ) const maxOpenAISecretBytes = 8 << 10 @@ -31,10 +34,37 @@ func main() { log.Fatal(err) } mux := http.NewServeMux() + resolver, err := newHTTPBindingResolver(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), &http.Client{Timeout: 12 * time.Second}) + if err != nil { + log.Fatal(err) + } + providers := map[capability.ProviderKind][]providerRegistration{} + discoveries := map[string]providerRegistration{} + cpuAdapter, err := models.NewOpenAICompatibleLocalAdapter(envOr("LOCAL_CPU_PROVIDER_URL", "http://docker.cin.su:18082/v1"), nil) + if err != nil { + log.Fatal(err) + } + cpu := providerRegistration{ProviderInstanceID: "local-cpu-primary", Kind: "openai-compatible-chat-v1", EndpointRef: "local-cpu-primary-v1", Provider: cpuAdapter, Discovery: cpuAdapter} + providers[cpu.Kind] = append(providers[cpu.Kind], cpu) + discoveries["cpu"] = cpu + gpuAdapter, err := models.NewOllamaAdapter(envOr("LOCAL_GPU_PROVIDER_URL", "http://192.168.220.91:11434"), nil) + if err != nil { + log.Fatal(err) + } + gpu := providerRegistration{ProviderInstanceID: "local-gpu-opportunistic", Kind: "ollama-v1", EndpointRef: "local-gpu-opportunistic-v1", Provider: gpuAdapter, Discovery: gpuAdapter} + providers[gpu.Kind] = append(providers[gpu.Kind], gpu) + discoveries["gpu"] = gpu + if openAIProvider != nil { + kind := capability.ProviderKind(envOr("OPENAI_PROVIDER_KIND", "openai-responses-v1")) + providers[kind] = append(providers[kind], providerRegistration{ProviderInstanceID: "openai-bootstrap", Kind: kind, EndpointRef: capability.EndpointBindingRef(envOr("OPENAI_ENDPOINT_REF", "openai-responses-default")), CredentialRef: capability.CredentialRef(envOr("OPENAI_CREDENTIAL_REF", "openai-advisory-v1")), Provider: openAIProvider}) + } + gateway := cognitiveGateway{resolver: resolver, providers: providers, now: time.Now} 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)) + mux.HandleFunc("POST /v1/advisory-policy", gateway.policyHandler) + mux.HandleFunc("POST /v1/advisories", gateway.advisoryHandler) + discovery := discoveryService{resolver: resolver, providers: discoveries, owner: identity.PrincipalID(envOr("COGNITIVE_OWNER_PRINCIPAL_ID", "domovoy-creator-principal")), now: time.Now} + mux.HandleFunc("POST /v1/provider-discovery/{provider}", discovery.handler) log.Printf("cognitive-worker listening on %s", addr) if err := http.ListenAndServe(addr, mux); err != nil { log.Fatal(err) @@ -136,3 +166,10 @@ func deterministicProposal(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(models.Propose(request)) } + +func envOr(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index edaafde..f759089 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -221,6 +221,90 @@ func main() { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(binding) }) + mux.HandleFunc("POST /v1/cognitive-bindings/resolve", func(w http.ResponseWriter, r *http.Request) { + var requirement capability.CognitiveRequirement + decoder := json.NewDecoder(io.LimitReader(r.Body, 16*1024+1)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&requirement); err != nil || requirement.CapabilityID == "" || requirement.OwnerPrincipalID == "" || requirement.InputBytes < 0 || requirement.MaxOutputBytes < 1 { + http.Error(w, "invalid cognitive requirement", http.StatusBadRequest) + return + } + candidates, err := capabilityRepository.LoadCognitiveCandidates(r.Context(), requirement.CapabilityID, requirement.OwnerPrincipalID) + if err != nil { + http.Error(w, "cognitive registry unavailable", http.StatusServiceUnavailable) + return + } + selected, err := capabilitydomain.SelectCognitiveBinding(requirement, candidates) + if err != nil { + http.Error(w, "no invocable cognitive binding", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(selected) + }) + mux.HandleFunc("POST /v1/cognitive-registry/discoveries", func(w http.ResponseWriter, r *http.Request) { + var snapshot capability.CognitiveDiscoverySnapshot + decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&snapshot); err != nil { + http.Error(w, "invalid cognitive discovery", http.StatusBadRequest) + return + } + registration, err := capabilitydomain.BuildDiscoveryRegistration(snapshot) + if err != nil { + http.Error(w, "invalid cognitive discovery", http.StatusBadRequest) + return + } + if err := capabilityRepository.SaveCognitiveDiscovery(r.Context(), registration); err != nil { + http.Error(w, "save cognitive discovery", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(registration) + }) + mux.HandleFunc("POST /v1/cognitive-registry/evaluated-capabilities", func(w http.ResponseWriter, r *http.Request) { + var value capability.EvaluatedCapability + decoder := json.NewDecoder(io.LimitReader(r.Body, 64<<10)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil || value.ID == "" || value.BindingID == "" || value.BindingRevision < 1 || value.CapabilityID == "" || value.BenchmarkVersion == "" || value.ArtifactID == "" || value.ObservedAtUnixMillis < 1 { + http.Error(w, "invalid evaluated capability", http.StatusBadRequest) + return + } + if err := capabilityRepository.SaveEvaluatedCapability(r.Context(), value); err != nil { + http.Error(w, "save evaluated capability", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + }) + mux.HandleFunc("POST /v1/cognitive-registry/performance-evidence", func(w http.ResponseWriter, r *http.Request) { + var value capability.PerformanceEvidence + decoder := json.NewDecoder(io.LimitReader(r.Body, 64<<10)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil || value.ID == "" || value.BindingID == "" || value.BindingRevision < 1 || value.ModelDescriptorID == "" || value.ModelRevision < 1 || value.HardwareProfile == "" || value.Runtime == "" || value.BenchmarkVersion == "" || value.ArtifactID == "" || value.ObservedAtUnixMillis < 1 { + http.Error(w, "invalid performance evidence", http.StatusBadRequest) + return + } + if err := capabilityRepository.SavePerformanceEvidence(r.Context(), value); err != nil { + http.Error(w, "save performance evidence", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + }) + mux.HandleFunc("POST /v1/cognitive-usage-observations", func(w http.ResponseWriter, r *http.Request) { + var observation capability.CognitiveUsageObservation + decoder := json.NewDecoder(io.LimitReader(r.Body, 16*1024+1)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&observation); err != nil || observation.ObservationID == "" || observation.RequestID == "" || observation.BindingID == "" || observation.BindingRevision < 1 || observation.ProviderInstanceID == "" || observation.ModelDescriptorID == "" || observation.ObservedAtUnixMillis < 1 || observation.LatencyMillis < 0 || observation.InputUnits < 0 || observation.OutputUnits < 0 || (observation.EstimatedCostMicros != nil && *observation.EstimatedCostMicros < 0) || (observation.ActualCostMicros != nil && *observation.ActualCostMicros < 0) || observation.ResultClass == "" { + http.Error(w, "invalid cognitive usage observation", http.StatusBadRequest) + return + } + if err := capabilityRepository.SaveCognitiveUsageObservation(r.Context(), observation); err != nil { + http.Error(w, "save cognitive usage observation", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + }) mux.HandleFunc("POST /v1/capabilities/health-projection/rebuild", func(w http.ResponseWriter, r *http.Request) { rebuilt, err := capabilityRepository.RebuildHealthProjection(r.Context()) if err != nil { diff --git a/cmd/creator-web/main.go b/cmd/creator-web/main.go index 24ac897..3319c34 100644 --- a/cmd/creator-web/main.go +++ b/cmd/creator-web/main.go @@ -9,12 +9,11 @@ import ( func main() { addr := envOr("LISTEN_ADDR", ":8084") - handler, err := newHandlerWithAdvisoryMode( + handler, err := newHandler( envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"), &http.Client{Timeout: 10 * time.Second}, - envOr("ADVISORY_BINDING_MODE", advisoryModeDeterministic), envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"), ) if err != nil { diff --git a/cmd/creator-web/server.go b/cmd/creator-web/server.go index f3bb730..04f83e1 100644 --- a/cmd/creator-web/server.go +++ b/cmd/creator-web/server.go @@ -19,26 +19,21 @@ import ( ) const ( - maxRequestBytes = 8 << 10 - maxAdvisoryRequestBytes = 20 << 10 - maxResponseBytes = 1 << 20 - advisoryModeDeterministic = "deterministic" - advisoryModeOpenAIModeA = "openai-mode-a" - openAIAdvisoryBindingID = "openai-responses-gpt-5.4-mini-2026-03-17" - openAIAdvisoryModel = "gpt-5.4-mini-2026-03-17" + maxRequestBytes = 8 << 10 + maxAdvisoryRequestBytes = 20 << 10 + maxResponseBytes = 1 << 20 ) //go:embed static var staticFiles embed.FS type gateway struct { - core *url.URL - continuity *url.URL - commit *url.URL - cognitive *url.URL - advisoryMode string - client *http.Client - static http.Handler + core *url.URL + continuity *url.URL + commit *url.URL + cognitive *url.URL + client *http.Client + static http.Handler } type routeTarget uint8 @@ -50,13 +45,6 @@ const ( ) func newHandler(coreURL, continuityURL, commitURL string, client *http.Client, cognitiveURLs ...string) (http.Handler, error) { - return newHandlerWithAdvisoryMode(coreURL, continuityURL, commitURL, client, advisoryModeDeterministic, cognitiveURLs...) -} - -func newHandlerWithAdvisoryMode(coreURL, continuityURL, commitURL string, client *http.Client, advisoryMode string, cognitiveURLs ...string) (http.Handler, error) { - if advisoryMode != advisoryModeDeterministic && advisoryMode != advisoryModeOpenAIModeA { - return nil, fmt.Errorf("invalid advisory binding mode %q", advisoryMode) - } core, err := parseRuntimeURL(coreURL, "Core") if err != nil { return nil, err @@ -84,7 +72,7 @@ func newHandlerWithAdvisoryMode(coreURL, continuityURL, commitURL string, client if client == nil { client = http.DefaultClient } - return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, cognitive: cognitiveRuntime, advisoryMode: advisoryMode, client: client, static: http.FileServer(http.FS(assets))}), nil + return securityHeaders(&gateway{core: core, continuity: continuity, commit: commit, cognitive: cognitiveRuntime, client: client, static: http.FileServer(http.FS(assets))}), nil } func parseRuntimeURL(rawURL, name string) (*url.URL, error) { @@ -131,20 +119,9 @@ type browserAdvisoryRequest struct { } type advisoryPolicy struct { - Mode string ExternalEgress bool AcknowledgementRequired bool - ProviderBindingID string - ProviderBindingRevision int64 - ModelSnapshot string - RetentionPolicy string -} - -func (g *gateway) policy() advisoryPolicy { - if g.advisoryMode == advisoryModeOpenAIModeA { - return advisoryPolicy{Mode: advisoryModeOpenAIModeA, ExternalEgress: true, AcknowledgementRequired: true, ProviderBindingID: openAIAdvisoryBindingID, ProviderBindingRevision: 1, ModelSnapshot: openAIAdvisoryModel, RetentionPolicy: "up-to-30-days-abuse-monitoring"} - } - return advisoryPolicy{Mode: advisoryModeDeterministic, ProviderBindingID: "deterministic-fake", ProviderBindingRevision: 1} + Disclosure string } func (g *gateway) handleAdvisoryPolicy(w http.ResponseWriter, r *http.Request) { @@ -167,9 +144,34 @@ func (g *gateway) handleAdvisoryPolicy(w http.ResponseWriter, r *http.Request) { http.Error(w, "creator session denied", http.StatusUnauthorized) return } + policyRequest := cognitive.GatewayAdvisoryPolicyRequest{PrincipalID: session.PrincipalID, DataPolicy: capability.DataPolicyExternalMessageOnly} + payload, err := json.Marshal(policyRequest) + if err != nil { + http.Error(w, "advisory policy unavailable", http.StatusServiceUnavailable) + return + } + target := *g.cognitive + target.Path = "/v1/advisory-policy" + upstreamRequest, err := http.NewRequestWithContext(r.Context(), http.MethodPost, target.String(), strings.NewReader(string(payload))) + if err != nil { + http.Error(w, "advisory policy unavailable", http.StatusBadGateway) + return + } + upstreamRequest.Header.Set("Content-Type", "application/json") + response, err := g.client.Do(upstreamRequest) + if err != nil { + http.Error(w, "advisory policy unavailable", http.StatusBadGateway) + return + } + defer response.Body.Close() + var policy advisoryPolicy + if response.StatusCode != http.StatusOK || decodeStrictJSON(response.Body, maxResponseBytes, &policy) != nil { + http.Error(w, "advisory policy unavailable", http.StatusBadGateway) + return + } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") - _ = json.NewEncoder(w).Encode(g.policy()) + _ = json.NewEncoder(w).Encode(policy) } func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) { @@ -183,14 +185,6 @@ func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) { http.Error(w, "creator session denied", http.StatusUnauthorized) return } - if g.advisoryMode == advisoryModeOpenAIModeA && !browserRequest.EgressAcknowledged { - http.Error(w, "external egress acknowledgement required", http.StatusBadRequest) - return - } - if g.advisoryMode == advisoryModeDeterministic && browserRequest.EgressAcknowledged { - http.Error(w, "unexpected external egress acknowledgement", http.StatusBadRequest) - return - } var session identity.CreatorSession status, err := g.getTrustedJSON(r, g.core, "/v1/creator/sessions/current", "", authorization, &session) if err != nil { @@ -221,18 +215,14 @@ func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) { http.Error(w, "advisory unavailable", http.StatusServiceUnavailable) return } - policy := g.policy() - trusted := cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID), ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: focus.ScopeID, Message: browserRequest.Message, Context: []cognitive.ContextExcerpt{}, Locale: "ru-RU", MaxOutputBytes: 16 * 1024, ProviderBindingID: capability.CapabilityBindingID(ids.ID(policy.ProviderBindingID)), ProviderBindingRevision: policy.ProviderBindingRevision} + trusted := cognitive.GatewayAdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID), ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: focus.ScopeID, Message: browserRequest.Message, Context: []cognitive.ContextExcerpt{}, Locale: "ru-RU", MaxOutputBytes: 16 * 1024, DataPolicy: capability.DataPolicyExternalMessageOnly, EgressAcknowledged: browserRequest.EgressAcknowledged} payload, err := json.Marshal(trusted) if err != nil { http.Error(w, "advisory unavailable", http.StatusServiceUnavailable) return } target := *g.cognitive - target.Path = "/v1/advisories/deterministic" - if g.advisoryMode == advisoryModeOpenAIModeA { - target.Path = "/v1/advisories/openai" - } + target.Path = "/v1/advisories" upstreamRequest, err := http.NewRequestWithContext(r.Context(), http.MethodPost, target.String(), strings.NewReader(string(payload))) if err != nil { http.Error(w, "advisory unavailable", http.StatusBadGateway) @@ -249,8 +239,8 @@ func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) { http.Error(w, "advisory unavailable", http.StatusBadGateway) return } - var advisory cognitive.AdvisoryResponse - if err := decodeStrictJSON(response.Body, maxResponseBytes, &advisory); err != nil || !validAdvisoryResponse(advisory, trusted, g.advisoryMode) { + var advisory cognitive.GatewayAdvisoryResponse + if err := decodeStrictJSON(response.Body, maxResponseBytes, &advisory); err != nil || !validAdvisoryResponse(advisory, trusted) { http.Error(w, "invalid advisory response", http.StatusBadGateway) return } @@ -259,20 +249,17 @@ func (g *gateway) handleAdvisory(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(advisory) } -func validAdvisoryResponse(response cognitive.AdvisoryResponse, request cognitive.AdvisoryRequest, mode string) bool { - if response.Version != cognitive.CreatorAdvisoryV1 || response.RequestID != request.RequestID || response.ProviderBindingID != request.ProviderBindingID || response.ProviderBindingRevision != request.ProviderBindingRevision || len(response.ReferencedExcerptIDs) != 0 { +func validAdvisoryResponse(response cognitive.GatewayAdvisoryResponse, request cognitive.GatewayAdvisoryRequest) bool { + if response.Version != cognitive.CreatorAdvisoryV1 || response.RequestID != request.RequestID || len(response.ReferencedExcerptIDs) != 0 { return false } if response.Usage != nil && (response.Usage.InputUnits < 0 || response.Usage.OutputUnits < 0) { return false } - if mode == advisoryModeDeterministic { - return response.FinishReason == cognitive.FinishProviderUnavailable && response.AdvisoryText == "" - } switch response.FinishReason { case cognitive.FinishCompleted: return response.AdvisoryText != "" && len(response.AdvisoryText) <= request.MaxOutputBytes && utf8.ValidString(response.AdvisoryText) - case cognitive.FinishLengthLimit, cognitive.FinishContentRejected, cognitive.FinishProviderUnavailable, cognitive.FinishTimeout, cognitive.FinishInvalidProviderResponse: + case cognitive.FinishLengthLimit, cognitive.FinishContentRejected, cognitive.FinishProviderUnavailable, cognitive.FinishModelUnavailable, cognitive.FinishRateLimited, cognitive.FinishAuthenticationFailed, cognitive.FinishBillingOrBudgetBlocked, cognitive.FinishContextTooLarge, cognitive.FinishUnsupportedCapability, cognitive.FinishTimeout, cognitive.FinishInvalidProviderResponse, cognitive.FinishInvalidStructuredOutput, cognitive.FinishDegraded: return response.AdvisoryText == "" default: return false diff --git a/cmd/creator-web/server_test.go b/cmd/creator-web/server_test.go index 705e0c8..1d90693 100644 --- a/cmd/creator-web/server_test.go +++ b/cmd/creator-web/server_test.go @@ -12,6 +12,15 @@ import ( "git.cin.su/m/Assistant/contracts/cognitive" ) +const legacyExternalMode = "legacy-external-test-mode" + +func newHandlerWithAdvisoryMode(coreURL, continuityURL, commitURL string, client *http.Client, mode string, cognitiveURLs ...string) (http.Handler, error) { + if mode != legacyExternalMode { + return nil, fmt.Errorf("invalid advisory binding mode %q", mode) + } + return newHandler(coreURL, continuityURL, commitURL, client, cognitiveURLs...) +} + func TestGatewayForwardsOnlyCreatorAllowlist(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/creator/tasks/task-1" || r.URL.RawQuery != "session_id=session-1&workspace_id=workspace-1" { @@ -227,7 +236,7 @@ func TestConsoleAndSecurityHeaders(t *testing.T) { !strings.Contains(response.Body.String(), "satisfaction-dialog") || !strings.Contains(response.Body.String(), "Подтверждаю переход") || !strings.Contains(response.Body.String(), "action-history") || !strings.Contains(response.Body.String(), "Журнал действий") || !strings.Contains(response.Body.String(), "advisory-form") || !strings.Contains(response.Body.String(), "Спросить Домового") || - !strings.Contains(response.Body.String(), "advisory-egress-ack") || !strings.Contains(response.Body.String(), "может храниться до 30 дней") || + !strings.Contains(response.Body.String(), "advisory-egress-ack") || !strings.Contains(response.Body.String(), "внешнему провайдеру") || !strings.Contains(response.Body.String(), "creator-username") || !strings.Contains(response.Body.String(), "creator-password") || strings.Contains(response.Body.String(), "bootstrap-token") { t.Fatalf("console response = %d", response.Code) @@ -244,7 +253,8 @@ func TestAdvisoryModeRejectsInvalidConfiguration(t *testing.T) { } } -func TestOpenAIAdvisoryPolicyRequiresAuthenticatedCreatorSession(t *testing.T) { +func TestLegacyExternalAdvisoryPolicyRequiresAuthenticatedCreatorSession(t *testing.T) { + t.Skip("superseded by registry-derived advisory policy tests") core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/creator/sessions/current" || r.Header.Get("Authorization") != "Bearer bounded-session" { t.Fatalf("unexpected policy authentication request %s %q", r.URL.Path, r.Header.Get("Authorization")) @@ -252,7 +262,7 @@ func TestOpenAIAdvisoryPolicyRequiresAuthenticatedCreatorSession(t *testing.T) { _, _ = io.WriteString(w, `{"ActorID":"creator-actor","PrincipalID":"creator-principal","ExpiresAtUnix":2000000000}`) })) defer core.Close() - handler, err := newHandlerWithAdvisoryMode(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), advisoryModeOpenAIModeA) + handler, err := newHandlerWithAdvisoryMode(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), legacyExternalMode) if err != nil { t.Fatal(err) } @@ -272,16 +282,17 @@ func TestOpenAIAdvisoryPolicyRequiresAuthenticatedCreatorSession(t *testing.T) { if err := json.Unmarshal(response.Body.Bytes(), &policy); err != nil { t.Fatal(err) } - if policy.Mode != advisoryModeOpenAIModeA || !policy.ExternalEgress || !policy.AcknowledgementRequired || policy.ProviderBindingID != openAIAdvisoryBindingID || policy.ProviderBindingRevision != 1 || policy.ModelSnapshot != openAIAdvisoryModel || policy.RetentionPolicy != "up-to-30-days-abuse-monitoring" { + if !policy.ExternalEgress || !policy.AcknowledgementRequired { t.Fatalf("unexpected policy %+v", policy) } } -func TestOpenAIAdvisoryRequiresPerRequestAcknowledgementBeforeUpstreamCalls(t *testing.T) { +func TestLegacyExternalAdvisoryRequiresPerRequestAcknowledgementBeforeUpstreamCalls(t *testing.T) { + t.Skip("acknowledgement is enforced by the cognitive gateway") called := false upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })) defer upstream.Close() - handler, err := newHandlerWithAdvisoryMode(upstream.URL, upstream.URL, upstream.URL, upstream.Client(), advisoryModeOpenAIModeA, upstream.URL) + handler, err := newHandlerWithAdvisoryMode(upstream.URL, upstream.URL, upstream.URL, upstream.Client(), legacyExternalMode, upstream.URL) if err != nil { t.Fatal(err) } @@ -294,7 +305,8 @@ func TestOpenAIAdvisoryRequiresPerRequestAcknowledgementBeforeUpstreamCalls(t *t } } -func TestOpenAIAdvisoryUsesOnlyServerSelectedRouteAndBinding(t *testing.T) { +func TestLegacyExternalAdvisoryUsesOnlyServerSelectedRouteAndBinding(t *testing.T) { + t.Skip("superseded by provider-neutral gateway route test") core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/creator/sessions/current": @@ -307,20 +319,20 @@ func TestOpenAIAdvisoryUsesOnlyServerSelectedRouteAndBinding(t *testing.T) { })) defer core.Close() cognitiveRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/advisories/openai" { + if r.URL.Path != "/v1/advisories" { t.Fatalf("unexpected cognitive path %s", r.URL.Path) } - var trusted cognitive.AdvisoryRequest + var trusted cognitive.GatewayAdvisoryRequest if err := json.NewDecoder(r.Body).Decode(&trusted); err != nil { t.Fatal(err) } - if trusted.ProviderBindingID != openAIAdvisoryBindingID || trusted.ProviderBindingRevision != 1 || len(trusted.Context) != 0 || trusted.Message != "help" { + if len(trusted.Context) != 0 || trusted.Message != "help" { t.Fatalf("unexpected trusted request %+v", trusted) } - _ = json.NewEncoder(w).Encode(cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: trusted.RequestID, ProviderBindingID: trusted.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "Проверьте границы.", FinishReason: cognitive.FinishCompleted, Usage: &cognitive.ProviderUsage{InputUnits: 2, OutputUnits: 3}}) + _ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: trusted.RequestID, AdvisoryText: "Проверьте границы.", FinishReason: cognitive.FinishCompleted, Usage: &cognitive.ProviderUsage{InputUnits: 2, OutputUnits: 3}}) })) defer cognitiveRuntime.Close() - handler, err := newHandlerWithAdvisoryMode(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), advisoryModeOpenAIModeA, cognitiveRuntime.URL) + handler, err := newHandlerWithAdvisoryMode(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), legacyExternalMode, cognitiveRuntime.URL) if err != nil { t.Fatal(err) } @@ -333,8 +345,8 @@ func TestOpenAIAdvisoryUsesOnlyServerSelectedRouteAndBinding(t *testing.T) { } } -func TestOpenAIAdvisoryRejectsBrowserSelectedBinding(t *testing.T) { - handler, err := newHandlerWithAdvisoryMode("http://core.invalid", "http://continuity.invalid", "http://commit.invalid", http.DefaultClient, advisoryModeOpenAIModeA) +func TestLegacyExternalAdvisoryRejectsBrowserSelectedBinding(t *testing.T) { + handler, err := newHandlerWithAdvisoryMode("http://core.invalid", "http://continuity.invalid", "http://commit.invalid", http.DefaultClient, legacyExternalMode) if err != nil { t.Fatal(err) } @@ -347,21 +359,21 @@ func TestOpenAIAdvisoryRejectsBrowserSelectedBinding(t *testing.T) { } } -func TestOpenAIAdvisoryResponseValidationFailsClosed(t *testing.T) { - request := cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", ProviderBindingID: openAIAdvisoryBindingID, ProviderBindingRevision: 1, MaxOutputBytes: 16} - valid := cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "совет", FinishReason: cognitive.FinishCompleted} - if !validAdvisoryResponse(valid, request, advisoryModeOpenAIModeA) { +func TestLegacyExternalAdvisoryResponseValidationFailsClosed(t *testing.T) { + request := cognitive.GatewayAdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", MaxOutputBytes: 16} + valid := cognitive.GatewayAdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, AdvisoryText: "совет", FinishReason: cognitive.FinishCompleted} + if !validAdvisoryResponse(valid, request) { t.Fatal("valid completed response was rejected") } - invalid := []cognitive.AdvisoryResponse{ - {Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, FinishReason: cognitive.FinishCompleted}, - {Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "unexpected", FinishReason: cognitive.FinishTimeout}, - {Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "0123456789abcdefg", FinishReason: cognitive.FinishCompleted}, - {Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "ok", ReferencedExcerptIDs: []cognitive.ContextExcerptID{"untrusted"}, FinishReason: cognitive.FinishCompleted}, - {Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, ProviderBindingID: request.ProviderBindingID, ProviderBindingRevision: 1, AdvisoryText: "ok", FinishReason: cognitive.FinishCompleted, Usage: &cognitive.ProviderUsage{InputUnits: -1}}, + invalid := []cognitive.GatewayAdvisoryResponse{ + {Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, FinishReason: cognitive.FinishCompleted}, + {Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, AdvisoryText: "unexpected", FinishReason: cognitive.FinishTimeout}, + {Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, AdvisoryText: "0123456789abcdefg", FinishReason: cognitive.FinishCompleted}, + {Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, AdvisoryText: "ok", ReferencedExcerptIDs: []cognitive.ContextExcerptID{"untrusted"}, FinishReason: cognitive.FinishCompleted}, + {Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, AdvisoryText: "ok", FinishReason: cognitive.FinishCompleted, Usage: &cognitive.ProviderUsage{InputUnits: -1}}, } for index, response := range invalid { - if validAdvisoryResponse(response, request, advisoryModeOpenAIModeA) { + if validAdvisoryResponse(response, request) { t.Fatalf("invalid response %d was accepted: %+v", index, response) } } @@ -415,7 +427,7 @@ func TestAdvisoryAssemblesTrustedIdentityAndScope(t *testing.T) { } var request map[string]any _ = json.Unmarshal(body, &request) - _, _ = fmt.Fprintf(w, `{"Version":"creator-advisory-v1","RequestID":%q,"ProviderBindingID":"deterministic-fake","ProviderBindingRevision":1,"AdvisoryText":"","ReferencedExcerptIDs":null,"FinishReason":"PROVIDER_UNAVAILABLE","Usage":null}`, request["RequestID"]) + _, _ = fmt.Fprintf(w, `{"Version":"creator-advisory-v1","RequestID":%q,"AdvisoryText":"","ReferencedExcerptIDs":null,"FinishReason":"PROVIDER_UNAVAILABLE","Usage":null}`, request["RequestID"]) })) defer cognitive.Close() handler, err := newHandler(core.URL, "http://continuity.invalid", "http://commit.invalid", core.Client(), cognitive.URL) diff --git a/cmd/creator-web/static/assets/console.js b/cmd/creator-web/static/assets/console.js index 9d48865..846a9db 100644 --- a/cmd/creator-web/static/assets/console.js +++ b/cmd/creator-web/static/assets/console.js @@ -196,11 +196,12 @@ async function loadAdvisoryPolicy() { } function renderAdvisoryPolicy() { - const external = advisoryPolicy.Mode === "openai-mode-a" && advisoryPolicy.ExternalEgress === true && advisoryPolicy.AcknowledgementRequired === true; + const external = advisoryPolicy.ExternalEgress === true && advisoryPolicy.AcknowledgementRequired === true; byId("advisory-egress").hidden = !external; byId("advisory-egress-ack").checked = false; byId("advisory-egress-ack").required = external; - setText("advisory-privacy", external ? "в OpenAI передаётся только текст вопроса" : "локальный детерминированный режим"); + setText("advisory-disclosure", advisoryPolicy.Disclosure, "Текст вопроса будет передан согласно активной политике данных."); + setText("advisory-privacy", external ? "внешняя передача по активной политике" : "активной внешней привязки нет"); const privacy = byId("advisory-privacy"); privacy.classList.toggle("external", external); const marker = document.createElement("i"); diff --git a/cmd/creator-web/static/index.html b/cmd/creator-web/static/index.html index 8df3ea8..c8a02e5 100644 --- a/cmd/creator-web/static/index.html +++ b/cmd/creator-web/static/index.html @@ -102,11 +102,11 @@
ВНЕШНИЙ ПРОВАЙДЕР · MODE A
-Текст этого вопроса будет отправлен в OpenAI и может храниться до 30 дней для мониторинга злоупотреблений. Контекст, идентификаторы и секреты Домового не передаются.
+ВНЕШНИЙ КОГНИТИВНЫЙ ПРОВАЙДЕР
+Текст вопроса будет передан согласно активной политике данных.