Verify self-contained schema preparation and submission

This commit is contained in:
2026-11-05 13:59:40 +00:00
parent cddb2b389c
commit 0aceff8c08
8 changed files with 118 additions and 15 deletions

View File

@@ -884,6 +884,12 @@ secret-handling policies.
## Extension Interfaces ## 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 Inject an [`LLMClient` or `ArtifactReader`](../../types.go) when the built-in
behavior does not fit the application. Their GoDoc defines concurrent use, behavior does not fit the application. Their GoDoc defines concurrent use,
context handling, ownership of copied values, nil responses, and preservation context handling, ownership of copied values, nil responses, and preservation

View File

@@ -418,8 +418,9 @@ generic inheritance and layer mechanics, consult
## Schemas ## Schemas
Schemas are JSON documents selected by a prompt or request `schema_path`. Schemas are JSON documents selected by a prompt or request `schema_path`.
Directory and `fs.FS` schema paths resolve within the configured root; Directory and `fs.FS` schema paths resolve within the configured root. Each
referenced nested schemas resolve relative to the owning schema document. 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, `WithSchemaFile` exposes one schema under its base name. An unreadable,
invalid, or unresolvable schema causes an operational validation error. invalid, or unresolvable schema causes an operational validation error.
Valid JSON that fails the schema instead yields a failed validation result. Valid JSON that fails the schema instead yields a failed validation result.

View File

@@ -216,10 +216,10 @@ instead of first making a response-sized string copy.
Every preparation operation creates one operation-local validation plan. None, Every preparation operation creates one operation-local validation plan. None,
basic, and JSON modes retain the effective output contract without source basic, and JSON modes retain the effective output contract without source
access. JSON Schema mode loads the root document once, resolves and compiles access. JSON Schema mode loads the root document once, compiles its declared
each transitive reference, and retains the compiled validator. Schema compiler schema locations, and retains the compiled root validator. External resources
resources use canonical escaped file or private-scheme URLs; loaders decode are rejected without file or network loading; in-document references remain
their paths once and enforce the configured source boundary. The supported. Root resources use canonical escaped file or private-scheme URLs. The
provider-facing structured-output metadata uses the root document captured by provider-facing structured-output metadata uses the root document captured by
the same plan. the same plan.

View File

@@ -375,6 +375,10 @@ recursive/dynamic references, embedded dialects, and independent instances.
validate/engine tests finish the schema transition. No local-only mode, bundler, validate/engine tests finish the schema transition. No local-only mode, bundler,
new dialect, or release/publication prerequisite is introduced. 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 ## Stage 14: Merge Provider Maps Into One Owned Lower Copy
**Catalog:** 19. **Files:** configuration targets and focused overlay tests. **Catalog:** 19. **Files:** configuration targets and focused overlay tests.

View File

@@ -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 // 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 // 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 { func WithSchemaFile(path string) Option {
return optionFunc(func(options *engineOptions) error { return optionFunc(func(options *engineOptions) error {
return options.internal.SetSchemaFile(path) return options.internal.SetSchemaFile(path)

View File

@@ -73,7 +73,7 @@ func (o *Options) SetSchemaFS(fsys fs.FS, root string) error {
// SetSchemaFile selects a single schema file. // SetSchemaFile selects a single schema file.
func (o *Options) SetSchemaFile(path string) error { func (o *Options) SetSchemaFile(path string) error {
fsys, root, _, err := fileSource(path) fsys, root, err := fileSource(path)
if err != nil { if err != nil {
return err return err
} }
@@ -208,23 +208,23 @@ func validateSourceFS(fsys fs.FS, root string) error {
return nil return nil
} }
func fileSource(name string) (fs.FS, string, string, error) { func fileSource(name string) (fs.FS, string, error) {
if strings.TrimSpace(name) == "" { if strings.TrimSpace(name) == "" {
return nil, "", "", fmt.Errorf("source path is required") return nil, "", fmt.Errorf("source path is required")
} }
directory := filepath.Dir(name) directory := filepath.Dir(name)
base := filepath.Base(name) base := filepath.Base(name)
if base == "." || base == string(filepath.Separator) { 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) info, err := os.Stat(name)
if err != nil { 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() { 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) { func (e *Engine) InspectPrompt(ctx context.Context, id string) (*domain.PromptInspection, error) {

View File

@@ -813,8 +813,9 @@ func preparedSchemaSource() fstest.MapFS {
"title": "original root", "title": "original root",
"type": "object", "type": "object",
"required": ["value"], "required": ["value"],
"$defs": {"value": {"type":"integer", "minimum":2}},
"properties": { "properties": {
"value": {"$ref": "value.json"} "value": {"$ref": "#/$defs/value"}
} }
}`)}, }`)},
"value.json": &fstest.MapFile{Data: []byte(`{ "value.json": &fstest.MapFile{Data: []byte(`{

View 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)
}
}