diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index b2459f3..613d21f 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -242,6 +242,8 @@ Validate: `go test ./internal/domain ./internal/backend`. Add ownership tests only for mutable structures whose accidental sharing would affect calls. No public `SmokeTest` method or temporary not-implemented API is introduced yet. +**Completed: 2026-10-13.** + ## Stage 2: Load Final Backend Diagnostic Metadata Scope: `internal/configuration`, `internal/backend`, and `backends.go`. diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 78d2c25..686b439 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -180,12 +180,14 @@ type PromptMessageTemplate struct { CacheControl *CacheControl `yaml:"cache_control,omitempty" json:"cache_control,omitempty"` } -// Backend describes reusable OpenAI-compatible connection defaults. +// Backend describes reusable OpenAI-compatible connection defaults and optional +// diagnostic metadata. type Backend struct { ID string Endpoint string APIKeyEnv string ExtraParams map[string]any + SmokeTests *BackendSmokeTestConfig ConcurrencyLimit int QueueCapacity int QueueCapacitySet bool diff --git a/internal/domain/smoke_diagnostics.go b/internal/domain/smoke_diagnostics.go new file mode 100644 index 0000000..d1fa129 --- /dev/null +++ b/internal/domain/smoke_diagnostics.go @@ -0,0 +1,263 @@ +package domain + +import ( + "time" + + "gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue" +) + +// BackendSmokeTestConfig describes optional backend-owned diagnostic defaults. +type BackendSmokeTestConfig struct { + DefaultProfile *string + Reachability *ReachabilityProbeConfig + Authentication *AuthenticationProbeConfig +} + +// ReachabilityProbeConfig describes an optional endpoint for reachability tests. +type ReachabilityProbeConfig struct { + Endpoint *string +} + +// AuthenticationProbeConfig describes an optional authentication diagnostic. +type AuthenticationProbeConfig struct { + Kind *string + Endpoint *string +} + +// SmokeTestKind identifies one built-in diagnostic operation. +type SmokeTestKind string + +const ( + SmokeTestReachability SmokeTestKind = "reachability" + SmokeTestAuthentication SmokeTestKind = "authentication" + SmokeTestTextInference SmokeTestKind = "text_inference" + SmokeTestStructuredOutput SmokeTestKind = "structured_output" +) + +// SmokeTestRequest selects one diagnostic and its execution target. +type SmokeTestRequest struct { + Kind SmokeTestKind + BackendID string + PromptID string + PromptVersion string + ProfileID string + Execution *ExecutionTargetOverride + APIKey string `json:"-" yaml:"-"` + TransientRetries *int +} + +// SmokeTestReport contains factual diagnostic results, not generated content. +type SmokeTestReport struct { + Kind SmokeTestKind + Supported bool + Applicable *bool + Completed bool + Reason SmokeTestReason + Target SmokeTestTarget + Facts SmokeTestFacts + Timing SmokeTestTiming + Attempts []SmokeTestAttempt + Usage ObservedTokenUsage + Failure ExecutionFailureKind +} + +// SmokeTestTarget identifies the selected diagnostic target and its settings. +type SmokeTestTarget struct { + BackendID string + ProfileID string + PromptID string + PromptVersion string + DefaultSourceBackendID string + SelectionSource string + Endpoint string + EffectiveSettings ExecutionTarget + SettingsPresence ExecutionTargetPresence +} + +// SmokeTestFacts contains optional factual observations from a diagnostic. +type SmokeTestFacts struct { + CredentialsSupplied *bool + EndpointReachable *bool + CredentialsAccepted *bool + ResponseReceived *bool + GenerationCompleted *bool + TextReturned *bool + ExpectedTextMatched *bool + StructuredOutputRequested *bool + ValidJSON *bool + SchemaMatched *bool +} + +// SmokeTestTiming records elapsed diagnostic and phase durations. +type SmokeTestTiming struct { + StartedAt time.Time + Duration time.Duration + CapacityWait *time.Duration + RequestDuration *time.Duration + RetryDelay *time.Duration + EvaluationDuration *time.Duration +} + +// SmokeTestAttempt records one dispatched attempt or an interrupted acquisition. +type SmokeTestAttempt struct { + Dispatched bool + InitialEndpoint string + Endpoint string + HTTPStatus *int + Facts SmokeTestFacts + Timing SmokeTestTiming + Failure ExecutionFailureKind + Usage ObservedTokenUsage +} + +// SmokeTestReason identifies additional factual context for a diagnostic. +type SmokeTestReason string + +const ( + SmokeTestReasonNone SmokeTestReason = "" + SmokeTestReasonAuthenticationNotApplicable SmokeTestReason = "authentication_not_applicable" + SmokeTestReasonProbeNotConfigured SmokeTestReason = "probe_not_configured" + SmokeTestReasonHTTPDiagnosticsUnavailable SmokeTestReason = "http_diagnostics_unavailable" + SmokeTestReasonRequiredCredentialMissing SmokeTestReason = "required_credential_missing" + SmokeTestReasonAuthenticationResponseUnrecognized SmokeTestReason = "authentication_response_unrecognized" +) + +// CloneBackend returns a copy of a backend, including its mutable settings. +func CloneBackend(backend Backend) (Backend, error) { + cloned := backend + var err error + cloned.ExtraParams, err = jsonvalue.CopyMap(backend.ExtraParams) + if err != nil { + return Backend{}, err + } + cloned.SmokeTests = CloneBackendSmokeTestConfig(backend.SmokeTests) + return cloned, nil +} + +// CloneBackendSmokeTestConfig returns an independent copy of optional metadata. +func CloneBackendSmokeTestConfig(config *BackendSmokeTestConfig) *BackendSmokeTestConfig { + if config == nil { + return nil + } + cloned := *config + cloned.DefaultProfile = cloneString(config.DefaultProfile) + if config.Reachability != nil { + reachability := *config.Reachability + reachability.Endpoint = cloneString(config.Reachability.Endpoint) + cloned.Reachability = &reachability + } + if config.Authentication != nil { + authentication := *config.Authentication + authentication.Kind = cloneString(config.Authentication.Kind) + authentication.Endpoint = cloneString(config.Authentication.Endpoint) + cloned.Authentication = &authentication + } + return &cloned +} + +// CloneSmokeTestRequest returns a copy that owns its optional settings. +func CloneSmokeTestRequest(request SmokeTestRequest) (SmokeTestRequest, error) { + cloned := request + cloned.TransientRetries = cloneInt(request.TransientRetries) + if request.Execution == nil { + return cloned, nil + } + execution := *request.Execution + execution.Temperature = cloneFloat64(request.Execution.Temperature) + execution.MaxTokens = cloneInt(request.Execution.MaxTokens) + execution.TopP = cloneFloat64(request.Execution.TopP) + execution.TimeoutSeconds = cloneInt(request.Execution.TimeoutSeconds) + execution.ReasoningEffort = cloneString(request.Execution.ReasoningEffort) + var err error + execution.ExtraParams, err = jsonvalue.CopyMap(request.Execution.ExtraParams) + if err != nil { + return SmokeTestRequest{}, err + } + cloned.Execution = &execution + return cloned, nil +} + +// CloneSmokeTestReport returns a copy with independent optional facts and maps. +func CloneSmokeTestReport(report SmokeTestReport) (SmokeTestReport, error) { + cloned := report + cloned.Applicable = cloneBool(report.Applicable) + cloned.Facts = cloneSmokeTestFacts(report.Facts) + cloned.Timing = cloneSmokeTestTiming(report.Timing) + var err error + cloned.Target.EffectiveSettings.ExtraParams, err = jsonvalue.CopyMap(report.Target.EffectiveSettings.ExtraParams) + if err != nil { + return SmokeTestReport{}, err + } + if report.Attempts != nil { + cloned.Attempts = make([]SmokeTestAttempt, len(report.Attempts)) + for index, attempt := range report.Attempts { + cloned.Attempts[index] = attempt + cloned.Attempts[index].HTTPStatus = cloneInt(attempt.HTTPStatus) + cloned.Attempts[index].Facts = cloneSmokeTestFacts(attempt.Facts) + cloned.Attempts[index].Timing = cloneSmokeTestTiming(attempt.Timing) + } + } + return cloned, nil +} + +func cloneSmokeTestFacts(facts SmokeTestFacts) SmokeTestFacts { + facts.CredentialsSupplied = cloneBool(facts.CredentialsSupplied) + facts.EndpointReachable = cloneBool(facts.EndpointReachable) + facts.CredentialsAccepted = cloneBool(facts.CredentialsAccepted) + facts.ResponseReceived = cloneBool(facts.ResponseReceived) + facts.GenerationCompleted = cloneBool(facts.GenerationCompleted) + facts.TextReturned = cloneBool(facts.TextReturned) + facts.ExpectedTextMatched = cloneBool(facts.ExpectedTextMatched) + facts.StructuredOutputRequested = cloneBool(facts.StructuredOutputRequested) + facts.ValidJSON = cloneBool(facts.ValidJSON) + facts.SchemaMatched = cloneBool(facts.SchemaMatched) + return facts +} + +func cloneSmokeTestTiming(timing SmokeTestTiming) SmokeTestTiming { + timing.CapacityWait = cloneDuration(timing.CapacityWait) + timing.RequestDuration = cloneDuration(timing.RequestDuration) + timing.RetryDelay = cloneDuration(timing.RetryDelay) + timing.EvaluationDuration = cloneDuration(timing.EvaluationDuration) + return timing +} + +func cloneBool(value *bool) *bool { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func cloneDuration(value *time.Duration) *time.Duration { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func cloneFloat64(value *float64) *float64 { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func cloneInt(value *int) *int { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func cloneString(value *string) *string { + if value == nil { + return nil + } + cloned := *value + return &cloned +} diff --git a/internal/domain/smoke_diagnostics_test.go b/internal/domain/smoke_diagnostics_test.go new file mode 100644 index 0000000..ea87df9 --- /dev/null +++ b/internal/domain/smoke_diagnostics_test.go @@ -0,0 +1,110 @@ +package domain + +import ( + "testing" + "time" +) + +func TestCloneBackendCopiesDiagnosticMetadataAndProviderValues(t *testing.T) { + profile := "profile" + reachabilityEndpoint := "https://probe.example/health" + authKind := "openrouter_key" + authEndpoint := "https://probe.example/key" + backend := Backend{ + ExtraParams: map[string]any{"nested": map[string]any{"value": "original"}}, + SmokeTests: &BackendSmokeTestConfig{ + DefaultProfile: &profile, + Reachability: &ReachabilityProbeConfig{Endpoint: &reachabilityEndpoint}, + Authentication: &AuthenticationProbeConfig{ + Kind: &authKind, + Endpoint: &authEndpoint, + }, + }, + } + + cloned, err := CloneBackend(backend) + if err != nil { + t.Fatal(err) + } + *cloned.SmokeTests.DefaultProfile = "changed" + *cloned.SmokeTests.Reachability.Endpoint = "changed" + *cloned.SmokeTests.Authentication.Kind = "changed" + *cloned.SmokeTests.Authentication.Endpoint = "changed" + cloned.ExtraParams["nested"].(map[string]any)["value"] = "changed" + + if profile != "profile" || reachabilityEndpoint != "https://probe.example/health" || + authKind != "openrouter_key" || authEndpoint != "https://probe.example/key" { + t.Fatal("cloned diagnostic metadata shared source pointers") + } + if got := backend.ExtraParams["nested"].(map[string]any)["value"]; got != "original" { + t.Fatalf("source provider parameter changed through clone: %v", got) + } +} + +func TestCloneSmokeTestRequestOwnsOptionalSettings(t *testing.T) { + temperature := 0.25 + retries := 2 + reasoning := "low" + request := SmokeTestRequest{ + APIKey: "secret", + TransientRetries: &retries, + Execution: &ExecutionTargetOverride{ + Temperature: &temperature, + ReasoningEffort: &reasoning, + ExtraParams: map[string]any{"nested": map[string]any{"value": "original"}}, + }, + } + + cloned, err := CloneSmokeTestRequest(request) + if err != nil { + t.Fatal(err) + } + *cloned.TransientRetries = 3 + *cloned.Execution.Temperature = 0.5 + *cloned.Execution.ReasoningEffort = "high" + cloned.Execution.ExtraParams["nested"].(map[string]any)["value"] = "changed" + + if retries != 2 || temperature != 0.25 || reasoning != "low" { + t.Fatal("cloned request shared optional scalar pointers") + } + if got := request.Execution.ExtraParams["nested"].(map[string]any)["value"]; got != "original" { + t.Fatalf("source provider parameter changed through clone: %v", got) + } +} + +func TestCloneSmokeTestReportOwnsMutableValues(t *testing.T) { + applicable := true + reachable := true + status := 200 + wait := 3 * time.Millisecond + report := SmokeTestReport{ + Applicable: &applicable, + Target: SmokeTestTarget{EffectiveSettings: ExecutionTarget{ + ExtraParams: map[string]any{"nested": map[string]any{"value": "original"}}, + }}, + Facts: SmokeTestFacts{EndpointReachable: &reachable}, + Attempts: []SmokeTestAttempt{{ + HTTPStatus: &status, + Facts: SmokeTestFacts{EndpointReachable: &reachable}, + Timing: SmokeTestTiming{CapacityWait: &wait}, + }}, + } + + cloned, err := CloneSmokeTestReport(report) + if err != nil { + t.Fatal(err) + } + *cloned.Applicable = false + *cloned.Facts.EndpointReachable = false + *cloned.Attempts[0].HTTPStatus = 503 + *cloned.Attempts[0].Facts.EndpointReachable = false + *cloned.Attempts[0].Timing.CapacityWait = time.Second + cloned.Target.EffectiveSettings.ExtraParams["nested"].(map[string]any)["value"] = "changed" + + if !applicable || !reachable || status != 200 || wait != 3*time.Millisecond { + t.Fatal("cloned report shared optional scalar pointers") + } + if got := report.Target.EffectiveSettings.ExtraParams["nested"].(map[string]any)["value"]; got != "original" { + t.Fatalf("source provider parameter changed through clone: %v", got) + } +}