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) |
|
||||
| `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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user