From 8d00354c590d713a258866ec26f8536b288daf0e Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 29 Jul 2026 16:59:43 +0000 Subject: [PATCH] Add immutable backend registry foundation --- docs/internal/overview.md | 1 + docs/policy/architecture.md | 10 +- docs/roadmap/implementation.md | 2 + internal/backend/json_copy.go | 225 ++++++++++++++++++++ internal/backend/registry.go | 172 +++++++++++++++ internal/backend/registry_test.go | 256 +++++++++++++++++++++++ internal/domain/domain.go | 8 + internal/llm/openai_compatible_client.go | 15 +- 8 files changed, 673 insertions(+), 16 deletions(-) create mode 100644 internal/backend/json_copy.go create mode 100644 internal/backend/registry.go create mode 100644 internal/backend/registry_test.go diff --git a/docs/internal/overview.md b/docs/internal/overview.md index e0f7941..d080866 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -14,6 +14,7 @@ contributor workflow and validation. | Root `promptkit` package | Provides the supported engine facade, source and injection options, public request and result values, built-in profile construction, extension interfaces, value conversion, redacted formatting, and public error mapping. | [Package GoDoc](../../doc.go), [engine assembly](../../engine.go) | | `examples/go-library/prepare` | Demonstrates an offline downstream consumer using a prompt file, in-memory profile, inline input, and `Prepare`. It is not a public library package. | [Example program](../../examples/go-library/prepare/main.go) | | `examples/go-library/run` | Demonstrates an offline downstream consumer using a prompt file, in-memory profile, inline input, an injected deterministic model client, and `Run`. It is not a public library package. | [Example program](../../examples/go-library/run/main.go) | +| `internal/backend` | Validates and defensively copies immutable OpenAI-compatible backend definitions, supplies the built-in OpenRouter definition, and owns the shared reserved request-field rule. | [Backend registry](../../internal/backend/registry.go) | | `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, profiles, execution targets, rendering, generation, and validation. | [Domain declarations](../../internal/domain/domain.go) | | `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) | | `internal/filecatalog` | Provides deterministic YAML discovery and path helpers for operating-system filesystems and `fs.FS` sources. | [File catalog](../../internal/filecatalog/catalog.go) | diff --git a/docs/policy/architecture.md b/docs/policy/architecture.md index cc53f34..4ba342c 100644 --- a/docs/policy/architecture.md +++ b/docs/policy/architecture.md @@ -21,6 +21,8 @@ The implemented internal components consist of: - `internal/domain`, which owns framework data values shared by later internal components; +- `internal/backend`, which owns validated immutable OpenAI-compatible backend + definitions and the built-in OpenRouter definition; - `internal/defaults`, which owns application-neutral framework defaults and constructs the default execution target; - `internal/filecatalog`, which discovers YAML files and provides source-path @@ -72,9 +74,11 @@ downstream consumers, including Scriptorium narrow injected abstractions ``` -The facade coordinates internal components and adapts the supported public -extension interfaces to narrow internal abstractions. Internal components must -not depend on consumers or on Scriptorium. +The backend registry depends on the domain model, and the model client reuses +its OpenAI-compatible reserved request-field rule. The facade coordinates +internal components and adapts the supported public extension interfaces to +narrow internal abstractions. Internal components must not depend on consumers +or on Scriptorium. ## Repository And Consumer Boundary diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index e9ce094..ba62d62 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -245,6 +245,8 @@ where the existing error boundary permits it. ## Stage 1 — Registry Foundation +**Status:** Complete. + ### Goal Introduce the internal domain and immutable registry without changing the diff --git a/internal/backend/json_copy.go b/internal/backend/json_copy.go new file mode 100644 index 0000000..cd64c99 --- /dev/null +++ b/internal/backend/json_copy.go @@ -0,0 +1,225 @@ +package backend + +import ( + "encoding/json" + "fmt" + "math" + "reflect" + "sort" + "strconv" +) + +const maxSafeJSONInteger = 1<<53 - 1 + +type jsonVisit struct { + typ reflect.Type + ptr uintptr +} + +func copyJSONMap(src map[string]any) (map[string]any, error) { + if src == nil { + return nil, nil + } + copied, err := copyJSONValue(reflect.ValueOf(src), "extra_params", make(map[jsonVisit]struct{})) + if err != nil { + return nil, err + } + out, ok := copied.(map[string]any) + if !ok { + return nil, fmt.Errorf("extra_params: expected object") + } + return out, nil +} + +func copyJSONValue(value reflect.Value, path string, seen map[jsonVisit]struct{}) (any, error) { + if !value.IsValid() { + return nil, nil + } + if value.Kind() == reflect.Interface { + if value.IsNil() { + return nil, nil + } + return copyJSONValue(value.Elem(), path, seen) + } + if !value.CanInterface() { + return nil, fmt.Errorf("%s: value cannot be copied", path) + } + if number, ok := value.Interface().(json.Number); ok { + f, err := strconv.ParseFloat(number.String(), 64) + if err != nil || math.IsNaN(f) || math.IsInf(f, 0) { + return nil, fmt.Errorf("%s: invalid JSON number", path) + } + return number, nil + } + + switch value.Kind() { + case reflect.Bool, reflect.String: + return value.Interface(), nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if value.Int() < -maxSafeJSONInteger || value.Int() > maxSafeJSONInteger { + return nil, fmt.Errorf("%s: integer is outside the JSON-safe range", path) + } + return value.Interface(), nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + if value.Uint() > maxSafeJSONInteger { + return nil, fmt.Errorf("%s: integer is outside the JSON-safe range", path) + } + return value.Interface(), nil + case reflect.Float32, reflect.Float64: + number := value.Convert(reflect.TypeOf(float64(0))).Float() + if math.IsNaN(number) || math.IsInf(number, 0) { + return nil, fmt.Errorf("%s: floating-point value must be finite", path) + } + return value.Interface(), nil + case reflect.Pointer: + if value.IsNil() { + return nil, nil + } + visit := jsonVisit{typ: value.Type(), ptr: value.Pointer()} + if _, ok := seen[visit]; ok { + return nil, fmt.Errorf("%s: cyclic value is not supported", path) + } + seen[visit] = struct{}{} + defer delete(seen, visit) + return copyJSONValue(value.Elem(), path, seen) + case reflect.Map: + return copyJSONMapValue(value, path, seen) + case reflect.Slice: + if value.IsNil() { + return nil, nil + } + return copyJSONSequenceValue(value, path, seen) + case reflect.Array: + return copyJSONSequenceValue(value, path, seen) + default: + return nil, fmt.Errorf("%s: unsupported JSON value type %s", path, value.Type()) + } +} + +func copyJSONMapValue(value reflect.Value, path string, seen map[jsonVisit]struct{}) (any, error) { + if value.IsNil() { + return nil, nil + } + if value.Type().Key().Kind() != reflect.String { + return nil, fmt.Errorf("%s: map key type %s is not supported", path, value.Type().Key()) + } + + visit := jsonVisit{typ: value.Type(), ptr: value.Pointer()} + if _, ok := seen[visit]; ok { + return nil, fmt.Errorf("%s: cyclic value is not supported", path) + } + seen[visit] = struct{}{} + defer delete(seen, visit) + + keys := value.MapKeys() + sort.Slice(keys, func(i, j int) bool { + return keys[i].String() < keys[j].String() + }) + + type entry struct { + key reflect.Value + name string + value any + } + entries := make([]entry, 0, len(keys)) + preserveType := true + elementType := value.Type().Elem() + for _, key := range keys { + name := key.String() + if name == "" { + return nil, fmt.Errorf("%s: map key must not be empty", path) + } + copied, err := copyJSONValue(value.MapIndex(key), path+"."+name, seen) + if err != nil { + return nil, err + } + entries = append(entries, entry{key: key, name: name, value: copied}) + if copied == nil { + if !canAssignNil(elementType) { + preserveType = false + } + continue + } + if !reflect.TypeOf(copied).AssignableTo(elementType) { + preserveType = false + } + } + + if preserveType { + out := reflect.MakeMapWithSize(value.Type(), len(entries)) + for _, entry := range entries { + if entry.value == nil { + out.SetMapIndex(entry.key, reflect.Zero(elementType)) + continue + } + out.SetMapIndex(entry.key, reflect.ValueOf(entry.value)) + } + return out.Interface(), nil + } + + out := make(map[string]any, len(entries)) + for _, entry := range entries { + out[entry.name] = entry.value + } + return out, nil +} + +func copyJSONSequenceValue(value reflect.Value, path string, seen map[jsonVisit]struct{}) (any, error) { + var visit jsonVisit + if value.Kind() == reflect.Slice { + visit = jsonVisit{typ: value.Type(), ptr: value.Pointer()} + if _, ok := seen[visit]; ok { + return nil, fmt.Errorf("%s: cyclic value is not supported", path) + } + seen[visit] = struct{}{} + defer delete(seen, visit) + } + + values := make([]any, value.Len()) + preserveType := true + elementType := value.Type().Elem() + for i := 0; i < value.Len(); i++ { + copied, err := copyJSONValue(value.Index(i), fmt.Sprintf("%s[%d]", path, i), seen) + if err != nil { + return nil, err + } + values[i] = copied + if copied == nil { + if !canAssignNil(elementType) { + preserveType = false + } + continue + } + if !reflect.TypeOf(copied).AssignableTo(elementType) { + preserveType = false + } + } + + if preserveType { + out := reflect.New(value.Type()).Elem() + if value.Kind() == reflect.Slice { + out = reflect.MakeSlice(value.Type(), value.Len(), value.Len()) + } + for i, copied := range values { + if copied == nil { + out.Index(i).Set(reflect.Zero(elementType)) + continue + } + out.Index(i).Set(reflect.ValueOf(copied)) + } + return out.Interface(), nil + } + + out := make([]any, len(values)) + copy(out, values) + return out, nil +} + +func canAssignNil(typ reflect.Type) bool { + switch typ.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return true + default: + return false + } +} diff --git a/internal/backend/registry.go b/internal/backend/registry.go new file mode 100644 index 0000000..f3a6486 --- /dev/null +++ b/internal/backend/registry.go @@ -0,0 +1,172 @@ +// Package backend owns validated, immutable OpenAI-compatible backend +// definitions. +package backend + +import ( + "errors" + "fmt" + "net/url" + "regexp" + "sort" + "strings" + + "gitea.maximumdirect.net/eric/promptkit/internal/domain" +) + +const ( + // OpenRouterID is the reserved ID of Promptkit's built-in OpenRouter + // backend. + OpenRouterID = "openrouter" + + openRouterEndpoint = "https://openrouter.ai/api/v1" + openRouterAPIKeyEnv = "OPENROUTER_API_KEY" +) + +// ErrBackendNotFound identifies a registry lookup for an unknown backend ID. +var ErrBackendNotFound = errors.New("backend not found") + +var environmentVariableName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +// Registry is an immutable collection of validated backend definitions. +type Registry struct { + backends map[string]domain.Backend +} + +// NewRegistry constructs a registry containing the built-in OpenRouter +// definition followed by the supplied additions. Every ID must be unique. +func NewRegistry(additions []domain.Backend) (*Registry, error) { + registry := &Registry{ + backends: make(map[string]domain.Backend, len(additions)+1), + } + + definitions := make([]domain.Backend, 0, len(additions)+1) + definitions = append(definitions, domain.Backend{ + ID: OpenRouterID, + Endpoint: openRouterEndpoint, + APIKeyEnv: openRouterAPIKeyEnv, + }) + definitions = append(definitions, additions...) + + for _, definition := range definitions { + definition.ID = strings.TrimSpace(definition.ID) + if definition.ID == "" { + return nil, errors.New("backend ID must not be blank") + } + if _, exists := registry.backends[definition.ID]; exists { + return nil, fmt.Errorf("backend ID %q is already registered", definition.ID) + } + + normalized, err := normalizeBackend(definition) + if err != nil { + return nil, err + } + registry.backends[normalized.ID] = normalized + } + + return registry, nil +} + +// GetBackend returns a defensive copy of the backend registered with id. +func (r *Registry) GetBackend(id string) (domain.Backend, error) { + if r == nil { + return domain.Backend{}, fmt.Errorf("%w: %q", ErrBackendNotFound, id) + } + definition, ok := r.backends[id] + if !ok { + return domain.Backend{}, fmt.Errorf("%w: %q", ErrBackendNotFound, id) + } + extraParams, err := copyJSONMap(definition.ExtraParams) + if err != nil { + return domain.Backend{}, fmt.Errorf("copy backend %q: %w", id, err) + } + definition.ExtraParams = extraParams + return definition, nil +} + +// IsReservedRequestField reports whether name is owned by the standard +// OpenAI-compatible chat request rather than backend extra parameters. +func IsReservedRequestField(name string) bool { + switch name { + case "model", + "session_id", + "messages", + "temperature", + "max_tokens", + "top_p", + "service_tier", + "reasoning_effort", + "response_format": + return true + default: + return false + } +} + +func normalizeBackend(definition domain.Backend) (domain.Backend, error) { + definition.Endpoint = strings.TrimSpace(definition.Endpoint) + if err := validateEndpoint(definition.Endpoint); err != nil { + return domain.Backend{}, fmt.Errorf("backend %q endpoint: %w", definition.ID, err) + } + + definition.APIKeyEnv = strings.TrimSpace(definition.APIKeyEnv) + if definition.APIKeyEnv != "" && !environmentVariableName.MatchString(definition.APIKeyEnv) { + return domain.Backend{}, fmt.Errorf( + "backend %q api key environment variable %q is invalid", + definition.ID, + definition.APIKeyEnv, + ) + } + + keys := make([]string, 0, len(definition.ExtraParams)) + for key := range definition.ExtraParams { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + if key == "" { + return domain.Backend{}, fmt.Errorf("backend %q extra parameter key must not be empty", definition.ID) + } + if IsReservedRequestField(key) { + return domain.Backend{}, fmt.Errorf( + "backend %q extra parameter %q collides with a reserved request field", + definition.ID, + key, + ) + } + } + + extraParams, err := copyJSONMap(definition.ExtraParams) + if err != nil { + return domain.Backend{}, fmt.Errorf("backend %q extra parameters: %w", definition.ID, err) + } + definition.ExtraParams = extraParams + return definition, nil +} + +func validateEndpoint(endpoint string) error { + if endpoint == "" { + return errors.New("must not be blank") + } + if strings.Contains(endpoint, "#") { + return errors.New("must not contain a fragment") + } + + parsed, err := url.Parse(endpoint) + if err != nil { + return fmt.Errorf("must be a valid URL: %w", err) + } + scheme := strings.ToLower(parsed.Scheme) + if scheme != "http" && scheme != "https" { + return errors.New("must use http or https") + } + if !parsed.IsAbs() || parsed.Hostname() == "" { + return errors.New("must be absolute and include a host") + } + if parsed.User != nil { + return errors.New("must not contain user information") + } + if parsed.RawQuery != "" || parsed.ForceQuery { + return errors.New("must not contain a query string") + } + return nil +} diff --git a/internal/backend/registry_test.go b/internal/backend/registry_test.go new file mode 100644 index 0000000..0119c0c --- /dev/null +++ b/internal/backend/registry_test.go @@ -0,0 +1,256 @@ +package backend_test + +import ( + "encoding/json" + "errors" + "math" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/promptkit/internal/backend" + "gitea.maximumdirect.net/eric/promptkit/internal/domain" +) + +const validEndpoint = "https://backend.example/v1" + +func TestRegistryIncludesExactOpenRouterDefinition(t *testing.T) { + registry, err := backend.NewRegistry(nil) + if err != nil { + t.Fatalf("construct registry: %v", err) + } + + definition, err := registry.GetBackend(backend.OpenRouterID) + if err != nil { + t.Fatalf("look up OpenRouter: %v", err) + } + if definition.ID != "openrouter" || + definition.Endpoint != "https://openrouter.ai/api/v1" || + definition.APIKeyEnv != "OPENROUTER_API_KEY" || + definition.ExtraParams != nil { + t.Fatalf("unexpected OpenRouter definition: %#v", definition) + } +} + +func TestRegistryNormalizesUniqueAdditionsAndIsolatesMutations(t *testing.T) { + nested := map[string]int{"limit": 2} + extraParams := map[string]any{ + "count": int64(7), + "nested": nested, + } + registry, err := backend.NewRegistry([]domain.Backend{ + { + ID: " custom ", + Endpoint: " https://custom.example/openai/v1 ", + APIKeyEnv: " CUSTOM_API_KEY ", + ExtraParams: extraParams, + }, + { + ID: "Custom", + Endpoint: validEndpoint, + }, + }) + if err != nil { + t.Fatalf("construct registry: %v", err) + } + + nested["limit"] = 99 + extraParams["added"] = true + + got, err := registry.GetBackend("custom") + if err != nil { + t.Fatalf("look up custom backend: %v", err) + } + if got.ID != "custom" || + got.Endpoint != "https://custom.example/openai/v1" || + got.APIKeyEnv != "CUSTOM_API_KEY" { + t.Fatalf("unexpected normalized definition: %#v", got) + } + if count, ok := got.ExtraParams["count"].(int64); !ok || count != 7 { + t.Fatalf("integer type or value changed: %#v", got.ExtraParams["count"]) + } + gotNested, ok := got.ExtraParams["nested"].(map[string]int) + if !ok || gotNested["limit"] != 2 { + t.Fatalf("container type or value changed: %#v", got.ExtraParams["nested"]) + } + if _, exists := got.ExtraParams["added"]; exists { + t.Fatalf("registry retained caller map: %#v", got.ExtraParams) + } + + gotNested["limit"] = 100 + got.ExtraParams["added"] = true + again, err := registry.GetBackend("custom") + if err != nil { + t.Fatalf("look up custom backend again: %v", err) + } + if again.ExtraParams["nested"].(map[string]int)["limit"] != 2 { + t.Fatalf("lookup exposed registry nested map: %#v", again.ExtraParams) + } + if _, exists := again.ExtraParams["added"]; exists { + t.Fatalf("lookup exposed registry map: %#v", again.ExtraParams) + } + + if _, err := registry.GetBackend("Custom"); err != nil { + t.Fatalf("backend IDs should be case-sensitive: %v", err) + } +} + +func TestNewRegistryRejectsDuplicateIDs(t *testing.T) { + tests := []struct { + name string + additions []domain.Backend + wantID string + }{ + { + name: "built-in collision after normalization", + additions: []domain.Backend{{ + ID: " openrouter ", + }}, + wantID: "openrouter", + }, + { + name: "consumer collision after normalization", + additions: []domain.Backend{ + {ID: "custom", Endpoint: validEndpoint}, + {ID: " custom ", Endpoint: "https://other.example/v1"}, + }, + wantID: "custom", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := backend.NewRegistry(tc.additions) + if err == nil { + t.Fatal("expected duplicate ID error") + } + if !strings.Contains(err.Error(), tc.wantID) { + t.Fatalf("expected error to identify %q, got %v", tc.wantID, err) + } + }) + } +} + +func TestNewRegistryValidatesIDs(t *testing.T) { + for _, id := range []string{"", " \t\n "} { + t.Run(id, func(t *testing.T) { + _, err := backend.NewRegistry([]domain.Backend{{ + ID: id, + Endpoint: validEndpoint, + }}) + if err == nil { + t.Fatal("expected blank ID error") + } + }) + } +} + +func TestNewRegistryValidatesEndpoints(t *testing.T) { + tests := []struct { + name string + endpoint string + }{ + {name: "blank", endpoint: ""}, + {name: "relative", endpoint: "/v1"}, + {name: "missing host", endpoint: "https:///v1"}, + {name: "unsupported scheme", endpoint: "ftp://backend.example/v1"}, + {name: "user information", endpoint: "https://user@backend.example/v1"}, + {name: "query", endpoint: "https://backend.example/v1?mode=chat"}, + {name: "empty query", endpoint: "https://backend.example/v1?"}, + {name: "fragment", endpoint: "https://backend.example/v1#chat"}, + {name: "empty fragment", endpoint: "https://backend.example/v1#"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := backend.NewRegistry([]domain.Backend{{ + ID: "custom", + Endpoint: tc.endpoint, + }}) + if err == nil { + t.Fatal("expected invalid endpoint error") + } + }) + } +} + +func TestNewRegistryValidatesEnvironmentVariableNames(t *testing.T) { + for _, name := range []string{"1API_KEY", "API-KEY", "API KEY", "ÅPI_KEY"} { + t.Run(name, func(t *testing.T) { + _, err := backend.NewRegistry([]domain.Backend{{ + ID: "custom", + Endpoint: validEndpoint, + APIKeyEnv: name, + }}) + if err == nil { + t.Fatal("expected invalid environment-variable name error") + } + }) + } +} + +func TestNewRegistryValidatesExtraParameters(t *testing.T) { + cyclic := map[string]any{} + cyclic["self"] = cyclic + + tests := []struct { + name string + extraParams map[string]any + }{ + {name: "empty top-level key", extraParams: map[string]any{"": true}}, + {name: "empty nested key", extraParams: map[string]any{"nested": map[string]int{"": 1}}}, + {name: "non-string map key", extraParams: map[string]any{"nested": map[int]string{1: "one"}}}, + {name: "unsupported value", extraParams: map[string]any{"value": make(chan int)}}, + {name: "cyclic value", extraParams: map[string]any{"value": cyclic}}, + {name: "NaN", extraParams: map[string]any{"value": math.NaN()}}, + {name: "positive infinity", extraParams: map[string]any{"value": math.Inf(1)}}, + {name: "unsafe integer", extraParams: map[string]any{"value": int64(1 << 53)}}, + {name: "invalid JSON number", extraParams: map[string]any{"value": json.Number("not-a-number")}}, + } + for _, key := range []string{ + "model", + "session_id", + "messages", + "temperature", + "max_tokens", + "top_p", + "service_tier", + "reasoning_effort", + "response_format", + } { + tests = append(tests, struct { + name string + extraParams map[string]any + }{ + name: "reserved key " + key, + extraParams: map[string]any{key: true}, + }) + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := backend.NewRegistry([]domain.Backend{{ + ID: "custom", + Endpoint: validEndpoint, + ExtraParams: tc.extraParams, + }}) + if err == nil { + t.Fatal("expected invalid extra parameters error") + } + }) + } +} + +func TestRegistryLookupReportsNotFound(t *testing.T) { + registry, err := backend.NewRegistry(nil) + if err != nil { + t.Fatalf("construct registry: %v", err) + } + + _, err = registry.GetBackend("missing") + if !errors.Is(err, backend.ErrBackendNotFound) { + t.Fatalf("expected ErrBackendNotFound, got %v", err) + } + if !strings.Contains(err.Error(), "missing") { + t.Fatalf("expected error to identify backend, got %v", err) + } +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 95c87bd..d1dfd3d 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -157,6 +157,14 @@ type PromptMessageTemplate struct { CacheControl *CacheControl `yaml:"cache_control,omitempty" json:"cache_control,omitempty"` } +// Backend describes reusable OpenAI-compatible connection defaults. +type Backend struct { + ID string + Endpoint string + APIKeyEnv string + ExtraParams map[string]any +} + // ExecutionProfile describes how and where to execute a model. type ExecutionProfile struct { ID string `yaml:"id"` diff --git a/internal/llm/openai_compatible_client.go b/internal/llm/openai_compatible_client.go index 2e9f230..8f9fa61 100644 --- a/internal/llm/openai_compatible_client.go +++ b/internal/llm/openai_compatible_client.go @@ -14,6 +14,7 @@ import ( "time" "unicode/utf8" + "gitea.maximumdirect.net/eric/promptkit/internal/backend" "gitea.maximumdirect.net/eric/promptkit/internal/defaults" "gitea.maximumdirect.net/eric/promptkit/internal/domain" ) @@ -262,7 +263,7 @@ func openAIChatRequestPayload(req openAIChatRequest) (map[string]any, error) { if key == "" { return nil, errors.New("extra_params key must not be empty") } - if _, reserved := reservedOpenAIChatRequestFields[key]; reserved { + if backend.IsReservedRequestField(key) { return nil, fmt.Errorf("extra_params key %q collides with reserved request field", key) } if _, err := json.Marshal(value); err != nil { @@ -274,18 +275,6 @@ func openAIChatRequestPayload(req openAIChatRequest) (map[string]any, error) { return out, nil } -var reservedOpenAIChatRequestFields = map[string]struct{}{ - "model": {}, - "session_id": {}, - "messages": {}, - "temperature": {}, - "max_tokens": {}, - "top_p": {}, - "service_tier": {}, - "reasoning_effort": {}, - "response_format": {}, -} - type openAIChatRequestMessage struct { Role string `json:"role"` Content any `json:"content"`