Verify self-contained schema preparation and submission
This commit is contained in:
@@ -884,6 +884,12 @@ secret-handling policies.
|
||||
|
||||
## Extension Interfaces
|
||||
|
||||
JSON Schema validation and native structured output use a self-contained root
|
||||
document. Internal references are supported; references to other files or
|
||||
network resources fail during preparation. `WithSchemaFile` selects one root
|
||||
file, while `WithSchemaFS` and `SchemaDir` select a root source. Prepared
|
||||
executions retain their captured schema even if the source later changes.
|
||||
|
||||
Inject an [`LLMClient` or `ArtifactReader`](../../types.go) when the built-in
|
||||
behavior does not fit the application. Their GoDoc defines concurrent use,
|
||||
context handling, ownership of copied values, nil responses, and preservation
|
||||
|
||||
@@ -418,8 +418,9 @@ generic inheritance and layer mechanics, consult
|
||||
## Schemas
|
||||
|
||||
Schemas are JSON documents selected by a prompt or request `schema_path`.
|
||||
Directory and `fs.FS` schema paths resolve within the configured root;
|
||||
referenced nested schemas resolve relative to the owning schema document.
|
||||
Directory and `fs.FS` schema paths resolve within the configured root. Each
|
||||
schema must be self-contained: in-document references, IDs, and anchors are
|
||||
supported, but references requiring another file or network resource fail.
|
||||
`WithSchemaFile` exposes one schema under its base name. An unreadable,
|
||||
invalid, or unresolvable schema causes an operational validation error.
|
||||
Valid JSON that fails the schema instead yields a failed validation result.
|
||||
|
||||
@@ -216,10 +216,10 @@ instead of first making a response-sized string copy.
|
||||
|
||||
Every preparation operation creates one operation-local validation plan. None,
|
||||
basic, and JSON modes retain the effective output contract without source
|
||||
access. JSON Schema mode loads the root document once, resolves and compiles
|
||||
each transitive reference, and retains the compiled validator. Schema compiler
|
||||
resources use canonical escaped file or private-scheme URLs; loaders decode
|
||||
their paths once and enforce the configured source boundary. The
|
||||
access. JSON Schema mode loads the root document once, compiles its declared
|
||||
schema locations, and retains the compiled root validator. External resources
|
||||
are rejected without file or network loading; in-document references remain
|
||||
supported. Root resources use canonical escaped file or private-scheme URLs. The
|
||||
provider-facing structured-output metadata uses the root document captured by
|
||||
the same plan.
|
||||
|
||||
|
||||
@@ -375,6 +375,10 @@ recursive/dynamic references, embedded dialects, and independent instances.
|
||||
validate/engine tests finish the schema transition. No local-only mode, bundler,
|
||||
new dialect, or release/publication prerequisite is introduced.
|
||||
|
||||
Completion: schema-file option, frozen preparation, native submission, and
|
||||
external-reference rejection are covered through public APIs. Root, validation,
|
||||
and engine tests pass on Go 1.27.3; current schema guidance is aligned.
|
||||
|
||||
## Stage 14: Merge Provider Maps Into One Owned Lower Copy
|
||||
|
||||
**Catalog:** 19. **Files:** configuration targets and focused overlay tests.
|
||||
|
||||
@@ -213,7 +213,8 @@ func WithSchemaFS(fsys fs.FS, root string) Option {
|
||||
//
|
||||
// Prompt schema_path values refer to the file's base name. path must name an
|
||||
// existing non-directory file when NewEngine applies the option. This option
|
||||
// replaces Config.SchemaDir and earlier schema-source options.
|
||||
// replaces Config.SchemaDir and earlier schema-source options. The selected
|
||||
// schema must be self-contained; external resources are not loaded.
|
||||
func WithSchemaFile(path string) Option {
|
||||
return optionFunc(func(options *engineOptions) error {
|
||||
return options.internal.SetSchemaFile(path)
|
||||
|
||||
@@ -73,7 +73,7 @@ func (o *Options) SetSchemaFS(fsys fs.FS, root string) error {
|
||||
|
||||
// SetSchemaFile selects a single schema file.
|
||||
func (o *Options) SetSchemaFile(path string) error {
|
||||
fsys, root, _, err := fileSource(path)
|
||||
fsys, root, err := fileSource(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -208,23 +208,23 @@ func validateSourceFS(fsys fs.FS, root string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileSource(name string) (fs.FS, string, string, error) {
|
||||
func fileSource(name string) (fs.FS, string, error) {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return nil, "", "", fmt.Errorf("source path is required")
|
||||
return nil, "", fmt.Errorf("source path is required")
|
||||
}
|
||||
directory := filepath.Dir(name)
|
||||
base := filepath.Base(name)
|
||||
if base == "." || base == string(filepath.Separator) {
|
||||
return nil, "", "", fmt.Errorf("source path must name a file")
|
||||
return nil, "", fmt.Errorf("source path must name a file")
|
||||
}
|
||||
info, err := os.Stat(name)
|
||||
if err != nil {
|
||||
return nil, "", "", fmt.Errorf("failed to access source file %q: %w", name, err)
|
||||
return nil, "", fmt.Errorf("failed to access source file %q: %w", name, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, "", "", fmt.Errorf("source path %q must be a file", name)
|
||||
return nil, "", fmt.Errorf("source path %q must be a file", name)
|
||||
}
|
||||
return os.DirFS(directory), filepath.ToSlash(base), directory, nil
|
||||
return os.DirFS(directory), filepath.ToSlash(base), nil
|
||||
}
|
||||
|
||||
func (e *Engine) InspectPrompt(ctx context.Context, id string) (*domain.PromptInspection, error) {
|
||||
|
||||
@@ -813,8 +813,9 @@ func preparedSchemaSource() fstest.MapFS {
|
||||
"title": "original root",
|
||||
"type": "object",
|
||||
"required": ["value"],
|
||||
"$defs": {"value": {"type":"integer", "minimum":2}},
|
||||
"properties": {
|
||||
"value": {"$ref": "value.json"}
|
||||
"value": {"$ref": "#/$defs/value"}
|
||||
}
|
||||
}`)},
|
||||
"value.json": &fstest.MapFile{Data: []byte(`{
|
||||
|
||||
90
schema_file_contract_test.go
Normal file
90
schema_file_contract_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package promptkit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.maximumdirect.net/configkit"
|
||||
"go.maximumdirect.net/promptkit"
|
||||
)
|
||||
|
||||
func TestSchemaFilePreparationAndNativeSubmission(t *testing.T) {
|
||||
firstDir, secondDir := t.TempDir(), t.TempDir()
|
||||
first, second := filepath.Join(firstDir, "schema.json"), filepath.Join(secondDir, "schema.json")
|
||||
if err := os.WriteFile(first, []byte(`{"type":"string"}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(second, []byte(`{"$defs":{"value":{"type":"integer"}},"$ref":"#/$defs/value"}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := configkit.Input{Source: configkit.Mapping("schema test", map[string]any{
|
||||
"profiles": []any{map[string]any{"id": "profile", "endpoint": "https://example.invalid/v1", "model": "model"}},
|
||||
"prompts": []any{map[string]any{"id": "prompt", "default_profile": "profile", "messages": []any{map[string]any{"role": "user", "content": "hello"}}, "output": map[string]any{"format": "json", "validation_mode": "json_schema", "schema_path": "schema.json"}}},
|
||||
})}
|
||||
var payload map[string]any
|
||||
client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"3"}}]}`))}, nil
|
||||
})}
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: client, Sources: promptkit.ConfigurationSources{Main: &input}}, promptkit.WithSchemaFile(first), promptkit.WithSchemaFile(second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := engine.ValidateRequest(context.Background(), promptkit.RunRequest{PromptID: "prompt"}); err != nil {
|
||||
t.Fatalf("validate request: %v", err)
|
||||
}
|
||||
prepared, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prompt"})
|
||||
if err != nil || prepared.Details().StructuredOutput == nil {
|
||||
t.Fatalf("prepared = %+v, %v", prepared, err)
|
||||
}
|
||||
if err := os.Remove(second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := engine.RunPrepared(context.Background(), prepared)
|
||||
if err != nil || result.Validation.Status != promptkit.ValidationPassed {
|
||||
t.Fatalf("run = %+v, %v", result, err)
|
||||
}
|
||||
encoded, err := json.Marshal(payload["response_format"])
|
||||
if err != nil || !strings.Contains(string(encoded), `"$defs"`) || !strings.Contains(string(encoded), `#/$defs/value`) {
|
||||
t.Fatalf("wire schema = %s, %v", encoded, err)
|
||||
}
|
||||
if _, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prompt"}); !errors.Is(err, promptkit.ErrValidation) {
|
||||
t.Fatalf("missing root error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaFileRejectsInvalidSourcesAndExternalReferences(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range []string{filepath.Join(dir, "missing.json"), dir} {
|
||||
if _, err := promptkit.NewEngine(promptkit.Config{}, promptkit.WithSchemaFile(name)); !errors.Is(err, promptkit.ErrInvalidConfig) {
|
||||
t.Fatalf("source %q = %v", name, err)
|
||||
}
|
||||
}
|
||||
root := filepath.Join(dir, "schema.json")
|
||||
if err := os.WriteFile(root, []byte(`{"$defs":{"unused":{"$ref":"other.json"}}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := configkit.Input{Source: configkit.Mapping("schema test", map[string]any{
|
||||
"profiles": []any{map[string]any{"id": "profile", "endpoint": "https://example.invalid/v1", "model": "model"}},
|
||||
"prompts": []any{map[string]any{"id": "prompt", "default_profile": "profile", "messages": []any{map[string]any{"role": "user", "content": "hello"}}, "output": map[string]any{"format": "json", "validation_mode": "json_schema", "schema_path": "schema.json"}}},
|
||||
})}
|
||||
calls := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { calls++; return nil, errors.New("unexpected dispatch") })}
|
||||
engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: client, Sources: promptkit.ConfigurationSources{Main: &input}}, promptkit.WithSchemaFile(root))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = engine.Run(context.Background(), promptkit.RunRequest{PromptID: "prompt"})
|
||||
if !errors.Is(err, promptkit.ErrValidation) || calls != 0 {
|
||||
t.Fatalf("external reference = %v, calls = %d", err, calls)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user