Add immutable backend registry foundation
This commit is contained in:
@@ -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) |
|
| 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/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) |
|
| `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/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/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) |
|
| `internal/filecatalog` | Provides deterministic YAML discovery and path helpers for operating-system filesystems and `fs.FS` sources. | [File catalog](../../internal/filecatalog/catalog.go) |
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ The implemented internal components consist of:
|
|||||||
|
|
||||||
- `internal/domain`, which owns framework data values shared by later internal
|
- `internal/domain`, which owns framework data values shared by later internal
|
||||||
components;
|
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
|
- `internal/defaults`, which owns application-neutral framework defaults and
|
||||||
constructs the default execution target;
|
constructs the default execution target;
|
||||||
- `internal/filecatalog`, which discovers YAML files and provides source-path
|
- `internal/filecatalog`, which discovers YAML files and provides source-path
|
||||||
@@ -72,9 +74,11 @@ downstream consumers, including Scriptorium
|
|||||||
narrow injected abstractions
|
narrow injected abstractions
|
||||||
```
|
```
|
||||||
|
|
||||||
The facade coordinates internal components and adapts the supported public
|
The backend registry depends on the domain model, and the model client reuses
|
||||||
extension interfaces to narrow internal abstractions. Internal components must
|
its OpenAI-compatible reserved request-field rule. The facade coordinates
|
||||||
not depend on consumers or on Scriptorium.
|
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
|
## Repository And Consumer Boundary
|
||||||
|
|
||||||
|
|||||||
@@ -245,6 +245,8 @@ where the existing error boundary permits it.
|
|||||||
|
|
||||||
## Stage 1 — Registry Foundation
|
## Stage 1 — Registry Foundation
|
||||||
|
|
||||||
|
**Status:** Complete.
|
||||||
|
|
||||||
### Goal
|
### Goal
|
||||||
|
|
||||||
Introduce the internal domain and immutable registry without changing the
|
Introduce the internal domain and immutable registry without changing the
|
||||||
|
|||||||
225
internal/backend/json_copy.go
Normal file
225
internal/backend/json_copy.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
172
internal/backend/registry.go
Normal file
172
internal/backend/registry.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
256
internal/backend/registry_test.go
Normal file
256
internal/backend/registry_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -157,6 +157,14 @@ type PromptMessageTemplate struct {
|
|||||||
CacheControl *CacheControl `yaml:"cache_control,omitempty" json:"cache_control,omitempty"`
|
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.
|
// ExecutionProfile describes how and where to execute a model.
|
||||||
type ExecutionProfile struct {
|
type ExecutionProfile struct {
|
||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
@@ -262,7 +263,7 @@ func openAIChatRequestPayload(req openAIChatRequest) (map[string]any, error) {
|
|||||||
if key == "" {
|
if key == "" {
|
||||||
return nil, errors.New("extra_params key must not be empty")
|
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)
|
return nil, fmt.Errorf("extra_params key %q collides with reserved request field", key)
|
||||||
}
|
}
|
||||||
if _, err := json.Marshal(value); err != nil {
|
if _, err := json.Marshal(value); err != nil {
|
||||||
@@ -274,18 +275,6 @@ func openAIChatRequestPayload(req openAIChatRequest) (map[string]any, error) {
|
|||||||
return out, nil
|
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 {
|
type openAIChatRequestMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content any `json:"content"`
|
Content any `json:"content"`
|
||||||
|
|||||||
Reference in New Issue
Block a user