From 106c80844226f46ab730286fecadab80a4748825 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 5 Nov 2026 13:41:01 +0000 Subject: [PATCH] Use ConfigKit mapping values directly --- backends.go | 5 +- docs/consumers/pkg-promptkit.md | 7 +- docs/roadmap/implementation.md | 4 ++ profiles.go | 5 +- provider_values.go | 121 -------------------------------- sources_contract_test.go | 49 +++++++++++++ 6 files changed, 65 insertions(+), 126 deletions(-) delete mode 100644 provider_values.go diff --git a/backends.go b/backends.go index 12e9120..fd6e79d 100644 --- a/backends.go +++ b/backends.go @@ -78,7 +78,8 @@ type AuthenticationProbeConfig struct { // It encodes fields without validating them; NewEngine validates the final // effective configuration after all selected source layers are combined. // Mapping captures a copy of supported input values at creation; unsupported -// values fail when the source is loaded. +// values fail when the source is loaded. ExtraParams must use ConfigKit Mapping +// input forms; callers must explicitly convert numbers or containers as needed. func BackendSource(label string, backends []BackendConfig) configkit.Source { definitions := make([]any, 0, len(backends)) for _, config := range backends { @@ -96,7 +97,7 @@ func BackendSource(label string, backends []BackendConfig) configkit.Source { fields["queue_capacity"] = *config.QueueCapacity } if config.ExtraParams != nil { - fields["extra_params"] = providerMapForMapping(config.ExtraParams) + fields["extra_params"] = config.ExtraParams } if config.SmokeTests != nil { smokeTests := make(map[string]any) diff --git a/docs/consumers/pkg-promptkit.md b/docs/consumers/pkg-promptkit.md index 255ebc9..0e1e25a 100644 --- a/docs/consumers/pkg-promptkit.md +++ b/docs/consumers/pkg-promptkit.md @@ -178,7 +178,12 @@ input := configkit.Input{Source: document} ``` `ProfileSource` and `BackendSource` are smaller typed conveniences when null -is not needed. Backend and profile definitions are supplied by the caller; +is not needed. Their provider maps use ConfigKit Mapping input forms: built-in +scalars, `[]any`, and `map[string]any` recursively. Convert `json.Number` or +typed containers explicitly to an appropriate supported value, choosing numeric +precision for your application. Mapping captures supported values immediately; +unsupported or cyclic values are reported when loaded. Runtime override values +follow their separate rules. Backend and profile definitions are supplied by the caller; there is no registration-only backend namespace. When configuration or message files change, build and validate a new engine, diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 7fb90dd..e6cfe8f 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -93,6 +93,10 @@ source-helper GoDoc and source/construction contract tests. capture and deferred upstream diagnostics, not acceptance of `json.Number`, typed containers, named scalars, or nested pointers by the removed adapter. +Completion: convenience sources pass provider maps directly to ConfigKit; +the converter was removed. Focused source and construction tests passed with +Go 1.27.3, including nested capture and deferred unsupported/cyclic errors. + ## Stage 3: Represent The Owning Error Operation **Catalog:** 7, first part. **Files:** use-case error/runner/preparation boundaries. diff --git a/profiles.go b/profiles.go index 1d46bf6..296fedc 100644 --- a/profiles.go +++ b/profiles.go @@ -25,7 +25,8 @@ type ProfileConfig struct { // It encodes fields without validating them; NewEngine validates the final // effective configuration after all selected source layers are combined. // Mapping captures a copy of supported input values at creation; unsupported -// values fail when the source is loaded. +// values fail when the source is loaded. ExtraParams must use ConfigKit Mapping +// input forms; callers must explicitly convert numbers or containers as needed. func ProfileSource(label string, profiles []ProfileConfig) configkit.Source { definitions := make([]any, 0, len(profiles)) for _, config := range profiles { @@ -67,7 +68,7 @@ func ProfileSource(label string, profiles []ProfileConfig) configkit.Source { fields["api_key_required"] = *config.APIKeyRequired } if config.ExtraParams != nil { - fields["extra_params"] = providerMapForMapping(config.ExtraParams) + fields["extra_params"] = config.ExtraParams } definitions = append(definitions, fields) } diff --git a/provider_values.go b/provider_values.go deleted file mode 100644 index 6620463..0000000 --- a/provider_values.go +++ /dev/null @@ -1,121 +0,0 @@ -package promptkit - -import ( - "encoding/json" - "reflect" - "strconv" -) - -type providerVisit struct { - typ reflect.Type - ptr uintptr -} - -type invalidProviderNumber struct{} - -// providerMapForMapping converts JSON-shaped Go containers to ConfigKit's -// built-in mapping containers. Unsupported values remain for Mapping to report -// as deferred source diagnostics during construction. -func providerMapForMapping(values map[string]any) map[string]any { - if values == nil { - return nil - } - converted := make(map[string]any, len(values)) - active := make(map[providerVisit]bool) - for key, value := range values { - converted[key] = providerValueForMapping(reflect.ValueOf(value), active, 0) - } - return converted -} - -func providerValueForMapping(value reflect.Value, active map[providerVisit]bool, depth int) any { - if !value.IsValid() { - return nil - } - if depth > 100 { - return value.Interface() - } - for value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer { - if value.IsNil() { - return nil - } - if value.Kind() == reflect.Pointer { - visit := providerVisit{typ: value.Type(), ptr: value.Pointer()} - if active[visit] { - return value.Interface() - } - active[visit] = true - defer delete(active, visit) - } - value = value.Elem() - } - if value.CanInterface() { - if number, ok := value.Interface().(json.Number); ok { - if _, err := json.Marshal(number); err != nil { - return invalidProviderNumber{} - } - if integer, err := strconv.ParseInt(string(number), 10, 64); err == nil { - return integer - } - if integer, err := strconv.ParseUint(string(number), 10, 64); err == nil { - return integer - } - if decimal, err := strconv.ParseFloat(string(number), 64); err == nil { - return decimal - } - return number - } - } - switch value.Kind() { - case reflect.Map: - if value.IsNil() { - return nil - } - if value.Type().Key().Kind() != reflect.String { - return value.Interface() - } - visit := providerVisit{typ: value.Type(), ptr: value.Pointer()} - if active[visit] { - return value.Interface() - } - active[visit] = true - defer delete(active, visit) - converted := make(map[string]any, value.Len()) - iter := value.MapRange() - for iter.Next() { - converted[iter.Key().String()] = providerValueForMapping(iter.Value(), active, depth+1) - } - return converted - case reflect.Slice, reflect.Array: - if value.Kind() == reflect.Slice { - if value.IsNil() { - return nil - } - visit := providerVisit{typ: value.Type(), ptr: value.Pointer()} - if visit.ptr != 0 { - if active[visit] { - return value.Interface() - } - active[visit] = true - defer delete(active, visit) - } - } - converted := make([]any, value.Len()) - for index := range converted { - converted[index] = providerValueForMapping(value.Index(index), active, depth+1) - } - return converted - case reflect.String: - return value.String() - case reflect.Bool: - return value.Bool() - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return value.Int() - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return value.Uint() - case reflect.Float32, reflect.Float64: - return value.Float() - default: - return value.Interface() - } -} diff --git a/sources_contract_test.go b/sources_contract_test.go index 10143ee..d376fef 100644 --- a/sources_contract_test.go +++ b/sources_contract_test.go @@ -3,6 +3,7 @@ package promptkit_test import ( "bytes" "context" + "encoding/json" "errors" "strings" "testing" @@ -73,3 +74,51 @@ func TestProgrammaticProfileVariantInherits(t *testing.T) { t.Fatalf("inherited profile = %+v, %v", profile, err) } } + +func TestProgrammaticSourcesCaptureNestedValuesAndDeferInvalidValues(t *testing.T) { + endpoint := "https://example.invalid/v1" + type namedString string + cyclic := map[string]any{} + cyclic["self"] = cyclic + for _, kind := range []string{"backend", "profile"} { + makeSource := func(params map[string]any) configkit.Source { + if kind == "backend" { + return promptkit.BackendSource("backend", []promptkit.BackendConfig{{ID: "local", Endpoint: &endpoint, ExtraParams: params}}) + } + return promptkit.ProfileSource("profile", []promptkit.ProfileConfig{{ID: "local", ExtraParams: params}}) + } + load := func(source configkit.Source) (*promptkit.Engine, error) { + return promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{Source: source}}}, promptkit.WithBundledOpenRouter(false)) + } + t.Run(kind+"/capture", func(t *testing.T) { + values := map[string]any{"nested": map[string]any{"items": []any{"original", int64(42), true, nil}}} + source := makeSource(values) + values["nested"].(map[string]any)["items"].([]any)[0] = "mutated" + engine, err := load(source) + if err != nil { + t.Fatal(err) + } + view, err := engine.InspectConfiguration() + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + if err := view.WriteEffective(&output); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), "original") || strings.Contains(output.String(), "mutated") { + t.Fatalf("captured configuration = %s", output.String()) + } + }) + for name, value := range map[string]any{"number": json.Number("1.25"), "typed slice": []string{"value"}, "named scalar": namedString("value"), "pointer": &endpoint, "cycle": cyclic} { + t.Run(kind+"/"+name, func(t *testing.T) { + source := makeSource(map[string]any{"value": value}) + _, err := load(source) + var loadErr *configkit.LoadError + if !errors.As(err, &loadErr) || len(loadErr.Diagnostics()) == 0 { + t.Fatalf("source error = %v", err) + } + }) + } + } +}