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 @@
diff --git a/contracts/capability/cognitive_registry.go b/contracts/capability/cognitive_registry.go new file mode 100644 index 0000000..d8afd58 --- /dev/null +++ b/contracts/capability/cognitive_registry.go @@ -0,0 +1,231 @@ +package capability + +import ( + "git.cin.su/m/Assistant/contracts/artifact" + "git.cin.su/m/Assistant/contracts/identity" + "git.cin.su/m/Assistant/platform/ids" +) + +type ProviderInstanceID ids.ID +type ModelDescriptorID ids.ID +type CredentialRef ids.ID +type EndpointBindingRef ids.ID +type LimitPolicyRef ids.ID +type ProviderKind string +type ProviderModelID string +type ModelFingerprint string +type QualificationState string +type ActivationState string +type DataPolicy string +type ModelModality string +type ModelCapability string +type OperationalState string +type EvaluationResult string +type EvaluationID ids.ID +type PerformanceEvidenceID ids.ID + +const ( + QualificationDiscovered QualificationState = "DISCOVERED" + QualificationProbing QualificationState = "PROBING" + QualificationEvaluating QualificationState = "EVALUATING" + QualificationValidated QualificationState = "VALIDATED" + QualificationRejected QualificationState = "REJECTED" + + ActivationInactive ActivationState = "INACTIVE" + ActivationCanary ActivationState = "CANARY" + ActivationActive ActivationState = "ACTIVE" + ActivationDeprecated ActivationState = "DEPRECATED" + ActivationRetired ActivationState = "RETIRED" + + DataPolicyLocalOnly DataPolicy = "LOCAL_ONLY" + DataPolicyExternalMessageOnly DataPolicy = "EXTERNAL_MESSAGE_ONLY" + DataPolicyExternalMinimizedContext DataPolicy = "EXTERNAL_MINIMIZED_CONTEXT" + + ModalityText ModelModality = "TEXT" + CapabilityAdvisory ModelCapability = "ADVISORY" + CapabilityUnknown ModelCapability = "UNKNOWN" + + OperationalNotObserved OperationalState = "NOT_OBSERVED" + OperationalYes OperationalState = "YES" + OperationalNo OperationalState = "NO" + + EvaluationUnknown EvaluationResult = "UNKNOWN" + EvaluationSupported EvaluationResult = "SUPPORTED" + EvaluationUnsupported EvaluationResult = "UNSUPPORTED" +) + +// ProviderInstance contains only opaque references to endpoint and credentials. +// Credential material is resolved by the cognitive runtime, never by Core. +type ProviderInstance struct { + ID ProviderInstanceID + Revision int64 + Kind ProviderKind + EndpointRef EndpointBindingRef + CredentialRef CredentialRef + Enabled bool + Qualification QualificationState + Health HealthState + ObservedAtUnixMillis int64 +} + +type ModelDescriptor struct { + ID ModelDescriptorID + Revision int64 + ProviderInstanceID ProviderInstanceID + ProviderModelID ProviderModelID + Fingerprint ModelFingerprint + MaxContextBytes int64 + Modalities []ModelModality + Capabilities []ModelCapability + Qualification QualificationState + Health HealthState + ObservedAtUnixMillis int64 +} + +type ProviderHealthObservation struct { + ProviderInstanceID ProviderInstanceID + Health HealthState + ObservedAtUnixMillis int64 + FailureClass CognitiveFailureType +} + +type ModelOperationalObservation struct { + ProviderInstanceID ProviderInstanceID + ModelDescriptorID ModelDescriptorID + Installed OperationalState + Loaded OperationalState + Invokable OperationalState + Health HealthState + ObservedAtUnixMillis int64 +} + +type CognitiveBinding struct { + ID CapabilityBindingID + Revision int64 + CapabilityID CapabilityID + OwnerPrincipalID identity.PrincipalID + ProviderInstanceID ProviderInstanceID + ProviderRevision int64 + ModelDescriptorID ModelDescriptorID + ModelRevision int64 + DataPolicy DataPolicy + Qualification QualificationState + Activation ActivationState + Health HealthState + LimitPolicyRef LimitPolicyRef +} + +type CognitiveRequirement struct { + CapabilityID CapabilityID + OwnerPrincipalID identity.PrincipalID + DataPolicy DataPolicy + RequiredModalities []ModelModality + RequiredCapabilities []ModelCapability + AllowCanary bool + InputBytes int64 + MaxOutputBytes int64 + ExcludedBindingIDs []CapabilityBindingID +} + +// ResolvedCognitiveBinding is a provider-neutral invocation snapshot selected +// by Capability. It contains references and declared metadata, never secrets. +type ResolvedCognitiveBinding struct { + Binding CognitiveBinding + Provider ProviderInstance + Model ModelDescriptor + Evaluation *EvaluatedCapability + Performance *PerformanceEvidence +} + +type CognitiveResultClass string +type CognitiveFailureType string + +type EvaluatedCapability struct { + ID EvaluationID + BindingID CapabilityBindingID + BindingRevision int64 + CapabilityID CapabilityID + Result EvaluationResult + QualityScoreMillis *int64 + CriticalFailureMillis *int64 + BenchmarkVersion string + ArtifactID artifact.ArtifactID + ObservedAtUnixMillis int64 +} + +type PerformanceEvidence struct { + ID PerformanceEvidenceID + BindingID CapabilityBindingID + BindingRevision int64 + ModelDescriptorID ModelDescriptorID + ModelRevision int64 + HardwareProfile string + Runtime string + Quantization string + ContextUnits *int64 + ThinkingEnabled *bool + GenerationTPSMillis *int64 + PromptTPSMillis *int64 + TTFTMicros *int64 + LatencyMicros *int64 + MemoryBytes *int64 + ReliabilityMillis *int64 + BenchmarkVersion string + ArtifactID artifact.ArtifactID + ObservedAtUnixMillis int64 +} + +type CognitiveDiscoveryRegistration struct { + Provider ProviderInstance + Models []ModelDescriptor + Bindings []CognitiveBinding + ProviderHealth ProviderHealthObservation + ModelAvailability []ModelOperationalObservation +} + +// CognitiveDiscoverySnapshot is provider-neutral observed inventory submitted +// to the Capability owner. The owner assigns immutable revisions and always +// registers discovered bindings as DISCOVERED/INACTIVE. +type CognitiveDiscoverySnapshot struct { + ProviderInstanceID ProviderInstanceID + ProviderKind ProviderKind + EndpointRef EndpointBindingRef + CredentialRef CredentialRef + Enabled bool + OwnerPrincipalID identity.PrincipalID + CapabilityID CapabilityID + DataPolicy DataPolicy + LimitPolicyRef LimitPolicyRef + Health HealthState + FailureClass CognitiveFailureType + ObservedAtUnixMillis int64 + Models []DiscoveredModelSnapshot +} + +type DiscoveredModelSnapshot struct { + ProviderModelID ProviderModelID + Fingerprint ModelFingerprint + MaxContextBytes int64 + Modalities []ModelModality + Capabilities []ModelCapability + Installed OperationalState + Loaded OperationalState + Invokable OperationalState +} + +type CognitiveUsageObservation struct { + ObservationID ids.ID + RequestID ids.ID + BindingID CapabilityBindingID + BindingRevision int64 + ProviderInstanceID ProviderInstanceID + ModelDescriptorID ModelDescriptorID + ObservedAtUnixMillis int64 + LatencyMillis int64 + InputUnits int64 + OutputUnits int64 + EstimatedCostMicros *int64 + ActualCostMicros *int64 + ResultClass CognitiveResultClass + FailureType CognitiveFailureType +} diff --git a/contracts/capability/types.go b/contracts/capability/types.go index d283960..65f464f 100644 --- a/contracts/capability/types.go +++ b/contracts/capability/types.go @@ -38,6 +38,7 @@ type Binding struct { type HealthState string const ( + HealthUnknown HealthState = "UNKNOWN" HealthAvailable HealthState = "AVAILABLE" HealthDegraded HealthState = "DEGRADED" HealthUnavailable HealthState = "UNAVAILABLE" diff --git a/contracts/cognitive/types.go b/contracts/cognitive/types.go index 497fa97..7fbd852 100644 --- a/contracts/cognitive/types.go +++ b/contracts/cognitive/types.go @@ -25,8 +25,16 @@ const ( FinishLengthLimit FinishReason = "LENGTH_LIMIT" FinishContentRejected FinishReason = "CONTENT_REJECTED" FinishProviderUnavailable FinishReason = "PROVIDER_UNAVAILABLE" + FinishModelUnavailable FinishReason = "MODEL_UNAVAILABLE" + FinishRateLimited FinishReason = "RATE_LIMITED" + FinishAuthenticationFailed FinishReason = "AUTHENTICATION_FAILED" + FinishBillingOrBudgetBlocked FinishReason = "BILLING_OR_BUDGET_BLOCKED" + FinishContextTooLarge FinishReason = "CONTEXT_TOO_LARGE" + FinishUnsupportedCapability FinishReason = "UNSUPPORTED_CAPABILITY" FinishTimeout FinishReason = "TIMEOUT" FinishInvalidProviderResponse FinishReason = "INVALID_PROVIDER_RESPONSE" + FinishInvalidStructuredOutput FinishReason = "INVALID_STRUCTURED_OUTPUT" + FinishDegraded FinishReason = "DEGRADED" ) type ContextExcerpt struct { @@ -52,6 +60,36 @@ type AdvisoryRequest struct { MaxOutputBytes int ProviderBindingID capability.CapabilityBindingID ProviderBindingRevision int64 + ProviderInstanceID capability.ProviderInstanceID + ModelDescriptorID capability.ModelDescriptorID + ProviderModelID capability.ProviderModelID +} + +// GatewayAdvisoryRequest describes the task and policy requirement. It cannot +// name a provider, model or binding; Capability owns that selection. +type GatewayAdvisoryRequest struct { + Version ContractVersion + RequestID RequestID + ActorID identity.ActorID + PrincipalID identity.PrincipalID + ScopeID authority.ScopeID + Message string + Context []ContextExcerpt + Locale string + MaxOutputBytes int + DataPolicy capability.DataPolicy + EgressAcknowledged bool +} + +type GatewayAdvisoryPolicyRequest struct { + PrincipalID identity.PrincipalID + DataPolicy capability.DataPolicy +} + +type GatewayAdvisoryPolicy struct { + ExternalEgress bool + AcknowledgementRequired bool + Disclosure string } type ProviderUsage struct { @@ -69,3 +107,12 @@ type AdvisoryResponse struct { FinishReason FinishReason Usage *ProviderUsage } + +type GatewayAdvisoryResponse struct { + Version ContractVersion + RequestID RequestID + AdvisoryText string + ReferencedExcerptIDs []ContextExcerptID + FinishReason FinishReason + Usage *ProviderUsage +} diff --git a/database/bootstrap/001_roles_and_schemas.sql b/database/bootstrap/001_roles_and_schemas.sql index 1bda888..af615f6 100644 --- a/database/bootstrap/001_roles_and_schemas.sql +++ b/database/bootstrap/001_roles_and_schemas.sql @@ -185,6 +185,90 @@ CREATE TABLE IF NOT EXISTS capability.notification_binding_heads ( binding_id TEXT NOT NULL, revision BIGINT NOT NULL, PRIMARY KEY (capability_id, owner_principal_id), CHECK (revision > 0) ); +CREATE TABLE IF NOT EXISTS capability.cognitive_provider_instances ( + provider_instance_id TEXT NOT NULL, revision BIGINT NOT NULL, provider_kind TEXT NOT NULL, + endpoint_ref TEXT NOT NULL, credential_ref TEXT, enabled BOOLEAN NOT NULL, + qualification TEXT NOT NULL, health TEXT NOT NULL, observed_at_unix_millis BIGINT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (provider_instance_id, revision), CHECK (revision > 0), + CHECK (qualification IN ('DISCOVERED','PROBING','EVALUATING','VALIDATED','REJECTED')), + CHECK (health IN ('UNKNOWN','AVAILABLE','DEGRADED','UNAVAILABLE')), CHECK (observed_at_unix_millis > 0) +); +CREATE TABLE IF NOT EXISTS capability.cognitive_model_descriptors ( + model_descriptor_id TEXT NOT NULL, revision BIGINT NOT NULL, provider_instance_id TEXT NOT NULL, + provider_model_id TEXT NOT NULL, fingerprint TEXT NOT NULL, max_context_bytes BIGINT NOT NULL, + modalities TEXT[] NOT NULL, capabilities TEXT[] NOT NULL, qualification TEXT NOT NULL, + health TEXT NOT NULL, observed_at_unix_millis BIGINT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (model_descriptor_id, revision), CHECK (revision > 0), CHECK (max_context_bytes >= 0), + CHECK (cardinality(modalities) > 0), CHECK (cardinality(capabilities) > 0), + CHECK (qualification IN ('DISCOVERED','PROBING','EVALUATING','VALIDATED','REJECTED')), + CHECK (health IN ('UNKNOWN','AVAILABLE','DEGRADED','UNAVAILABLE')), CHECK (observed_at_unix_millis > 0) +); +CREATE TABLE IF NOT EXISTS capability.cognitive_bindings ( + binding_id TEXT NOT NULL, revision BIGINT NOT NULL, capability_id TEXT NOT NULL, + owner_principal_id TEXT NOT NULL, provider_instance_id TEXT NOT NULL, provider_revision BIGINT NOT NULL, + model_descriptor_id TEXT NOT NULL, model_revision BIGINT NOT NULL, + data_policy TEXT NOT NULL, qualification TEXT NOT NULL, activation TEXT NOT NULL, + health TEXT NOT NULL, limit_policy_ref TEXT NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (binding_id, revision), CHECK (revision > 0), CHECK (provider_revision > 0), CHECK (model_revision > 0), + CHECK (data_policy IN ('LOCAL_ONLY','EXTERNAL_MESSAGE_ONLY','EXTERNAL_MINIMIZED_CONTEXT')), + CHECK (qualification IN ('DISCOVERED','PROBING','EVALUATING','VALIDATED','REJECTED')), + CHECK (activation IN ('INACTIVE','CANARY','ACTIVE','DEPRECATED','RETIRED')), + CHECK (health IN ('UNKNOWN','AVAILABLE','DEGRADED','UNAVAILABLE')) +); +CREATE TABLE IF NOT EXISTS capability.cognitive_binding_heads ( + capability_id TEXT NOT NULL, owner_principal_id TEXT NOT NULL, binding_id TEXT NOT NULL, + revision BIGINT NOT NULL, PRIMARY KEY (capability_id, owner_principal_id), CHECK (revision > 0) +); +CREATE TABLE IF NOT EXISTS capability.cognitive_usage_observations ( + observation_id TEXT PRIMARY KEY, request_id TEXT NOT NULL, binding_id TEXT NOT NULL, + binding_revision BIGINT NOT NULL, provider_instance_id TEXT NOT NULL, model_descriptor_id TEXT NOT NULL, + observed_at_unix_millis BIGINT NOT NULL, latency_millis BIGINT NOT NULL, + input_units BIGINT NOT NULL, output_units BIGINT NOT NULL, estimated_cost_micros BIGINT, + actual_cost_micros BIGINT, result_class TEXT NOT NULL, failure_type TEXT NOT NULL DEFAULT '', + recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (binding_revision > 0), CHECK (observed_at_unix_millis > 0), CHECK (latency_millis >= 0), + CHECK (input_units >= 0), CHECK (output_units >= 0), + CHECK (estimated_cost_micros IS NULL OR estimated_cost_micros >= 0), + CHECK (actual_cost_micros IS NULL OR actual_cost_micros >= 0) +); +CREATE TABLE IF NOT EXISTS capability.cognitive_provider_health ( + provider_instance_id TEXT PRIMARY KEY, health TEXT NOT NULL, observed_at_unix_millis BIGINT NOT NULL, + failure_class TEXT NOT NULL DEFAULT '', recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (health IN ('UNKNOWN','AVAILABLE','DEGRADED','UNAVAILABLE')), CHECK (observed_at_unix_millis > 0) +); +CREATE TABLE IF NOT EXISTS capability.cognitive_model_availability ( + provider_instance_id TEXT NOT NULL, model_descriptor_id TEXT NOT NULL, + installed TEXT NOT NULL, loaded TEXT NOT NULL, invokable TEXT NOT NULL, health TEXT NOT NULL, + observed_at_unix_millis BIGINT NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (provider_instance_id, model_descriptor_id), + CHECK (installed IN ('NOT_OBSERVED','YES','NO')), CHECK (loaded IN ('NOT_OBSERVED','YES','NO')), + CHECK (invokable IN ('NOT_OBSERVED','YES','NO')), CHECK (health IN ('UNKNOWN','AVAILABLE','DEGRADED','UNAVAILABLE')), + CHECK (observed_at_unix_millis > 0) +); +CREATE TABLE IF NOT EXISTS capability.cognitive_evaluated_capabilities ( + evaluation_id TEXT PRIMARY KEY, binding_id TEXT NOT NULL, binding_revision BIGINT NOT NULL, + capability_id TEXT NOT NULL, result TEXT NOT NULL, quality_score_millis BIGINT, + critical_failure_millis BIGINT, benchmark_version TEXT NOT NULL, artifact_id TEXT NOT NULL, + observed_at_unix_millis BIGINT NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (binding_revision > 0), CHECK (result IN ('UNKNOWN','SUPPORTED','UNSUPPORTED')), + CHECK (quality_score_millis IS NULL OR quality_score_millis >= 0), + CHECK (critical_failure_millis IS NULL OR critical_failure_millis >= 0), CHECK (observed_at_unix_millis > 0) +); +CREATE TABLE IF NOT EXISTS capability.cognitive_performance_evidence ( + evidence_id TEXT PRIMARY KEY, binding_id TEXT NOT NULL, binding_revision BIGINT NOT NULL, + model_descriptor_id TEXT NOT NULL, model_revision BIGINT NOT NULL, hardware_profile TEXT NOT NULL, + runtime TEXT NOT NULL, quantization TEXT NOT NULL, context_units BIGINT, thinking_enabled BOOLEAN, + generation_tps_millis BIGINT, prompt_tps_millis BIGINT, ttft_micros BIGINT, latency_micros BIGINT, + memory_bytes BIGINT, reliability_millis BIGINT, benchmark_version TEXT NOT NULL, artifact_id TEXT NOT NULL, + observed_at_unix_millis BIGINT NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (binding_revision > 0), CHECK (model_revision > 0), + CHECK (context_units IS NULL OR context_units >= 0), CHECK (generation_tps_millis IS NULL OR generation_tps_millis >= 0), + CHECK (prompt_tps_millis IS NULL OR prompt_tps_millis >= 0), CHECK (ttft_micros IS NULL OR ttft_micros >= 0), + CHECK (latency_micros IS NULL OR latency_micros >= 0), CHECK (memory_bytes IS NULL OR memory_bytes >= 0), + CHECK (reliability_millis IS NULL OR reliability_millis >= 0), CHECK (observed_at_unix_millis > 0) +); RESET ROLE; SET ROLE continuity_role; diff --git a/database/migrations/012_capability_cognitive_registry.sql b/database/migrations/012_capability_cognitive_registry.sql new file mode 100644 index 0000000..75d2adf --- /dev/null +++ b/database/migrations/012_capability_cognitive_registry.sql @@ -0,0 +1,61 @@ +BEGIN; +SET ROLE capability_role; + +CREATE TABLE IF NOT EXISTS capability.cognitive_provider_instances ( + provider_instance_id TEXT NOT NULL, revision BIGINT NOT NULL, provider_kind TEXT NOT NULL, + endpoint_ref TEXT NOT NULL, credential_ref TEXT NOT NULL, enabled BOOLEAN NOT NULL, + qualification TEXT NOT NULL, health TEXT NOT NULL, observed_at_unix_millis BIGINT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (provider_instance_id, revision), CHECK (revision > 0), + CHECK (qualification IN ('DISCOVERED','PROBING','EVALUATING','VALIDATED','REJECTED')), + CHECK (health IN ('UNKNOWN','AVAILABLE','DEGRADED','UNAVAILABLE')), + CHECK (observed_at_unix_millis > 0) +); + +CREATE TABLE IF NOT EXISTS capability.cognitive_model_descriptors ( + model_descriptor_id TEXT NOT NULL, revision BIGINT NOT NULL, provider_instance_id TEXT NOT NULL, + provider_model_id TEXT NOT NULL, fingerprint TEXT NOT NULL, max_context_bytes BIGINT NOT NULL, + modalities TEXT[] NOT NULL, capabilities TEXT[] NOT NULL, qualification TEXT NOT NULL, + health TEXT NOT NULL, observed_at_unix_millis BIGINT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (model_descriptor_id, revision), CHECK (revision > 0), CHECK (max_context_bytes > 0), + CHECK (cardinality(modalities) > 0), CHECK (cardinality(capabilities) > 0), + CHECK (qualification IN ('DISCOVERED','PROBING','EVALUATING','VALIDATED','REJECTED')), + CHECK (health IN ('UNKNOWN','AVAILABLE','DEGRADED','UNAVAILABLE')), + CHECK (observed_at_unix_millis > 0) +); + +CREATE TABLE IF NOT EXISTS capability.cognitive_bindings ( + binding_id TEXT NOT NULL, revision BIGINT NOT NULL, capability_id TEXT NOT NULL, + owner_principal_id TEXT NOT NULL, provider_instance_id TEXT NOT NULL, provider_revision BIGINT NOT NULL, + model_descriptor_id TEXT NOT NULL, model_revision BIGINT NOT NULL, + data_policy TEXT NOT NULL, qualification TEXT NOT NULL, activation TEXT NOT NULL, + health TEXT NOT NULL, limit_policy_ref TEXT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (binding_id, revision), CHECK (revision > 0), CHECK (provider_revision > 0), CHECK (model_revision > 0), + CHECK (data_policy IN ('LOCAL_ONLY','EXTERNAL_MESSAGE_ONLY','EXTERNAL_MINIMIZED_CONTEXT')), + CHECK (qualification IN ('DISCOVERED','PROBING','EVALUATING','VALIDATED','REJECTED')), + CHECK (activation IN ('INACTIVE','CANARY','ACTIVE','DEPRECATED','RETIRED')), + CHECK (health IN ('UNKNOWN','AVAILABLE','DEGRADED','UNAVAILABLE')) +); + +CREATE TABLE IF NOT EXISTS capability.cognitive_binding_heads ( + capability_id TEXT NOT NULL, owner_principal_id TEXT NOT NULL, binding_id TEXT NOT NULL, + revision BIGINT NOT NULL, PRIMARY KEY (capability_id, owner_principal_id), CHECK (revision > 0) +); + +CREATE TABLE IF NOT EXISTS capability.cognitive_usage_observations ( + observation_id TEXT PRIMARY KEY, request_id TEXT NOT NULL, binding_id TEXT NOT NULL, + binding_revision BIGINT NOT NULL, provider_instance_id TEXT NOT NULL, model_descriptor_id TEXT NOT NULL, + observed_at_unix_millis BIGINT NOT NULL, latency_millis BIGINT NOT NULL, + input_units BIGINT NOT NULL, output_units BIGINT NOT NULL, estimated_cost_micros BIGINT, + actual_cost_micros BIGINT, result_class TEXT NOT NULL, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (binding_revision > 0), CHECK (observed_at_unix_millis > 0), CHECK (latency_millis >= 0), + CHECK (input_units >= 0), CHECK (output_units >= 0), + CHECK (estimated_cost_micros IS NULL OR estimated_cost_micros >= 0), + CHECK (actual_cost_micros IS NULL OR actual_cost_micros >= 0) +); + +RESET ROLE; +COMMIT; diff --git a/database/migrations/013_capability_cognitive_discovery_evidence.sql b/database/migrations/013_capability_cognitive_discovery_evidence.sql new file mode 100644 index 0000000..93f0d7e --- /dev/null +++ b/database/migrations/013_capability_cognitive_discovery_evidence.sql @@ -0,0 +1,51 @@ +BEGIN; +SET ROLE capability_role; + +ALTER TABLE capability.cognitive_provider_instances ALTER COLUMN credential_ref DROP NOT NULL; +ALTER TABLE capability.cognitive_model_descriptors DROP CONSTRAINT IF EXISTS cognitive_model_descriptors_max_context_bytes_check; +ALTER TABLE capability.cognitive_model_descriptors ADD CONSTRAINT cognitive_model_descriptors_max_context_bytes_check CHECK (max_context_bytes >= 0); + +CREATE TABLE IF NOT EXISTS capability.cognitive_provider_health ( + provider_instance_id TEXT PRIMARY KEY, health TEXT NOT NULL, observed_at_unix_millis BIGINT NOT NULL, + failure_class TEXT NOT NULL DEFAULT '', recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (health IN ('UNKNOWN','AVAILABLE','DEGRADED','UNAVAILABLE')), CHECK (observed_at_unix_millis > 0) +); + +CREATE TABLE IF NOT EXISTS capability.cognitive_model_availability ( + provider_instance_id TEXT NOT NULL, model_descriptor_id TEXT NOT NULL, + installed TEXT NOT NULL, loaded TEXT NOT NULL, invokable TEXT NOT NULL, health TEXT NOT NULL, + observed_at_unix_millis BIGINT NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (provider_instance_id, model_descriptor_id), + CHECK (installed IN ('NOT_OBSERVED','YES','NO')), CHECK (loaded IN ('NOT_OBSERVED','YES','NO')), + CHECK (invokable IN ('NOT_OBSERVED','YES','NO')), CHECK (health IN ('UNKNOWN','AVAILABLE','DEGRADED','UNAVAILABLE')), + CHECK (observed_at_unix_millis > 0) +); + +CREATE TABLE IF NOT EXISTS capability.cognitive_evaluated_capabilities ( + evaluation_id TEXT PRIMARY KEY, binding_id TEXT NOT NULL, binding_revision BIGINT NOT NULL, + capability_id TEXT NOT NULL, result TEXT NOT NULL, quality_score_millis BIGINT, + critical_failure_millis BIGINT, benchmark_version TEXT NOT NULL, artifact_id TEXT NOT NULL, + observed_at_unix_millis BIGINT NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (binding_revision > 0), CHECK (result IN ('UNKNOWN','SUPPORTED','UNSUPPORTED')), + CHECK (quality_score_millis IS NULL OR quality_score_millis >= 0), + CHECK (critical_failure_millis IS NULL OR critical_failure_millis >= 0), CHECK (observed_at_unix_millis > 0) +); + +CREATE TABLE IF NOT EXISTS capability.cognitive_performance_evidence ( + evidence_id TEXT PRIMARY KEY, binding_id TEXT NOT NULL, binding_revision BIGINT NOT NULL, + model_descriptor_id TEXT NOT NULL, model_revision BIGINT NOT NULL, hardware_profile TEXT NOT NULL, + runtime TEXT NOT NULL, quantization TEXT NOT NULL, context_units BIGINT, thinking_enabled BOOLEAN, + generation_tps_millis BIGINT, prompt_tps_millis BIGINT, ttft_micros BIGINT, latency_micros BIGINT, + memory_bytes BIGINT, reliability_millis BIGINT, benchmark_version TEXT NOT NULL, artifact_id TEXT NOT NULL, + observed_at_unix_millis BIGINT NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (binding_revision > 0), CHECK (model_revision > 0), + CHECK (context_units IS NULL OR context_units >= 0), CHECK (generation_tps_millis IS NULL OR generation_tps_millis >= 0), + CHECK (prompt_tps_millis IS NULL OR prompt_tps_millis >= 0), CHECK (ttft_micros IS NULL OR ttft_micros >= 0), + CHECK (latency_micros IS NULL OR latency_micros >= 0), CHECK (memory_bytes IS NULL OR memory_bytes >= 0), + CHECK (reliability_millis IS NULL OR reliability_millis >= 0), CHECK (observed_at_unix_millis > 0) +); + +ALTER TABLE capability.cognitive_usage_observations ADD COLUMN IF NOT EXISTS failure_type TEXT NOT NULL DEFAULT ''; + +RESET ROLE; +COMMIT; diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 79c0a2a..c2217d8 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -95,7 +95,12 @@ services: context: .. dockerfile: deploy/images/runtime.Dockerfile args: { TARGET: cognitive-worker } - environment: { LISTEN_ADDR: ":8083" } + environment: + LISTEN_ADDR: ":8083" + CORE_RUNTIME_URL: http://core-runtime:8080 + LOCAL_CPU_PROVIDER_URL: http://docker.cin.su:18082/v1 + LOCAL_GPU_PROVIDER_URL: http://192.168.220.91:11434 + COGNITIVE_OWNER_PRINCIPAL_ID: domovoy-creator-principal ports: ["19083:8083"] creator-web: diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index cfed193..7c5eb6e 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -112,7 +112,13 @@ services: cognitive-worker: build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: cognitive-worker, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } } restart: unless-stopped - environment: { LISTEN_ADDR: ":8083" } + environment: + LISTEN_ADDR: ":8083" + CORE_RUNTIME_URL: http://core-runtime:8080 + LOCAL_CPU_PROVIDER_URL: http://docker.cin.su:18082/v1 + LOCAL_GPU_PROVIDER_URL: http://192.168.220.91:11434 + COGNITIVE_OWNER_PRINCIPAL_ID: domovoy-creator-principal + depends_on: [core-runtime] creator-web: build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: creator-web, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } } diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 25e814f..4e49f9a 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000054-promote-creator-advisory-routing.json", - "task_id": "DEV-000054", + "task_file": "dev/tasks/DEV-000056-local-cognitive-providers.json", + "task_id": "DEV-000056", "task_revision": 1 } diff --git a/dev/tasks/DEV-000055-cognitive-registry-foundation.json b/dev/tasks/DEV-000055-cognitive-registry-foundation.json new file mode 100644 index 0000000..2f780d0 --- /dev/null +++ b/dev/tasks/DEV-000055-cognitive-registry-foundation.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000055", + "task_revision": 1, + "architecture_revision": "ADR-0007", + "base_repo_revision": "9e99a04c767833476df09f5e65016a5ad2fc47d9", + "risk_tier": "E", + "intent": "Implement the first bounded Cognitive Provider/Model Registry foundation under Capability ownership: typed provider, model and cognitive binding state; deterministic gateway selection; provider-neutral Creator routing; expanded failures; credential references; and usage observations, without adaptive routing, Ollama discovery, production activation or new authority.", + "requirement_ids": ["INV-OWN-001", "INV-EPI-001", "ADR-0006", "ADR-0007", "MODEL-ARCH-AUDIT-STAGE-1"], + "allowed_change_scope": {"modules": ["capability"], "paths": ["contracts/capability/**", "contracts/cognitive/**", "modules/capability/**", "adapters/postgres/**", "adapters/models/**", "database/bootstrap/**", "database/migrations/**", "cmd/core-runtime/**", "cmd/cognitive-worker/**", "cmd/creator-web/**", "tests/cognitive_registry/**", "tests/cognitive_advisory/**", "docs/PROJECT_STATUS.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000055-cognitive-registry-foundation.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "ci/**", ".gitea/**", "modules/identity/**", "modules/authority/**", "modules/situation/**", "modules/work/**", "modules/continuity/**", "modules/commit/**", "modules/epistemics/**", "modules/artifact/**", "deploy/**", "security/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0}, + "contracts": {"consume": ["CreatorSession", "Situation FocusBinding", "Capability owner repository", "creator-advisory-v1"], "may_change": ["contracts.capability cognitive registry types", "contracts.cognitive routing/failure/usage types"]}, + "semantic_owner": "capability", + "canonical_state": ["ProviderInstance", "ModelDescriptor", "CognitiveBinding", "CognitiveUsageObservation"], + "state_transitions": ["provider/model qualification transitions remain independent from activation and health", "discovery never activates", "binding invocation requires current ACTIVE or explicitly eligible CANARY revision", "retired/deprecated/rejected bindings are not invocable"], + "side_effects_and_privileges": ["owner-local capability persistence only", "provider invocation remains non-canonical advisory", "no production credential, egress activation, automatic installation or external mutation"], + "persistence_and_migrations": ["typed capability.provider_instances", "typed capability.model_descriptors", "typed capability.cognitive_bindings and active heads", "prompt-free capability.cognitive_usage_observations", "capability_role remains sole writer"], + "failure_retry_restart": ["typed provider/model/rate/auth/budget/context/capability/timeout/refusal/invalid/degraded failures", "no automatic retry", "no active binding returns provider unavailable while deterministic Assistant remains functional", "registry persists independently of cognitive worker restart"], + "assumptions": ["Capability ownership of registry/bindings is explicit in Core Semantic Model and Architecture Registry", "initial selection is deterministic explicit/default policy", "OpenAI remains disabled in production", "Ollama discovery and adaptive optimization are later tasks", "docker-test remains prohibited"], + "acceptance_scenarios": ["Creator Web contains no OpenAI, Ollama or concrete model identifiers", "discovered but inactive provider/model cannot be invoked", "current active binding can be invoked", "retired or stale binding cannot be invoked", "rate limited remains rate limited", "no cognitive bindings preserves deterministic Assistant behavior", "persisted registry types expose only CredentialRef and never credential plaintext", "qualification activation and health are independent typed state"], + "required_tests": ["unit", "contract", "gateway adversarial", "migration and DB ACL", "LLM-free regression", "go test ./...", "go vet ./...", "architecture sentinel", "exact diff scope", "CI"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"] +} diff --git a/dev/tasks/DEV-000056-local-cognitive-providers.json b/dev/tasks/DEV-000056-local-cognitive-providers.json new file mode 100644 index 0000000..b1c25e1 --- /dev/null +++ b/dev/tasks/DEV-000056-local-cognitive-providers.json @@ -0,0 +1,26 @@ +{ + "task_id": "DEV-000056", + "task_revision": 1, + "architecture_revision": "ADR-0008", + "base_repo_revision": "9e99a04c767833476df09f5e65016a5ad2fc47d9", + "risk_tier": "E", + "intent": "Extend the provider-neutral Cognitive Registry foundation with bounded evaluation evidence, deterministic multi-binding routing, read-only discovery and adapters for the real unauthenticated local CPU OpenAI-compatible endpoint and opportunistic Ollama endpoint, while registering all discoveries as INACTIVE and preserving full LLM-free production behavior.", + "requirement_ids": ["INV-OWN-001", "INV-EPI-001", "ADR-0006", "ADR-0007", "ADR-0008", "LOCAL-COGNITIVE-PROVIDERS-STAGE-2"], + "allowed_change_scope": { + "modules": ["capability"], + "paths": ["architecture/adrs/ADR-0008-cognitive-evaluation-ownership.md", "contracts/capability/**", "contracts/cognitive/**", "modules/capability/**", "adapters/postgres/**", "adapters/models/**", "database/bootstrap/**", "database/migrations/**", "cmd/core-runtime/**", "cmd/cognitive-worker/**", "cmd/creator-web/**", "tests/cognitive_registry/**", "tests/cognitive_providers/**", "tests/cognitive_router/**", "tests/cognitive_advisory/**", "docs/PROJECT_STATUS.md", "docs/evidence/**", "deploy/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000055-cognitive-registry-foundation.json", "dev/tasks/DEV-000056-local-cognitive-providers.json"] + }, + "forbidden_change_scope": {"paths": ["architecture/ARCHITECTURE_CONSTITUTION.md", "architecture/architecture-registry.yaml", "architecture/architecture-registry.json", "ci/**", ".gitea/**", "cmd/devctl/**", "modules/identity/**", "modules/authority/**", "modules/situation/**", "modules/work/**", "modules/continuity/**", "modules/commit/**", "security/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_provider_adapters": 2}, + "contracts": {"consume": ["CreatorSession", "Situation FocusBinding", "contracts.artifact ArtifactID", "Capability cognitive registry", "creator-advisory-v1"], "may_change": ["contracts.capability cognitive evaluation, discovery, health and routing types", "contracts.cognitive provider-neutral routing and usage types"]}, + "semantic_owner": "capability", + "canonical_state": ["ProviderInstance revisions", "ModelDescriptor revisions", "CognitiveBinding revisions", "EvaluatedCapability", "PerformanceEvidenceReference", "CognitiveUsageObservation"], + "state_transitions": ["discovery appends DISCOVERED and INACTIVE records only", "model digest change creates a new immutable descriptor revision", "health observations may move UNKNOWN AVAILABLE DEGRADED UNAVAILABLE without changing activation", "only explicit later promotion may move EVALUATING VALIDATED CANARY ACTIVE"], + "side_effects_and_privileges": ["read-only HTTP probes and inventory discovery against the two named local endpoints", "owner-local Capability registration through Core", "no model download deletion activation or paid OpenAI request", "no Commit Executor credential or canonical mutation authority for models"], + "persistence_and_migrations": ["Capability-owned typed discovery and evaluation tables", "ArtifactRef for immutable benchmark provenance", "no cross-owner FK JOIN write", "capability_role remains sole database writer"], + "failure_retry_restart": ["bounded HTTP timeout and no automatic retry loops", "provider loss maps to typed cognitive failure", "discovery is idempotent", "startup never depends on either provider", "no active binding preserves deterministic behavior"], + "assumptions": ["DEV-000055 candidate changes are the immediate foundation for this task", "the CPU endpoint is intentionally unauthenticated", "the Ollama endpoint is opportunistic", "docker-test remains prohibited", "all discovered local models remain INACTIVE"], + "acceptance_scenarios": ["A-T from the attached local cognitive provider task", "Creator Web remains free of provider and model identifiers", "OpenAI remains credential-free and inactive", "GPU benchmark evidence is imported only through a typed artifact-referenced boundary"], + "required_tests": ["unit", "provider adapter fake transport", "live read-only discovery", "router", "health loss and recovery", "idempotency", "DB ownership", "LLM-free regression", "go test ./...", "go vet ./...", "architecture sentinel", "exact diff scope"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"] +} diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 27a3ec5..4d1521d 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -1,6 +1,15 @@ # Assistant project status -Audit date: 2026-08-16. Active implementation: DEV-000053 revision 1. +Audit date: 2026-08-17. Active implementation: DEV-000056 revision 1. + +## Current milestone + +**Provider-neutral Cognitive Architecture + Local AI integration.** + +- Docker production baseline at `9e99a04c…` is completed and healthy. +- The OpenAI integration is a bootstrap adapter only. Real OpenAI credentials and production traffic are intentionally deferred. +- The current milestone integrates one normally available local CPU provider and one opportunistic local Ollama/GPU provider through typed discovery, health and routing boundaries. +- Discovery never activates a provider, model or binding. Until a later benchmark and explicit promotion task, all discovered bindings remain `INACTIVE` and production remains fully LLM-free. ## Delivered @@ -20,7 +29,7 @@ Audit date: 2026-08-16. Active implementation: DEV-000053 revision 1. ## Remaining work requiring a new task decision -1. **Cognitive provider:** the cognitive worker intentionally remains the deterministic fake. Selecting a real model/provider, data policy and allowed proposal contract requires a separate capability/architecture decision; it must not gain canonical mutation authority. +1. **Cognitive qualification and promotion:** DEV-000056 discovers and registers local providers/models without activation. A later benchmark decision must establish demonstrated capabilities and explicitly promote exact binding revisions through `EVALUATING → VALIDATED → CANARY → ACTIVE`. 2. **Product breadth:** notification delivery is the only accepted real external operation. Any additional operation needs its own proposal, authority model, idempotency/reconciliation semantics and Capability binding. 3. **Artifact and Epistemics product behavior:** their ownership boundaries exist, but no product task has yet specified durable user-facing workflows for them. diff --git a/docs/evidence/gpu-sustained-benchmark-2026-08-17-v1.json b/docs/evidence/gpu-sustained-benchmark-2026-08-17-v1.json new file mode 100644 index 0000000..bad0a2f --- /dev/null +++ b/docs/evidence/gpu-sustained-benchmark-2026-08-17-v1.json @@ -0,0 +1,39 @@ +{ + "artifact_id": "gpu-sustained-benchmark-2026-08-17-v1", + "artifact_version": 1, + "source": "user-provided sustained benchmark", + "observed_at": "2026-08-17T00:00:00+03:00", + "hardware_profile": "NVIDIA RTX 4090 24 GB VRAM", + "runtime": "Ollama", + "settings": { + "temperature": 0, + "seed": 42, + "context_units": 4096, + "thinking_enabled": false + }, + "results": [ + { + "provider_model_id": "qwen3:30b", + "parameter_size": "30.5B", + "quantization": "Q4_K_M", + "memory_bytes": 18870000000, + "generation_tps": [ + {"output_units": 128, "median": 224.99}, + {"output_units": 512, "median": 219.40}, + {"output_units": 1024, "median": 208.43} + ] + }, + { + "provider_model_id": "gemma4:26b", + "parameter_size": "25.8B", + "quantization": "Q4_K_M", + "memory_bytes": 17350000000, + "generation_tps": [ + {"output_units": 128, "median": 168.10}, + {"output_units": 512, "median": 165.54}, + {"output_units": 1024, "median": 164.12} + ] + } + ], + "interpretation": "performance evidence only; no quality or activation conclusion" +} diff --git a/modules/capability/domain/cognitive_registry.go b/modules/capability/domain/cognitive_registry.go new file mode 100644 index 0000000..5e34dfe --- /dev/null +++ b/modules/capability/domain/cognitive_registry.go @@ -0,0 +1,201 @@ +package domain + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "slices" + "sort" + + "git.cin.su/m/Assistant/contracts/capability" +) + +var ErrInvalidCognitiveRegistryRecord = errors.New("invalid cognitive registry record") +var ErrNoInvocableCognitiveBinding = errors.New("no invocable cognitive binding") + +func BuildDiscoveryRegistration(snapshot capability.CognitiveDiscoverySnapshot) (capability.CognitiveDiscoveryRegistration, error) { + if snapshot.ProviderInstanceID == "" || snapshot.ProviderKind == "" || snapshot.EndpointRef == "" || snapshot.OwnerPrincipalID == "" || snapshot.CapabilityID == "" || snapshot.LimitPolicyRef == "" || snapshot.ObservedAtUnixMillis < 1 || !validHealth(snapshot.Health) { + return capability.CognitiveDiscoveryRegistration{}, ErrInvalidCognitiveRegistryRecord + } + providerRevision := stableRevision(string(snapshot.ProviderKind), string(snapshot.EndpointRef), string(snapshot.CredentialRef), fmt.Sprint(snapshot.Enabled)) + provider := capability.ProviderInstance{ID: snapshot.ProviderInstanceID, Revision: providerRevision, Kind: snapshot.ProviderKind, EndpointRef: snapshot.EndpointRef, CredentialRef: snapshot.CredentialRef, Enabled: snapshot.Enabled, Qualification: capability.QualificationDiscovered, Health: capability.HealthUnknown, ObservedAtUnixMillis: snapshot.ObservedAtUnixMillis} + registration := capability.CognitiveDiscoveryRegistration{Provider: provider, ProviderHealth: capability.ProviderHealthObservation{ProviderInstanceID: provider.ID, Health: snapshot.Health, ObservedAtUnixMillis: snapshot.ObservedAtUnixMillis, FailureClass: snapshot.FailureClass}} + for _, discovered := range snapshot.Models { + if discovered.ProviderModelID == "" || discovered.MaxContextBytes < 0 || len(discovered.Modalities) == 0 || len(discovered.Capabilities) == 0 || !validOperational(discovered.Installed) || !validOperational(discovered.Loaded) || !validOperational(discovered.Invokable) { + return capability.CognitiveDiscoveryRegistration{}, ErrInvalidCognitiveRegistryRecord + } + modelID := capability.ModelDescriptorID(stableID("model", string(snapshot.ProviderInstanceID), string(discovered.ProviderModelID))) + modelRevision := stableRevision(string(discovered.Fingerprint), fmt.Sprint(discovered.MaxContextBytes), fmt.Sprint(discovered.Modalities), fmt.Sprint(discovered.Capabilities)) + model := capability.ModelDescriptor{ID: modelID, Revision: modelRevision, ProviderInstanceID: provider.ID, ProviderModelID: discovered.ProviderModelID, Fingerprint: discovered.Fingerprint, MaxContextBytes: discovered.MaxContextBytes, Modalities: append([]capability.ModelModality(nil), discovered.Modalities...), Capabilities: append([]capability.ModelCapability(nil), discovered.Capabilities...), Qualification: capability.QualificationDiscovered, Health: capability.HealthUnknown, ObservedAtUnixMillis: snapshot.ObservedAtUnixMillis} + bindingID := capability.CapabilityBindingID(stableID("binding", string(snapshot.OwnerPrincipalID), string(snapshot.CapabilityID), string(provider.ID), string(model.ID))) + bindingRevision := stableRevision(fmt.Sprint(provider.Revision), fmt.Sprint(model.Revision), string(snapshot.DataPolicy), string(snapshot.LimitPolicyRef)) + binding := capability.CognitiveBinding{ID: bindingID, Revision: bindingRevision, CapabilityID: snapshot.CapabilityID, OwnerPrincipalID: snapshot.OwnerPrincipalID, ProviderInstanceID: provider.ID, ProviderRevision: provider.Revision, ModelDescriptorID: model.ID, ModelRevision: model.Revision, DataPolicy: snapshot.DataPolicy, Qualification: capability.QualificationDiscovered, Activation: capability.ActivationInactive, Health: capability.HealthUnknown, LimitPolicyRef: snapshot.LimitPolicyRef} + registration.Models = append(registration.Models, model) + registration.Bindings = append(registration.Bindings, binding) + registration.ModelAvailability = append(registration.ModelAvailability, capability.ModelOperationalObservation{ProviderInstanceID: provider.ID, ModelDescriptorID: model.ID, Installed: discovered.Installed, Loaded: discovered.Loaded, Invokable: discovered.Invokable, Health: snapshot.Health, ObservedAtUnixMillis: snapshot.ObservedAtUnixMillis}) + } + return registration, nil +} + +func stableID(kind string, parts ...string) string { + hash := sha256.New() + _, _ = hash.Write([]byte(kind)) + for _, part := range parts { + _, _ = hash.Write([]byte{0}) + _, _ = hash.Write([]byte(part)) + } + return kind + "-" + hex.EncodeToString(hash.Sum(nil))[:24] +} + +func stableRevision(parts ...string) int64 { + hash := sha256.New() + for _, part := range parts { + _, _ = hash.Write([]byte{0}) + _, _ = hash.Write([]byte(part)) + } + return int64(binary.BigEndian.Uint64(hash.Sum(nil)[:8])&0x3fffffffffffffff) + 1 +} + +func ValidateProviderInstance(value capability.ProviderInstance) error { + if value.ID == "" || value.Revision < 1 || value.Kind == "" || value.EndpointRef == "" || !validQualification(value.Qualification) || !validHealth(value.Health) || value.ObservedAtUnixMillis < 1 { + return ErrInvalidCognitiveRegistryRecord + } + return nil +} + +func ValidateModelDescriptor(value capability.ModelDescriptor) error { + if value.ID == "" || value.Revision < 1 || value.ProviderInstanceID == "" || value.ProviderModelID == "" || value.MaxContextBytes < 0 || len(value.Modalities) == 0 || len(value.Capabilities) == 0 || !validQualification(value.Qualification) || !validHealth(value.Health) || value.ObservedAtUnixMillis < 1 { + return ErrInvalidCognitiveRegistryRecord + } + return nil +} + +func ValidateCognitiveBinding(value capability.CognitiveBinding) error { + if value.ID == "" || value.Revision < 1 || value.CapabilityID == "" || value.OwnerPrincipalID == "" || value.ProviderInstanceID == "" || value.ProviderRevision < 1 || value.ModelDescriptorID == "" || value.ModelRevision < 1 || value.LimitPolicyRef == "" || !validQualification(value.Qualification) || !validActivation(value.Activation) || !validHealth(value.Health) { + return ErrInvalidCognitiveRegistryRecord + } + switch value.DataPolicy { + case capability.DataPolicyLocalOnly, capability.DataPolicyExternalMessageOnly, capability.DataPolicyExternalMinimizedContext: + return nil + default: + return ErrInvalidCognitiveRegistryRecord + } +} + +func SelectCognitiveBinding(requirement capability.CognitiveRequirement, candidates []capability.ResolvedCognitiveBinding) (capability.ResolvedCognitiveBinding, error) { + eligible := make([]capability.ResolvedCognitiveBinding, 0, len(candidates)) + for _, candidate := range candidates { + binding, provider, model := candidate.Binding, candidate.Provider, candidate.Model + if slices.Contains(requirement.ExcludedBindingIDs, binding.ID) { + continue + } + if ValidateCognitiveBinding(binding) != nil || ValidateProviderInstance(provider) != nil || ValidateModelDescriptor(model) != nil { + continue + } + if binding.CapabilityID != requirement.CapabilityID || binding.OwnerPrincipalID != requirement.OwnerPrincipalID || !dataPolicySatisfies(binding.DataPolicy, requirement.DataPolicy) || binding.Qualification != capability.QualificationValidated || binding.Health != capability.HealthAvailable || !provider.Enabled || provider.Qualification != capability.QualificationValidated || provider.Health != capability.HealthAvailable || model.Qualification != capability.QualificationValidated || model.Health != capability.HealthAvailable { + continue + } + if binding.ProviderInstanceID != provider.ID || binding.ProviderRevision != provider.Revision || binding.ModelDescriptorID != model.ID || binding.ModelRevision != model.Revision || model.ProviderInstanceID != provider.ID || requirement.InputBytes+requirement.MaxOutputBytes > model.MaxContextBytes { + continue + } + if binding.Activation != capability.ActivationActive && !(requirement.AllowCanary && binding.Activation == capability.ActivationCanary) { + continue + } + if !containsAll(model.Modalities, requirement.RequiredModalities) || !supportsCapabilities(candidate, requirement.RequiredCapabilities) { + continue + } + if candidate.Evaluation != nil && candidate.Evaluation.Result == capability.EvaluationUnsupported { + continue + } + eligible = append(eligible, candidate) + } + if len(eligible) > 0 { + sort.SliceStable(eligible, func(i, j int) bool { return betterCandidate(eligible[i], eligible[j]) }) + return eligible[0], nil + } + return capability.ResolvedCognitiveBinding{}, ErrNoInvocableCognitiveBinding +} + +func dataPolicySatisfies(actual, allowed capability.DataPolicy) bool { + return actual == capability.DataPolicyLocalOnly || actual == allowed +} + +func supportsCapabilities(candidate capability.ResolvedCognitiveBinding, required []capability.ModelCapability) bool { + for _, item := range required { + if slices.Contains(candidate.Model.Capabilities, item) { + continue + } + if candidate.Evaluation == nil || candidate.Evaluation.CapabilityID != candidate.Binding.CapabilityID || candidate.Evaluation.Result != capability.EvaluationSupported { + return false + } + } + return true +} + +func betterCandidate(left, right capability.ResolvedCognitiveBinding) bool { + le, re := left.Evaluation, right.Evaluation + if le != nil && le.Result == capability.EvaluationSupported && (re == nil || re.Result != capability.EvaluationSupported) { + return true + } + if re != nil && re.Result == capability.EvaluationSupported && (le == nil || le.Result != capability.EvaluationSupported) { + return false + } + if le != nil && re != nil && le.QualityScoreMillis != nil && re.QualityScoreMillis != nil && *le.QualityScoreMillis != *re.QualityScoreMillis { + return *le.QualityScoreMillis > *re.QualityScoreMillis + } + if le != nil && re != nil && le.CriticalFailureMillis != nil && re.CriticalFailureMillis != nil && *le.CriticalFailureMillis != *re.CriticalFailureMillis { + return *le.CriticalFailureMillis < *re.CriticalFailureMillis + } + lp, rp := left.Performance, right.Performance + if lp != nil && rp != nil && lp.ReliabilityMillis != nil && rp.ReliabilityMillis != nil && *lp.ReliabilityMillis != *rp.ReliabilityMillis { + return *lp.ReliabilityMillis > *rp.ReliabilityMillis + } + if lp != nil && rp != nil && lp.LatencyMicros != nil && rp.LatencyMicros != nil && *lp.LatencyMicros != *rp.LatencyMicros { + return *lp.LatencyMicros < *rp.LatencyMicros + } + if lp != nil && rp != nil && lp.GenerationTPSMillis != nil && rp.GenerationTPSMillis != nil && *lp.GenerationTPSMillis != *rp.GenerationTPSMillis { + return *lp.GenerationTPSMillis > *rp.GenerationTPSMillis + } + return string(left.Binding.ID) < string(right.Binding.ID) +} + +func containsAll[T comparable](actual, required []T) bool { + for _, item := range required { + if !slices.Contains(actual, item) { + return false + } + } + return true +} + +func validQualification(value capability.QualificationState) bool { + switch value { + case capability.QualificationDiscovered, capability.QualificationProbing, capability.QualificationEvaluating, capability.QualificationValidated, capability.QualificationRejected: + return true + } + return false +} +func validActivation(value capability.ActivationState) bool { + switch value { + case capability.ActivationInactive, capability.ActivationCanary, capability.ActivationActive, capability.ActivationDeprecated, capability.ActivationRetired: + return true + } + return false +} +func validHealth(value capability.HealthState) bool { + switch value { + case capability.HealthUnknown, capability.HealthAvailable, capability.HealthDegraded, capability.HealthUnavailable: + return true + } + return false +} + +func validOperational(value capability.OperationalState) bool { + switch value { + case capability.OperationalNotObserved, capability.OperationalYes, capability.OperationalNo: + return true + } + return false +} diff --git a/modules/capability/ports/repository.go b/modules/capability/ports/repository.go index 3b2aa12..aed2006 100644 --- a/modules/capability/ports/repository.go +++ b/modules/capability/ports/repository.go @@ -14,4 +14,12 @@ type Repository interface { SaveBinding(context.Context, capability.Binding) error LoadBinding(context.Context, capability.CapabilityBindingID, int64) (capability.Binding, error) LoadActiveBinding(context.Context, capability.CapabilityID, identity.PrincipalID) (capability.Binding, error) + SaveProviderInstance(context.Context, capability.ProviderInstance) error + SaveModelDescriptor(context.Context, capability.ModelDescriptor) error + SaveCognitiveBinding(context.Context, capability.CognitiveBinding) error + LoadCognitiveCandidates(context.Context, capability.CapabilityID, identity.PrincipalID) ([]capability.ResolvedCognitiveBinding, error) + SaveCognitiveUsageObservation(context.Context, capability.CognitiveUsageObservation) error + SaveCognitiveDiscovery(context.Context, capability.CognitiveDiscoveryRegistration) error + SaveEvaluatedCapability(context.Context, capability.EvaluatedCapability) error + SavePerformanceEvidence(context.Context, capability.PerformanceEvidence) error } diff --git a/tests/cognitive_providers/live_test.go b/tests/cognitive_providers/live_test.go new file mode 100644 index 0000000..9b8d8f0 --- /dev/null +++ b/tests/cognitive_providers/live_test.go @@ -0,0 +1,37 @@ +package cognitive_providers_test + +import ( + "context" + "os" + "testing" + "time" + + "git.cin.su/m/Assistant/adapters/models" + "git.cin.su/m/Assistant/contracts/capability" +) + +func TestLiveReadOnlyDiscovery(t *testing.T) { + if os.Getenv("ASSISTANT_LIVE_LOCAL_DISCOVERY") != "1" { + t.Skip("set ASSISTANT_LIVE_LOCAL_DISCOVERY=1 for read-only local provider probes") + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + cpu, err := models.NewOpenAICompatibleLocalAdapter("http://docker.cin.su:18082/v1", nil) + if err != nil { + t.Fatal(err) + } + cpuModels, cpuProbe := cpu.Discover(ctx) + if cpuProbe.Health != capability.HealthAvailable || len(cpuModels) == 0 { + t.Fatalf("CPU probe=%+v models=%+v", cpuProbe, cpuModels) + } + t.Logf("CPU probe=%+v models=%+v", cpuProbe, cpuModels) + gpu, err := models.NewOllamaAdapter("http://192.168.220.91:11434", nil) + if err != nil { + t.Fatal(err) + } + gpuModels, gpuProbe := gpu.Discover(ctx) + if gpuProbe.Health != capability.HealthAvailable || len(gpuModels) == 0 { + t.Fatalf("GPU probe=%+v models=%+v", gpuProbe, gpuModels) + } + t.Logf("GPU probe=%+v models=%+v", gpuProbe, gpuModels) +} diff --git a/tests/cognitive_providers/providers_test.go b/tests/cognitive_providers/providers_test.go new file mode 100644 index 0000000..41a3752 --- /dev/null +++ b/tests/cognitive_providers/providers_test.go @@ -0,0 +1,86 @@ +package cognitive_providers_test + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.cin.su/m/Assistant/adapters/models" + "git.cin.su/m/Assistant/contracts/capability" + capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain" +) + +func TestCPUDiscoveryIsReadOnlyAndCredentialFree(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet || r.URL.Path != "/v1/models" || r.Header.Get("Authorization") != "" { + t.Fatalf("unexpected request %s %s auth=%q", r.Method, r.URL.Path, r.Header.Get("Authorization")) + } + _, _ = io.WriteString(w, `{"data":[{"id":"same-name","created":1,"owned_by":"local","meta":{"n_ctx":2048,"n_params":3,"size":10,"ftype":"Q4"}}]}`) + })) + defer server.Close() + adapter, err := models.NewOpenAICompatibleLocalAdapter(server.URL+"/v1", nil) + if err != nil { + t.Fatal(err) + } + discovered, probe := adapter.Discover(context.Background()) + if probe.Health != capability.HealthAvailable || len(discovered) != 1 || requests != 1 { + t.Fatalf("probe=%+v models=%+v requests=%d", probe, discovered, requests) + } +} + +func TestOllamaDiscoveryDoesNotRunInference(t *testing.T) { + paths := []string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + switch r.URL.Path { + case "/api/version": + _, _ = io.WriteString(w, `{"version":"1.2.3"}`) + case "/api/tags": + _, _ = io.WriteString(w, `{"models":[{"name":"same-name","model":"same-name","size":20,"digest":"digest-a","details":{"format":"gguf","quantization_level":"Q4","context_length":4096},"capabilities":["completion"]}]}`) + default: + t.Fatalf("unexpected expensive endpoint %s", r.URL.Path) + } + })) + defer server.Close() + adapter, err := models.NewOllamaAdapter(server.URL, nil) + if err != nil { + t.Fatal(err) + } + discovered, probe := adapter.Discover(context.Background()) + if probe.Health != capability.HealthAvailable || len(discovered) != 1 || strings.Join(paths, ",") != "/api/version,/api/tags" { + t.Fatalf("probe=%+v models=%+v paths=%v", probe, discovered, paths) + } +} + +func TestDiscoveryIsIdempotentAndProviderScoped(t *testing.T) { + base := capability.CognitiveDiscoverySnapshot{ProviderInstanceID: "cpu", ProviderKind: "kind", EndpointRef: "endpoint", Enabled: true, OwnerPrincipalID: "owner", CapabilityID: "creator-advisory", DataPolicy: capability.DataPolicyLocalOnly, LimitPolicyRef: "limits", Health: capability.HealthAvailable, ObservedAtUnixMillis: 1, Models: []capability.DiscoveredModelSnapshot{{ProviderModelID: "same-name", Fingerprint: "digest-a", MaxContextBytes: 100, Modalities: []capability.ModelModality{capability.ModalityText}, Capabilities: []capability.ModelCapability{capability.CapabilityUnknown}, Installed: capability.OperationalYes, Loaded: capability.OperationalNotObserved, Invokable: capability.OperationalNotObserved}}} + first, err := capabilitydomain.BuildDiscoveryRegistration(base) + if err != nil { + t.Fatal(err) + } + second, err := capabilitydomain.BuildDiscoveryRegistration(base) + if err != nil { + t.Fatal(err) + } + if first.Models[0].ID != second.Models[0].ID || first.Models[0].Revision != second.Models[0].Revision || first.Bindings[0].Activation != capability.ActivationInactive { + t.Fatalf("discovery not idempotent/inactive: %+v %+v", first, second) + } + gpu := base + gpu.ProviderInstanceID = "gpu" + other, _ := capabilitydomain.BuildDiscoveryRegistration(gpu) + if first.Models[0].ID == other.Models[0].ID { + t.Fatal("same model name collided across providers") + } + changed := base + changed.Models = append([]capability.DiscoveredModelSnapshot(nil), base.Models...) + changed.Models[0].Fingerprint = "digest-b" + revision, _ := capabilitydomain.BuildDiscoveryRegistration(changed) + if revision.Models[0].Revision == first.Models[0].Revision { + t.Fatal("digest change silently reused revision") + } +} diff --git a/tests/cognitive_registry/registry_test.go b/tests/cognitive_registry/registry_test.go new file mode 100644 index 0000000..2e11fa6 --- /dev/null +++ b/tests/cognitive_registry/registry_test.go @@ -0,0 +1,147 @@ +package cognitive_registry_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.cin.su/m/Assistant/contracts/capability" + capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain" +) + +func validCandidate() capability.ResolvedCognitiveBinding { + return capability.ResolvedCognitiveBinding{ + Binding: capability.CognitiveBinding{ID: "binding-1", Revision: 3, CapabilityID: "creator-advisory", OwnerPrincipalID: "principal-1", ProviderInstanceID: "provider-1", ProviderRevision: 2, 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: 2, Kind: "external-responses-v1", 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: 32768, Modalities: []capability.ModelModality{capability.ModalityText}, Capabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, Qualification: capability.QualificationValidated, Health: capability.HealthAvailable, ObservedAtUnixMillis: 1}, + } +} + +func requirement() capability.CognitiveRequirement { + return capability.CognitiveRequirement{CapabilityID: "creator-advisory", OwnerPrincipalID: "principal-1", DataPolicy: capability.DataPolicyExternalMessageOnly, RequiredModalities: []capability.ModelModality{capability.ModalityText}, RequiredCapabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, InputBytes: 100, MaxOutputBytes: 1000} +} + +func TestRegisteredButDisabledProviderCannotBeInvoked(t *testing.T) { + candidate := validCandidate() + candidate.Provider.Enabled = false + if _, err := capabilitydomain.SelectCognitiveBinding(requirement(), []capability.ResolvedCognitiveBinding{candidate}); err == nil { + t.Fatal("disabled provider was selected") + } +} + +func TestDiscoveredInactiveBindingCannotBeInvoked(t *testing.T) { + candidate := validCandidate() + candidate.Binding.Qualification = capability.QualificationDiscovered + candidate.Binding.Activation = capability.ActivationInactive + if _, err := capabilitydomain.SelectCognitiveBinding(requirement(), []capability.ResolvedCognitiveBinding{candidate}); err == nil { + t.Fatal("discovered inactive binding was selected") + } +} + +func TestValidatedActiveBindingCanBeSelected(t *testing.T) { + selected, err := capabilitydomain.SelectCognitiveBinding(requirement(), []capability.ResolvedCognitiveBinding{validCandidate()}) + if err != nil || selected.Binding.ID != "binding-1" { + t.Fatalf("selected=%+v err=%v", selected, err) + } +} + +func TestRetiredBindingCannotBeInvoked(t *testing.T) { + candidate := validCandidate() + candidate.Binding.Activation = capability.ActivationRetired + if _, err := capabilitydomain.SelectCognitiveBinding(requirement(), []capability.ResolvedCognitiveBinding{candidate}); err == nil { + t.Fatal("retired binding was selected") + } +} + +func TestHealthTransitionsChangeEligibility(t *testing.T) { + for _, state := range []capability.HealthState{capability.HealthUnknown, capability.HealthDegraded, capability.HealthUnavailable} { + candidate := validCandidate() + candidate.Binding.Health = state + if _, err := capabilitydomain.SelectCognitiveBinding(requirement(), []capability.ResolvedCognitiveBinding{candidate}); err == nil { + t.Fatalf("binding health %s was selected", state) + } + } + if _, err := capabilitydomain.SelectCognitiveBinding(requirement(), []capability.ResolvedCognitiveBinding{validCandidate()}); err != nil { + t.Fatalf("available binding rejected: %v", err) + } +} + +func TestCreatorWebHasNoConcreteProviderModelOrBindingSelection(t *testing.T) { + root := filepath.Clean(filepath.Join("..", "..", "cmd", "creator-web")) + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil || entry.IsDir() || strings.HasSuffix(path, "_test.go") { + return err + } + contents, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + lower := strings.ToLower(string(contents)) + for _, forbidden := range []string{"openai", "ollama", "providerbindingid", "advisory_binding_mode", "gpt-"} { + if strings.Contains(lower, forbidden) { + t.Errorf("%s contains concrete selection token %q", path, forbidden) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestRegistryPersistenceContainsReferencesNotCredentialMaterial(t *testing.T) { + for _, path := range []string{filepath.Join("..", "..", "database", "migrations", "012_capability_cognitive_registry.sql"), filepath.Join("..", "..", "contracts", "capability", "cognitive_registry.go")} { + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + lower := strings.ToLower(string(contents)) + if !strings.Contains(lower, "credentialref") && !strings.Contains(lower, "credential_ref") { + t.Fatalf("%s lacks credential reference", path) + } + for _, forbidden := range []string{"api_key", "access_token", "credential_bytes", "plaintext_password"} { + if strings.Contains(lower, forbidden) { + t.Fatalf("%s stores forbidden credential material field %q", path, forbidden) + } + } + } +} + +func TestMigrationUsesOnlyCapabilityOwnerRoleAndSchema(t *testing.T) { + path := filepath.Join("..", "..", "database", "migrations", "012_capability_cognitive_registry.sql") + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + lower := strings.ToLower(string(contents)) + if strings.Count(lower, "set role capability_role") != 1 { + t.Fatal("migration must execute under capability_role") + } + for _, schema := range []string{"authority.", "commit.", "continuity.", "identity.", "situation.", "work."} { + if strings.Contains(lower, schema) { + t.Fatalf("migration touches foreign owner schema %s", schema) + } + } +} + +func TestAuthorityAndCommitRemainCognitiveIndependent(t *testing.T) { + for _, directory := range []string{"authority", "commit"} { + for _, parent := range []string{"contracts", "modules"} { + root := filepath.Join("..", "..", parent, directory) + _ = filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err + } + contents, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + if strings.Contains(string(contents), "contracts/cognitive") || strings.Contains(string(contents), "cognitive_registry") { + t.Errorf("%s depends on cognitive registry", path) + } + return nil + }) + } + } +} diff --git a/tests/cognitive_router/router_test.go b/tests/cognitive_router/router_test.go new file mode 100644 index 0000000..3b911f9 --- /dev/null +++ b/tests/cognitive_router/router_test.go @@ -0,0 +1,46 @@ +package cognitive_router_test + +import ( + "testing" + + "git.cin.su/m/Assistant/contracts/capability" + capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain" +) + +func candidate(id string, health capability.HealthState) capability.ResolvedCognitiveBinding { + return capability.ResolvedCognitiveBinding{Binding: capability.CognitiveBinding{ID: capability.CapabilityBindingID(id), Revision: 1, CapabilityID: "creator-advisory", OwnerPrincipalID: "owner", ProviderInstanceID: capability.ProviderInstanceID("provider-" + id), ProviderRevision: 1, ModelDescriptorID: capability.ModelDescriptorID("model-" + id), ModelRevision: 1, DataPolicy: capability.DataPolicyLocalOnly, Qualification: capability.QualificationValidated, Activation: capability.ActivationActive, Health: capability.HealthAvailable, LimitPolicyRef: "limits"}, Provider: capability.ProviderInstance{ID: capability.ProviderInstanceID("provider-" + id), Revision: 1, Kind: "kind", EndpointRef: capability.EndpointBindingRef("endpoint-" + id), Enabled: true, Qualification: capability.QualificationValidated, Health: health, ObservedAtUnixMillis: 1}, Model: capability.ModelDescriptor{ID: capability.ModelDescriptorID("model-" + id), Revision: 1, ProviderInstanceID: capability.ProviderInstanceID("provider-" + id), ProviderModelID: "snapshot", Fingerprint: "digest", MaxContextBytes: 10000, Modalities: []capability.ModelModality{capability.ModalityText}, Capabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, Qualification: capability.QualificationValidated, Health: capability.HealthAvailable, ObservedAtUnixMillis: 1}} +} + +func requirement() capability.CognitiveRequirement { + return capability.CognitiveRequirement{CapabilityID: "creator-advisory", OwnerPrincipalID: "owner", DataPolicy: capability.DataPolicyExternalMessageOnly, RequiredModalities: []capability.ModelModality{capability.ModalityText}, RequiredCapabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, InputBytes: 10, MaxOutputBytes: 100} +} + +func TestUnavailableGPUFallsBackToCPU(t *testing.T) { + gpu := candidate("gpu", capability.HealthUnavailable) + cpu := candidate("cpu", capability.HealthAvailable) + selected, err := capabilitydomain.SelectCognitiveBinding(requirement(), []capability.ResolvedCognitiveBinding{gpu, cpu}) + if err != nil || selected.Binding.ID != "cpu" { + t.Fatalf("selected=%+v err=%v", selected, err) + } +} + +func TestObservedQualityRanksOnlyComparableEvidence(t *testing.T) { + left, right := candidate("left", capability.HealthAvailable), candidate("right", capability.HealthAvailable) + low, high := int64(600), int64(900) + left.Evaluation = &capability.EvaluatedCapability{CapabilityID: "creator-advisory", Result: capability.EvaluationSupported, QualityScoreMillis: &low} + right.Evaluation = &capability.EvaluatedCapability{CapabilityID: "creator-advisory", Result: capability.EvaluationSupported, QualityScoreMillis: &high} + selected, err := capabilitydomain.SelectCognitiveBinding(requirement(), []capability.ResolvedCognitiveBinding{left, right}) + if err != nil || selected.Binding.ID != "right" { + t.Fatalf("selected=%+v err=%v", selected, err) + } +} + +func TestExcludedFailedBindingSelectsAlternative(t *testing.T) { + gpu, cpu := candidate("gpu", capability.HealthAvailable), candidate("cpu", capability.HealthAvailable) + req := requirement() + req.ExcludedBindingIDs = []capability.CapabilityBindingID{"gpu"} + selected, err := capabilitydomain.SelectCognitiveBinding(req, []capability.ResolvedCognitiveBinding{gpu, cpu}) + if err != nil || selected.Binding.ID != "cpu" { + t.Fatalf("selected=%+v err=%v", selected, err) + } +}