Compare commits
7 Commits
0d56d986af
...
9e68a2bbf7
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e68a2bbf7 | |||
| e4899fb54d | |||
| 18b12a25c1 | |||
| 7e94ab133b | |||
| 62b26fb29e | |||
| ebc1f3e919 | |||
| ad1f2674ab |
28
README.md
28
README.md
@@ -1,18 +1,34 @@
|
|||||||
# Promptkit
|
# Promptkit
|
||||||
|
|
||||||
Promptkit is the reusable Go prompt-execution framework being separated from
|
Promptkit is a reusable Go library for preparing and executing prompt-defined
|
||||||
Scriptorium. Its module path is:
|
LLM workflows. Its module path is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
gitea.maximumdirect.net/eric/promptkit
|
gitea.maximumdirect.net/eric/promptkit
|
||||||
```
|
```
|
||||||
|
|
||||||
The repository currently provides the independent Go module and its root public
|
The root `promptkit` package provides the supported public engine. Consumers
|
||||||
package boundary. Framework behavior and consumer APIs have not yet been
|
can configure filesystem or in-memory prompt, profile, and schema sources,
|
||||||
extracted, so there is no installation or usage example at this time.
|
prepare requests without generation, run requests with the built-in
|
||||||
|
OpenAI-compatible client, or inject their own model client and artifact reader.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
Run the maintained offline preparation example from the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./examples/go-library/prepare
|
||||||
|
```
|
||||||
|
|
||||||
|
It loads a repository-local prompt, supplies an in-memory profile and inline
|
||||||
|
input, and prints deterministic preparation metadata without contacting a
|
||||||
|
provider or requiring credentials. Read the
|
||||||
|
[example source](examples/go-library/prepare/main.go), the
|
||||||
|
[Go package consumer guide](docs/consumers/pkg-promptkit.md), and the
|
||||||
|
[framework format reference](docs/formats.md) to build a consumer workflow.
|
||||||
|
|
||||||
Contributors should start with the [development guide](docs/development.md).
|
Contributors should start with the [development guide](docs/development.md).
|
||||||
The [architecture policy](docs/policy/architecture.md) defines the library
|
The [architecture policy](docs/policy/architecture.md) defines the library
|
||||||
boundary and constraints that future framework work must preserve.
|
boundary and constraints that framework work must preserve.
|
||||||
|
|
||||||
Promptkit is licensed under the [GNU General Public License version 3](LICENSE).
|
Promptkit is licensed under the [GNU General Public License version 3](LICENSE).
|
||||||
|
|||||||
89
architecture_test.go
Normal file
89
architecture_test.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const formerModulePath = "gitea.maximumdirect.net/eric/" + "scrip" + "torium"
|
||||||
|
|
||||||
|
func TestRepositoryDoesNotImportFormerModule(t *testing.T) {
|
||||||
|
violations, err := findFormerModuleImports(".")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("inspect repository imports: %v", err)
|
||||||
|
}
|
||||||
|
if len(violations) > 0 {
|
||||||
|
t.Fatalf("repository imports the former module:\n%s", strings.Join(violations, "\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormerModuleGuardFindsNestedImport(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
nested := filepath.Join(root, "nested", "package")
|
||||||
|
if err := os.MkdirAll(nested, 0o755); err != nil {
|
||||||
|
t.Fatalf("create nested package: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sourcePath := filepath.Join(nested, "violation.go")
|
||||||
|
source := "package nested\n\nimport _ " + strconv.Quote(formerModulePath+"/internal/domain") + "\n"
|
||||||
|
if err := os.WriteFile(sourcePath, []byte(source), 0o600); err != nil {
|
||||||
|
t.Fatalf("write nested source: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
violations, err := findFormerModuleImports(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("inspect nested imports: %v", err)
|
||||||
|
}
|
||||||
|
if len(violations) != 1 {
|
||||||
|
t.Fatalf("violations = %v, want one nested import", violations)
|
||||||
|
}
|
||||||
|
if !strings.Contains(violations[0], "violation.go") ||
|
||||||
|
!strings.Contains(violations[0], formerModulePath+"/internal/domain") {
|
||||||
|
t.Fatalf("violation = %q, want file and import path", violations[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findFormerModuleImports(root string) ([]string, error) {
|
||||||
|
var violations []string
|
||||||
|
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if entry.IsDir() {
|
||||||
|
switch entry.Name() {
|
||||||
|
case ".git", "generated", "vendor":
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if filepath.Ext(path) != ".go" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly|parser.ParseComments)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ast.IsGenerated(file) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, spec := range file.Imports {
|
||||||
|
importPath, err := strconv.Unquote(spec.Path.Value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if importPath == formerModulePath || strings.HasPrefix(importPath, formerModulePath+"/") {
|
||||||
|
violations = append(violations, path+": "+importPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return violations, err
|
||||||
|
}
|
||||||
39
artifact_reader.go
Normal file
39
artifact_reader.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
artifactadapter "gitea.maximumdirect.net/eric/promptkit/internal/artifact"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNilArtifactReaderResponse = errors.New("artifact reader returned nil artifact without error")
|
||||||
|
|
||||||
|
type publicArtifactReaderAdapter struct {
|
||||||
|
reader ArtifactReader
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ artifactadapter.Reader = publicArtifactReaderAdapter{}
|
||||||
|
|
||||||
|
func (a publicArtifactReaderAdapter) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
||||||
|
artifact, err := a.reader.Read(ctx, ArtifactRef{
|
||||||
|
Type: ArtifactRefType(ref.Type),
|
||||||
|
URI: ref.URI,
|
||||||
|
Body: ref.Body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if artifact == nil {
|
||||||
|
return nil, errNilArtifactReaderResponse
|
||||||
|
}
|
||||||
|
return &domain.Artifact{
|
||||||
|
Name: artifact.Name,
|
||||||
|
ContentType: artifact.ContentType,
|
||||||
|
Body: copyBytes(artifact.Body),
|
||||||
|
URI: artifact.URI,
|
||||||
|
Size: artifact.Size,
|
||||||
|
Hash: artifact.Hash,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
36
artifact_reader_internal_test.go
Normal file
36
artifact_reader_internal_test.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublicArtifactReaderAdapterCopiesBody(t *testing.T) {
|
||||||
|
reader := internalArtifactReaderFake{
|
||||||
|
artifact: &Artifact{Body: []byte("original")},
|
||||||
|
}
|
||||||
|
adapter := publicArtifactReaderAdapter{reader: &reader}
|
||||||
|
|
||||||
|
artifact, err := adapter.Read(context.Background(), domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefInline,
|
||||||
|
URI: "memory://input",
|
||||||
|
Body: "input",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read artifact: %v", err)
|
||||||
|
}
|
||||||
|
artifact.Body[0] = 'X'
|
||||||
|
if got := string(reader.artifact.Body); got != "original" {
|
||||||
|
t.Fatalf("reader artifact body was mutated: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type internalArtifactReaderFake struct {
|
||||||
|
artifact *Artifact
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *internalArtifactReaderFake) Read(context.Context, ArtifactRef) (*Artifact, error) {
|
||||||
|
return r.artifact, nil
|
||||||
|
}
|
||||||
406
convert.go
Normal file
406
convert.go
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
|
||||||
|
execution, err := toDomainExecutionTargetOverride(req.Execution)
|
||||||
|
if err != nil {
|
||||||
|
return domain.RunRequest{}, err
|
||||||
|
}
|
||||||
|
return domain.RunRequest{
|
||||||
|
PromptID: req.PromptID,
|
||||||
|
PromptVersion: req.PromptVersion,
|
||||||
|
ProfileID: req.ProfileID,
|
||||||
|
APIKey: req.APIKey,
|
||||||
|
Inputs: toDomainArtifactRefMap(req.Inputs),
|
||||||
|
Vars: copyStringMap(req.Vars),
|
||||||
|
Execution: execution,
|
||||||
|
Validation: toDomainOutputContractPtr(req.Validation),
|
||||||
|
Metadata: copyStringMap(req.Metadata),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainPreparedRun(prepared *domain.PreparedRun) *PreparedRun {
|
||||||
|
if prepared == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &PreparedRun{
|
||||||
|
PromptID: prepared.PromptID,
|
||||||
|
PromptVersion: prepared.PromptVersion,
|
||||||
|
PromptHash: prepared.PromptHash,
|
||||||
|
SelectedProfileID: prepared.SelectedProfileID,
|
||||||
|
EffectiveModelParams: fromDomainExecutionTarget(prepared.EffectiveModelParams),
|
||||||
|
OutputContract: fromDomainOutputContract(prepared.OutputContract),
|
||||||
|
StructuredOutput: fromDomainStructuredOutputSpec(prepared.StructuredOutput),
|
||||||
|
InputHashes: copyStringMap(prepared.InputHashes),
|
||||||
|
SessionID: prepared.SessionID,
|
||||||
|
RenderedPromptHash: prepared.RenderedPromptHash,
|
||||||
|
Messages: fromDomainRenderedMessages(prepared.Messages),
|
||||||
|
StartTime: prepared.StartTime,
|
||||||
|
EndTime: prepared.EndTime,
|
||||||
|
DurationMS: prepared.DurationMS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainRunResult(result *domain.RunResult) *RunResult {
|
||||||
|
if result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &RunResult{
|
||||||
|
RunID: result.RunID,
|
||||||
|
Artifact: fromDomainArtifact(result.Artifact),
|
||||||
|
RawOutput: result.RawOutput,
|
||||||
|
Validation: fromDomainValidationResult(result.Validation),
|
||||||
|
PromptID: result.PromptID,
|
||||||
|
PromptVersion: result.PromptVersion,
|
||||||
|
PromptHash: result.PromptHash,
|
||||||
|
RenderedPromptHash: result.RenderedPromptHash,
|
||||||
|
SelectedProfileID: result.SelectedProfileID,
|
||||||
|
ModelName: result.ModelName,
|
||||||
|
Endpoint: result.Endpoint,
|
||||||
|
EffectiveModelParams: fromDomainExecutionTarget(result.EffectiveModelParams),
|
||||||
|
InputHashes: copyStringMap(result.InputHashes),
|
||||||
|
Usage: fromDomainTokenUsage(result.Usage),
|
||||||
|
StartTime: result.StartTime,
|
||||||
|
EndTime: result.EndTime,
|
||||||
|
Duration: result.Duration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainGenerateRequest(req domain.GenerateRequest) GenerateRequest {
|
||||||
|
return GenerateRequest{
|
||||||
|
Prompt: fromDomainRenderedPrompt(req.Prompt),
|
||||||
|
Target: fromDomainExecutionTarget(req.Target),
|
||||||
|
TargetPresence: fromDomainExecutionTargetPresence(req.TargetPresence),
|
||||||
|
StructuredOutput: fromDomainStructuredOutputSpec(req.StructuredOutput),
|
||||||
|
APIKey: req.Target.APIKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainGenerateResponse(resp *GenerateResponse) *domain.GenerateResponse {
|
||||||
|
if resp == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &domain.GenerateResponse{
|
||||||
|
Content: resp.Content,
|
||||||
|
Usage: toDomainTokenUsage(resp.Usage),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainRenderedPrompt(prompt domain.RenderedPrompt) RenderedPrompt {
|
||||||
|
return RenderedPrompt{
|
||||||
|
SessionID: prompt.SessionID,
|
||||||
|
Messages: fromDomainRenderedMessages(prompt.Messages),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainArtifactRefMap(src map[string]ArtifactRef) map[string]domain.ArtifactRef {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]domain.ArtifactRef, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
out[k] = toDomainArtifactRef(v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainArtifactRef(ref ArtifactRef) domain.ArtifactRef {
|
||||||
|
return domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefType(ref.Type),
|
||||||
|
URI: ref.URI,
|
||||||
|
Body: ref.Body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainArtifact(artifact domain.Artifact) Artifact {
|
||||||
|
return Artifact{
|
||||||
|
Name: artifact.Name,
|
||||||
|
ContentType: artifact.ContentType,
|
||||||
|
Body: copyBytes(artifact.Body),
|
||||||
|
URI: artifact.URI,
|
||||||
|
Size: artifact.Size,
|
||||||
|
Hash: artifact.Hash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainExecutionTargetOverride(override *ExecutionTargetOverride) (*domain.ExecutionTargetOverride, error) {
|
||||||
|
if override == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
extraParams, err := copyPublicJSONMap(override.ExtraParams)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &domain.ExecutionTargetOverride{
|
||||||
|
Endpoint: override.Endpoint,
|
||||||
|
Model: override.Model,
|
||||||
|
Temperature: copyFloat64Ptr(override.Temperature),
|
||||||
|
MaxTokens: copyIntPtr(override.MaxTokens),
|
||||||
|
TopP: copyFloat64Ptr(override.TopP),
|
||||||
|
TimeoutSeconds: copyIntPtr(override.TimeoutSeconds),
|
||||||
|
ServiceTier: override.ServiceTier,
|
||||||
|
ReasoningEffort: override.ReasoningEffort,
|
||||||
|
APIKeyEnv: override.APIKeyEnv,
|
||||||
|
ExtraParams: extraParams,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainExecutionTarget(target domain.ExecutionTarget) ExecutionTarget {
|
||||||
|
return ExecutionTarget{
|
||||||
|
Endpoint: target.Endpoint,
|
||||||
|
Model: target.Model,
|
||||||
|
Temperature: target.Temperature,
|
||||||
|
MaxTokens: target.MaxTokens,
|
||||||
|
TopP: target.TopP,
|
||||||
|
TimeoutSeconds: target.TimeoutSeconds,
|
||||||
|
ServiceTier: target.ServiceTier,
|
||||||
|
ReasoningEffort: target.ReasoningEffort,
|
||||||
|
APIKeyEnv: target.APIKeyEnv,
|
||||||
|
ExtraParams: copyAnyMap(target.ExtraParams),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainExecutionTargetPresence(presence domain.ExecutionTargetPresence) ExecutionTargetPresence {
|
||||||
|
return ExecutionTargetPresence{
|
||||||
|
Temperature: presence.Temperature,
|
||||||
|
MaxTokens: presence.MaxTokens,
|
||||||
|
TopP: presence.TopP,
|
||||||
|
TimeoutSeconds: presence.TimeoutSeconds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainOutputContractPtr(contract *OutputContract) *domain.OutputContract {
|
||||||
|
if contract == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := toDomainOutputContract(*contract)
|
||||||
|
return &out
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainOutputContract(contract OutputContract) domain.OutputContract {
|
||||||
|
return domain.OutputContract{
|
||||||
|
Format: domain.OutputFormat(contract.Format),
|
||||||
|
ValidationMode: domain.ValidationMode(contract.ValidationMode),
|
||||||
|
SchemaPath: contract.SchemaPath,
|
||||||
|
RepairAttempts: contract.RepairAttempts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainOutputContract(contract domain.OutputContract) OutputContract {
|
||||||
|
return OutputContract{
|
||||||
|
Format: OutputFormat(contract.Format),
|
||||||
|
ValidationMode: ValidationMode(contract.ValidationMode),
|
||||||
|
SchemaPath: contract.SchemaPath,
|
||||||
|
RepairAttempts: contract.RepairAttempts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainValidationResult(result domain.ValidationResult) ValidationResult {
|
||||||
|
return ValidationResult{
|
||||||
|
Status: ValidationStatus(result.Status),
|
||||||
|
Mode: ValidationMode(result.Mode),
|
||||||
|
Errors: copyStringSlice(result.Errors),
|
||||||
|
SchemaPath: result.SchemaPath,
|
||||||
|
RepairAttempts: result.RepairAttempts,
|
||||||
|
IsValid: result.IsValid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainTokenUsage(usage domain.TokenUsage) TokenUsage {
|
||||||
|
return TokenUsage{
|
||||||
|
PromptTokens: usage.PromptTokens,
|
||||||
|
CompletionTokens: usage.CompletionTokens,
|
||||||
|
TotalTokens: usage.TotalTokens,
|
||||||
|
CachedTokens: usage.CachedTokens,
|
||||||
|
CacheWriteTokens: usage.CacheWriteTokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainTokenUsage(usage TokenUsage) domain.TokenUsage {
|
||||||
|
return domain.TokenUsage{
|
||||||
|
PromptTokens: usage.PromptTokens,
|
||||||
|
CompletionTokens: usage.CompletionTokens,
|
||||||
|
TotalTokens: usage.TotalTokens,
|
||||||
|
CachedTokens: usage.CachedTokens,
|
||||||
|
CacheWriteTokens: usage.CacheWriteTokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainRenderedMessages(messages []domain.RenderedMessage) []RenderedMessage {
|
||||||
|
if messages == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]RenderedMessage, len(messages))
|
||||||
|
for i, msg := range messages {
|
||||||
|
out[i] = RenderedMessage{
|
||||||
|
Role: msg.Role,
|
||||||
|
Content: msg.Content,
|
||||||
|
CacheControl: fromDomainCacheControl(msg.CacheControl),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainCacheControl(cacheControl *domain.CacheControl) *CacheControl {
|
||||||
|
if cacheControl == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &CacheControl{
|
||||||
|
Type: CacheControlType(cacheControl.Type),
|
||||||
|
TTL: cacheControl.TTL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainStructuredOutputSpec(spec *domain.StructuredOutputSpec) *StructuredOutputSpec {
|
||||||
|
if spec == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := &StructuredOutputSpec{
|
||||||
|
Type: StructuredOutputType(spec.Type),
|
||||||
|
}
|
||||||
|
if spec.JSONSchema != nil {
|
||||||
|
out.JSONSchema = &StructuredOutputJSONSpec{
|
||||||
|
Name: spec.JSONSchema.Name,
|
||||||
|
Strict: spec.JSONSchema.Strict,
|
||||||
|
Schema: copyAny(spec.JSONSchema.Schema),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyStringMap(src map[string]string) map[string]string {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyAnyMap(src map[string]any) map[string]any {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]any, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
out[k] = copyAny(v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyAny(value any) any {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch v := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
return copyAnyMap(v)
|
||||||
|
case []any:
|
||||||
|
out := make([]any, len(v))
|
||||||
|
for i, item := range v {
|
||||||
|
out[i] = copyAny(item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []string:
|
||||||
|
return copyStringSlice(v)
|
||||||
|
case []byte:
|
||||||
|
return copyBytes(v)
|
||||||
|
default:
|
||||||
|
return copyReflectValue(reflect.ValueOf(value)).Interface()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyReflectValue(value reflect.Value) reflect.Value {
|
||||||
|
if !value.IsValid() {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Interface:
|
||||||
|
if value.IsNil() {
|
||||||
|
return reflect.Zero(value.Type())
|
||||||
|
}
|
||||||
|
copied := copyReflectValue(value.Elem())
|
||||||
|
if copied.IsValid() && copied.Type().AssignableTo(value.Type()) {
|
||||||
|
return copied
|
||||||
|
}
|
||||||
|
out := reflect.New(value.Type()).Elem()
|
||||||
|
out.Set(copied)
|
||||||
|
return out
|
||||||
|
case reflect.Pointer:
|
||||||
|
if value.IsNil() {
|
||||||
|
return reflect.Zero(value.Type())
|
||||||
|
}
|
||||||
|
out := reflect.New(value.Type().Elem())
|
||||||
|
out.Elem().Set(copyReflectValue(value.Elem()))
|
||||||
|
return out
|
||||||
|
case reflect.Map:
|
||||||
|
if value.IsNil() {
|
||||||
|
return reflect.Zero(value.Type())
|
||||||
|
}
|
||||||
|
out := reflect.MakeMapWithSize(value.Type(), value.Len())
|
||||||
|
iter := value.MapRange()
|
||||||
|
for iter.Next() {
|
||||||
|
out.SetMapIndex(copyReflectValue(iter.Key()), copyReflectValue(iter.Value()))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case reflect.Slice:
|
||||||
|
if value.IsNil() {
|
||||||
|
return reflect.Zero(value.Type())
|
||||||
|
}
|
||||||
|
out := reflect.MakeSlice(value.Type(), value.Len(), value.Cap())
|
||||||
|
for i := 0; i < value.Len(); i++ {
|
||||||
|
out.Index(i).Set(copyReflectValue(value.Index(i)))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case reflect.Array:
|
||||||
|
out := reflect.New(value.Type()).Elem()
|
||||||
|
for i := 0; i < value.Len(); i++ {
|
||||||
|
out.Index(i).Set(copyReflectValue(value.Index(i)))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyStringSlice(src []string) []string {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, len(src))
|
||||||
|
copy(out, src)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyBytes(src []byte) []byte {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]byte, len(src))
|
||||||
|
copy(out, src)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFloat64Ptr(src *float64) *float64 {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := *src
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyIntPtr(src *int) *int {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := *src
|
||||||
|
return &v
|
||||||
|
}
|
||||||
8
doc.go
8
doc.go
@@ -1,2 +1,8 @@
|
|||||||
// Package promptkit defines the public package boundary for the Promptkit Go module.
|
// Package promptkit provides an embeddable engine for preparing and executing
|
||||||
|
// prompt-defined LLM workflows.
|
||||||
|
//
|
||||||
|
// Applications construct an Engine with NewEngine, select filesystem or
|
||||||
|
// in-memory definition sources with options, and use Prepare or Run to execute
|
||||||
|
// requests. Concrete repositories, validators, and outbound clients remain
|
||||||
|
// internal implementation details.
|
||||||
package promptkit
|
package promptkit
|
||||||
|
|||||||
151
docs/consumers/pkg-promptkit.md
Normal file
151
docs/consumers/pkg-promptkit.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# Package `promptkit`
|
||||||
|
|
||||||
|
Import path:
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
```
|
||||||
|
|
||||||
|
Package `promptkit` is the supported Go contract for in-process prompt
|
||||||
|
preparation and execution. The declarations and their GoDoc in the
|
||||||
|
[root package](../../doc.go) own the exact API; this guide explains how the
|
||||||
|
pieces are used together. The [framework format reference](../formats.md) owns
|
||||||
|
prompt, profile, and schema file contracts.
|
||||||
|
|
||||||
|
## Engine Construction And Sources
|
||||||
|
|
||||||
|
Construct an engine with [`NewEngine`, `Config`, and
|
||||||
|
`Option`](../../engine.go). `PromptDir` is required unless a prompt source
|
||||||
|
option is supplied. `ProfileDir` optionally overlays built-in profiles, and an
|
||||||
|
empty `SchemaDir` uses the current directory. `Timeout` is the transport-wide
|
||||||
|
safety cap for the built-in OpenAI-compatible client. An optional `HTTPClient`
|
||||||
|
is cloned; its positive timeout takes precedence.
|
||||||
|
|
||||||
|
Nil options are ignored. Invalid construction, including a nil injected client
|
||||||
|
or artifact reader, returns an error matching `ErrInvalidConfig`.
|
||||||
|
|
||||||
|
The [source options](../../engine.go) replace their matching directory source:
|
||||||
|
|
||||||
|
- `WithPromptFS` and `WithPromptFile` select prompt definitions;
|
||||||
|
- `WithProfileFS` and `WithProfileFile` overlay built-in profiles;
|
||||||
|
- `WithProfiles` adds in-memory profiles ahead of file and built-in profiles;
|
||||||
|
- `WithSchemaFS` and `WithSchemaFile` select JSON Schema documents;
|
||||||
|
- `WithLLMClient` replaces the built-in model client; and
|
||||||
|
- `WithArtifactReader` replaces the default reader for every input.
|
||||||
|
|
||||||
|
Source selection, path resolution, strict decoding, profile overlays, and
|
||||||
|
file-to-request precedence are defined in the
|
||||||
|
[framework format reference](../formats.md).
|
||||||
|
|
||||||
|
Per-generation timeout values from profiles or requests are independent of
|
||||||
|
the transport cap and caller context. An explicit request value of zero
|
||||||
|
disables only the per-generation deadline. The
|
||||||
|
[outbound integration contract](../integrations/openai-compatible-chat.md#timeout-and-cancellation)
|
||||||
|
defines the complete timeout layering.
|
||||||
|
|
||||||
|
## Preparation And Execution
|
||||||
|
|
||||||
|
[`Engine.Prepare` and `Engine.Run`](../../engine.go) accept the public
|
||||||
|
[`RunRequest`](../../types.go). `Prepare` resolves the prompt, profile, input
|
||||||
|
artifacts, validation contract, and rendered messages without calling an LLM.
|
||||||
|
`Run` performs the same preparation, calls the configured client, and validates
|
||||||
|
the generated content. The maintained
|
||||||
|
[offline preparation example](../../examples/go-library/prepare/main.go)
|
||||||
|
provides a complete runnable workflow using a prompt file, in-memory profile,
|
||||||
|
and inline input.
|
||||||
|
|
||||||
|
[`PreparedRun` and `RunResult`](../../types.go) expose copied public values.
|
||||||
|
Preparation returns effective settings, hashes, rendered messages, selected
|
||||||
|
profile, structured-output information, and timing without resolved secrets or
|
||||||
|
model output. Execution adds the generated artifact and raw output, validation
|
||||||
|
state, model metadata, usage, run ID, and duration.
|
||||||
|
|
||||||
|
A generated-content validation failure returns a result with
|
||||||
|
`Validation.Status == ValidationFailed`. An inability to perform validation
|
||||||
|
returns an error matching `ErrValidation`.
|
||||||
|
|
||||||
|
## Requests, Inputs, And Overrides
|
||||||
|
|
||||||
|
The [request and value declarations](../../types.go) own the available fields,
|
||||||
|
serialized constants, and result shapes. Use `File`, `Inline`, or
|
||||||
|
`InlineWithURI` to construct artifact references. The
|
||||||
|
[framework format reference](../formats.md) defines declared inputs, template
|
||||||
|
references, output contracts, and the relationship between file values and
|
||||||
|
request overrides.
|
||||||
|
|
||||||
|
`ExecutionTargetOverride` uses pointers for numeric settings so an explicit
|
||||||
|
zero remains distinct from no override. `ExtraParams` accepts JSON-compatible
|
||||||
|
strings, booleans, finite numbers, string-keyed objects, arrays or slices, and
|
||||||
|
nil. Unsupported values, non-string map keys, non-finite numbers, and cycles
|
||||||
|
match `ErrInvalidConfig` in profiles or `ErrInvalidRequest` in request
|
||||||
|
overrides.
|
||||||
|
|
||||||
|
Returned requests, profiles, prepared values, results, artifacts, maps, and
|
||||||
|
slices are isolated from internal engine state. Consumers and injected
|
||||||
|
extensions should not retain or mutate values owned by another caller.
|
||||||
|
|
||||||
|
## Profiles And Credentials
|
||||||
|
|
||||||
|
[`OpenAICompatibleProfile`](../../profiles.go) constructs an ordinary
|
||||||
|
in-memory profile for an OpenAI-compatible chat-completions endpoint.
|
||||||
|
`WithProfiles` rejects duplicate IDs in one call and gives in-memory profiles
|
||||||
|
precedence over explicit file sources and built-ins.
|
||||||
|
|
||||||
|
Raw API keys do not belong in profiles. File-backed profiles may name an
|
||||||
|
environment variable, while an in-memory profile can require a request key.
|
||||||
|
A direct `RunRequest.APIKey` is request-scoped and takes precedence over an
|
||||||
|
environment lookup for the built-in client. Profile fields, ranges, built-ins,
|
||||||
|
precedence, and credential rules are owned by the
|
||||||
|
[framework format reference](../formats.md).
|
||||||
|
|
||||||
|
API keys are excluded from JSON, prepared values, and results. The public
|
||||||
|
`String` and `GoString` methods report only whether a direct key is present.
|
||||||
|
Avoid reflection-based dumps of request structs, which can bypass that
|
||||||
|
redaction.
|
||||||
|
|
||||||
|
## Extension Interfaces
|
||||||
|
|
||||||
|
The [`LLMClient`, `GenerateRequest`, and
|
||||||
|
`GenerateResponse`](../../types.go) boundary lets a consumer replace model
|
||||||
|
generation. Injected clients receive copied rendered messages, effective
|
||||||
|
settings, explicit numeric-setting presence, structured-output constraints,
|
||||||
|
and the request-scoped key. They return generated content and token usage.
|
||||||
|
|
||||||
|
The [`ArtifactReader`](../../types.go) boundary replaces the default inline and
|
||||||
|
file reader for every input. Readers provide artifact content and metadata; the
|
||||||
|
engine fills an empty artifact name from the input-map key. A reader error
|
||||||
|
matches `ErrArtifactLoad` while preserving the original identity for
|
||||||
|
`errors.Is`. A nil artifact with a nil error is also an artifact-load failure.
|
||||||
|
|
||||||
|
Extensions should honor context cancellation and avoid logging raw prompts,
|
||||||
|
artifacts, or credentials.
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
The [public error declarations](../../engine.go) and
|
||||||
|
[mapping](../../errors.go) preserve these sentinel checks through `errors.Is`:
|
||||||
|
|
||||||
|
- `ErrInvalidConfig`
|
||||||
|
- `ErrInvalidRequest`
|
||||||
|
- `ErrPromptNotFound`
|
||||||
|
- `ErrProfileNotFound`
|
||||||
|
- `ErrProfileRequired`
|
||||||
|
- `ErrPromptLoad`
|
||||||
|
- `ErrProfileLoad`
|
||||||
|
- `ErrAPIKeyEnvMissing`
|
||||||
|
- `ErrArtifactLoad`
|
||||||
|
- `ErrPromptRender`
|
||||||
|
- `ErrLLMGenerate`
|
||||||
|
- `ErrValidation`
|
||||||
|
|
||||||
|
`ErrProfileRequired` and `ErrAPIKeyEnvMissing` also match
|
||||||
|
`ErrInvalidRequest`, allowing either broad request handling or a specific
|
||||||
|
condition. Wrapped collaborator errors retain their identity where the public
|
||||||
|
contract promises it.
|
||||||
|
|
||||||
|
## Consumer Boundary
|
||||||
|
|
||||||
|
Promptkit is an importable library. It does not own a command, inbound HTTP
|
||||||
|
API, process configuration, or deployment policy. Scriptorium is one
|
||||||
|
downstream application that maps this root package contract into those
|
||||||
|
application concerns.
|
||||||
@@ -31,11 +31,14 @@ Start with:
|
|||||||
|
|
||||||
| Task | Read before changing |
|
| Task | Read before changing |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Documentation or examples | The [documentation policy](policy/documentation.md) and the canonical owner of the affected contract. |
|
| Root public API | The [architecture policy](policy/architecture.md), [consumer guide](consumers/pkg-promptkit.md), [testing policy](policy/testing.md), and existing GoDoc. |
|
||||||
| Tests or test fixtures | The [testing policy](policy/testing.md), the owning package, and any focused internal document listed by the component overview. |
|
| Prompt, profile, or schema formats | The [framework format reference](formats.md), owning parser or validator package, and [documentation policy](policy/documentation.md). |
|
||||||
| Root public API, once implemented | The [architecture policy](policy/architecture.md), [root package declaration](../doc.go), [testing policy](policy/testing.md), and existing GoDoc. |
|
| Source loading or validation | The [framework format reference](formats.md), [internal source document](internal/sources.md), and owning package tests. |
|
||||||
| Internal package implementation, once introduced | The [architecture policy](policy/architecture.md), [internal component overview](internal/overview.md), and any focused internal document that the overview lists for that package. |
|
| Model-client behavior | The [OpenAI-compatible integration contract](integrations/openai-compatible-chat.md), [internal model-client document](internal/llm.md), and owning package tests. |
|
||||||
| Integration behavior, once introduced | The [architecture policy](policy/architecture.md), [documentation policy](policy/documentation.md), and the integration's owning contract under `docs/integrations/`. |
|
| Internal package implementation | The [architecture policy](policy/architecture.md), [internal component overview](internal/overview.md), and focused internal document listed for that package. |
|
||||||
|
| Tests or test fixtures | The [testing policy](policy/testing.md), owning package, and focused internal document listed by the component overview. |
|
||||||
|
| Maintained example | The [example](../examples/go-library/prepare/main.go), [consumer guide](consumers/pkg-promptkit.md), [framework format reference](formats.md), and [documentation policy](policy/documentation.md). |
|
||||||
|
| Documentation | The [documentation policy](policy/documentation.md) and canonical owner of every affected contract. |
|
||||||
| Release preparation or publication | The [release procedure](release.md). |
|
| Release preparation or publication | The [release procedure](release.md). |
|
||||||
|
|
||||||
For cross-cutting changes, follow every applicable row. Do not create
|
For cross-cutting changes, follow every applicable row. Do not create
|
||||||
@@ -50,8 +53,10 @@ validation from the Promptkit repository root:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./...
|
go test ./...
|
||||||
|
go test -race ./...
|
||||||
go vet ./...
|
go vet ./...
|
||||||
go build ./...
|
go build ./...
|
||||||
|
go run ./examples/go-library/prepare
|
||||||
```
|
```
|
||||||
|
|
||||||
Check formatting across every tracked Go file:
|
Check formatting across every tracked Go file:
|
||||||
@@ -75,7 +80,7 @@ behavior checked by the module.
|
|||||||
## Focused Validation
|
## Focused Validation
|
||||||
|
|
||||||
Use focused checks while iterating, then run the complete validation sequence
|
Use focused checks while iterating, then run the complete validation sequence
|
||||||
before accepting the change. The root package currently supports:
|
before accepting the change. The root package supports:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test .
|
go test .
|
||||||
@@ -83,17 +88,16 @@ go vet .
|
|||||||
go build .
|
go build .
|
||||||
```
|
```
|
||||||
|
|
||||||
Filter tests by name without assuming a future package layout:
|
Filter tests by name without assuming a fixed internal package layout:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./... -run 'TestName'
|
go test ./... -run 'TestName'
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace `TestName` with a useful regular expression. When internal packages are
|
Replace `TestName` with a useful regular expression. Target only paths that
|
||||||
introduced, target only paths that actually exist, such as
|
exist, and consult the internal component overview for their owning
|
||||||
`go test ./path/to/package`, and consult the internal component overview for
|
documentation. A filtered or package-specific run does not replace the
|
||||||
their owning documentation. A filtered or package-specific run does not replace
|
complete repository validation.
|
||||||
the complete repository validation.
|
|
||||||
|
|
||||||
## Coordinated Work With Scriptorium
|
## Coordinated Work With Scriptorium
|
||||||
|
|
||||||
|
|||||||
276
docs/formats.md
Normal file
276
docs/formats.md
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
# Framework Format Reference
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document is the canonical contract for Promptkit prompt-definition,
|
||||||
|
profile, and schema files. The [Go package consumer guide](consumers/pkg-promptkit.md)
|
||||||
|
explains how to select these sources and invoke the engine. The
|
||||||
|
[OpenAI-compatible integration contract](integrations/openai-compatible-chat.md)
|
||||||
|
owns the resulting outbound wire behavior.
|
||||||
|
|
||||||
|
Prompt and profile sources recursively discover files ending in `.yaml` or
|
||||||
|
`.yml`. YAML decoding is strict: unknown fields are errors for the selected
|
||||||
|
definition. Definitions are selected by their YAML `id`, not their file name
|
||||||
|
or directory.
|
||||||
|
|
||||||
|
## Prompt Definitions
|
||||||
|
|
||||||
|
A prompt definition describes inputs, Go-template messages, an optional
|
||||||
|
default profile, and an output contract.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: meeting.summary
|
||||||
|
version: "1.0.0"
|
||||||
|
default_profile: local-summary
|
||||||
|
description: Summarize a synthetic meeting note.
|
||||||
|
session_id: '{{.session}}'
|
||||||
|
inputs:
|
||||||
|
- name: note
|
||||||
|
required: true
|
||||||
|
content_type: text/plain
|
||||||
|
description: Meeting note to summarize.
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content: Return a concise summary.
|
||||||
|
cache_control:
|
||||||
|
type: ephemeral
|
||||||
|
ttl: 1h
|
||||||
|
- role: user
|
||||||
|
content_file: ./summary.user.md
|
||||||
|
output:
|
||||||
|
format: markdown
|
||||||
|
validation_mode: basic
|
||||||
|
repair_attempts: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | yes | Non-empty prompt identifier used by `RunRequest.PromptID`. |
|
||||||
|
| `version` | yes | Non-empty version selected by an optional `RunRequest.PromptVersion`. |
|
||||||
|
| `default_profile` | no | Non-empty profile ID used when the request omits `ProfileID`. |
|
||||||
|
| `description` | no | Human-readable description. |
|
||||||
|
| `session_id` | no | Go template rendered from request variables and input helpers. |
|
||||||
|
| `inputs` | no | Declared input metadata. |
|
||||||
|
| `messages` | yes | One or more chat-message templates. |
|
||||||
|
| `output` | yes | Output format and validation settings. |
|
||||||
|
|
||||||
|
When a request omits a version, the selected prompt ID must identify exactly
|
||||||
|
one definition. When it supplies a version, the ID and version pair must be
|
||||||
|
unique.
|
||||||
|
|
||||||
|
### Inputs
|
||||||
|
|
||||||
|
Each `inputs` item has these fields:
|
||||||
|
|
||||||
|
| Field | Required | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | yes | Non-empty name used by the request input map and `input` template helper. Names must be unique. |
|
||||||
|
| `required` | no | When true, preparation fails if the request omits the input. The default is false. |
|
||||||
|
| `content_type` | no | Expected media-type metadata. |
|
||||||
|
| `description` | no | Human-readable input description. |
|
||||||
|
|
||||||
|
Requests supply inputs as inline or file-backed `ArtifactRef` values. Declared
|
||||||
|
required inputs must be present. A template reference also requires the named
|
||||||
|
input to exist, whether or not it was declared. Extra request inputs are
|
||||||
|
allowed.
|
||||||
|
|
||||||
|
### Messages And Templates
|
||||||
|
|
||||||
|
Each message has a non-empty `role` and exactly one of:
|
||||||
|
|
||||||
|
- `content`, containing an inline Go template; or
|
||||||
|
- `content_file`, naming a file whose contents are the Go template.
|
||||||
|
|
||||||
|
For directory and `fs.FS` prompt sources, `content_file` resolves relative to
|
||||||
|
the prompt file and remains within the source root. `WithPromptFile` also
|
||||||
|
resolves it relative to that file.
|
||||||
|
|
||||||
|
Request variables are the template data, so a variable named `audience` is
|
||||||
|
referenced as `{{.audience}}`. The `{{input "note"}}` helper renders the body
|
||||||
|
of a named input. Missing variables and input references are errors.
|
||||||
|
|
||||||
|
The optional `session_id` uses the same template data and input helper. Its
|
||||||
|
rendered value is trimmed, omitted when empty, and limited to 256 Unicode code
|
||||||
|
points.
|
||||||
|
|
||||||
|
### Cache Control
|
||||||
|
|
||||||
|
`cache_control` is optional and has these fields:
|
||||||
|
|
||||||
|
| Field | Required | Values |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `type` | yes | `ephemeral` |
|
||||||
|
| `ttl` | no | Empty or `1h` |
|
||||||
|
|
||||||
|
Promptkit preserves cache-control metadata on the rendered message. The
|
||||||
|
outbound integration determines its wire representation.
|
||||||
|
|
||||||
|
### Output Contract
|
||||||
|
|
||||||
|
| Field | Required | Values or behavior |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `format` | yes | `text`, `markdown`, or `json`. |
|
||||||
|
| `validation_mode` | yes | `none`, `basic`, `json`, or `json_schema`. |
|
||||||
|
| `schema_path` | for `json_schema` | Path to a schema in the configured schema source. |
|
||||||
|
| `repair_attempts` | no | Integer zero or greater; omitted means zero. |
|
||||||
|
|
||||||
|
The validation modes behave as follows:
|
||||||
|
|
||||||
|
- `none` skips content validation;
|
||||||
|
- `basic` requires non-empty generated content;
|
||||||
|
- `json` requires valid JSON; and
|
||||||
|
- `json_schema` requires valid JSON that satisfies the selected schema.
|
||||||
|
|
||||||
|
`format` controls output artifact metadata. JSON Schema mode also supplies the
|
||||||
|
schema to compatible model clients as structured-output metadata. The public
|
||||||
|
engine does not install an output repairer, so its validation is single-pass
|
||||||
|
even when a positive `repair_attempts` value is present.
|
||||||
|
|
||||||
|
A request-level `OutputContract` replaces the complete prompt output contract.
|
||||||
|
It does not merge individual fields. If its format is empty, Promptkit uses
|
||||||
|
`text`.
|
||||||
|
|
||||||
|
## Profile Definitions
|
||||||
|
|
||||||
|
A profile supplies model execution settings:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: local-summary
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: example-model
|
||||||
|
temperature: 0.2
|
||||||
|
max_tokens: 500
|
||||||
|
top_p: 0.95
|
||||||
|
timeout_seconds: 90
|
||||||
|
service_tier: flex
|
||||||
|
reasoning_effort: medium
|
||||||
|
api_key_env: EXAMPLE_API_KEY
|
||||||
|
extra_params:
|
||||||
|
provider_option: enabled
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | yes | Non-empty profile identifier. IDs must be unique within one source. |
|
||||||
|
| `endpoint` | yes | Non-empty OpenAI-compatible base URL, including an API version path when required. |
|
||||||
|
| `model` | yes | Non-empty provider model name. |
|
||||||
|
| `temperature` | no | Number from 0 through 2. |
|
||||||
|
| `max_tokens` | no | Integer zero or greater. |
|
||||||
|
| `top_p` | no | Number from 0 through 1. |
|
||||||
|
| `timeout_seconds` | no | Per-generation deadline in whole seconds; integer zero or greater. |
|
||||||
|
| `service_tier` | no | Provider-specific request tier. |
|
||||||
|
| `reasoning_effort` | no | Provider-specific reasoning setting. |
|
||||||
|
| `api_key_env` | no | Name of an environment variable containing the API key. |
|
||||||
|
| `extra_params` | no | JSON-compatible provider-specific outbound fields. |
|
||||||
|
|
||||||
|
Raw `api_key` is prohibited in profile YAML. Store only an environment
|
||||||
|
variable name in `api_key_env`.
|
||||||
|
|
||||||
|
`extra_params` accepts null, booleans, finite numbers, strings, arrays, and
|
||||||
|
objects with string keys. Keys must be non-empty. With the built-in client,
|
||||||
|
they also cannot collide with the standard fields listed in the
|
||||||
|
[outbound request contract](integrations/openai-compatible-chat.md#request-body).
|
||||||
|
|
||||||
|
### Defaults And Overrides
|
||||||
|
|
||||||
|
Execution settings resolve in this order:
|
||||||
|
|
||||||
|
1. framework defaults;
|
||||||
|
2. the selected profile; and
|
||||||
|
3. request `ExecutionTargetOverride` values.
|
||||||
|
|
||||||
|
The framework defaults are:
|
||||||
|
|
||||||
|
| Setting | Default |
|
||||||
|
| --- | --- |
|
||||||
|
| `temperature` | `0` |
|
||||||
|
| `max_tokens` | `0` |
|
||||||
|
| `top_p` | `1` |
|
||||||
|
| `timeout_seconds` | `600` |
|
||||||
|
|
||||||
|
Numeric zero in a file or in-memory profile means that the profile does not
|
||||||
|
replace the framework default. Numeric request overrides use pointers, so an
|
||||||
|
explicit zero is preserved. In particular, an explicit request
|
||||||
|
`timeout_seconds` of zero disables the per-generation deadline while leaving
|
||||||
|
the caller context and transport timeout intact.
|
||||||
|
|
||||||
|
Non-empty request strings replace profile strings. A non-empty request
|
||||||
|
`ExtraParams` map replaces the profile map rather than merging keys.
|
||||||
|
The [outbound integration contract](integrations/openai-compatible-chat.md)
|
||||||
|
defines how the effective settings are serialized.
|
||||||
|
|
||||||
|
### Source And Profile Precedence
|
||||||
|
|
||||||
|
An explicit request profile ID takes precedence over the prompt's
|
||||||
|
`default_profile`. If neither is present, preparation fails.
|
||||||
|
|
||||||
|
Profile sources resolve matching IDs in this order:
|
||||||
|
|
||||||
|
1. in-memory profiles supplied with `WithProfiles`;
|
||||||
|
2. a profile file, `fs.FS`, or configured profile directory; and
|
||||||
|
3. embedded built-in profiles.
|
||||||
|
|
||||||
|
A higher-precedence source falls back only when the profile is absent. An
|
||||||
|
invalid matching profile is an error and does not fall back. In-memory
|
||||||
|
`Profile` values follow the same ranges as YAML profiles. They use
|
||||||
|
`APIKeyRequired` for request-scoped credentials instead of `api_key_env`.
|
||||||
|
|
||||||
|
## Built-In Profile Catalog
|
||||||
|
|
||||||
|
Built-ins use the OpenRouter-compatible endpoint and
|
||||||
|
`OPENROUTER_API_KEY`. A custom or in-memory profile with the same ID takes
|
||||||
|
precedence.
|
||||||
|
|
||||||
|
| Provider | ID | Model |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| aion-labs | `aion-2` | `aion-labs/aion-2.0` |
|
||||||
|
| anthropic | `claude-fable-latest` | `~anthropic/claude-fable-latest` |
|
||||||
|
| anthropic | `claude-haiku-latest` | `~anthropic/claude-haiku-latest` |
|
||||||
|
| anthropic | `claude-opus-latest` | `~anthropic/claude-opus-latest` |
|
||||||
|
| anthropic | `claude-sonnet-latest` | `~anthropic/claude-sonnet-latest` |
|
||||||
|
| deepseek | `deepseek-3-2` | `deepseek/deepseek-v3.2` |
|
||||||
|
| deepseek | `deepseek-4-flash` | `deepseek/deepseek-v4-flash` |
|
||||||
|
| deepseek | `deepseek-4-pro` | `deepseek/deepseek-v4-pro` |
|
||||||
|
| google | `gemini-2-flash` | `google/gemini-2.5-flash` |
|
||||||
|
| google | `gemini-2-flash-lite` | `google/gemini-2.5-flash-lite` |
|
||||||
|
| google | `gemini-2-pro` | `google/gemini-2.5-pro` |
|
||||||
|
| google | `gemini-3-flash-lite` | `google/gemini-3.1-flash-lite` |
|
||||||
|
| google | `gemini-flash-latest` | `~google/gemini-flash-latest` |
|
||||||
|
| google | `gemini-pro-latest` | `~google/gemini-pro-latest` |
|
||||||
|
| google | `gemma-4-31b` | `google/gemma-4-31b-it:exacto` |
|
||||||
|
| minimax | `minimax-m2` | `minimax/minimax-m2.5` |
|
||||||
|
| minimax | `minimax-m3` | `minimax/minimax-m3` |
|
||||||
|
| mistral | `mistral-large-2512` | `mistralai/mistral-large-2512` |
|
||||||
|
| mistral | `mistral-medium-3-5` | `mistralai/mistral-medium-3-5` |
|
||||||
|
| mistral | `mistral-small-3` | `mistralai/mistral-small-3.2-24b-instruct` |
|
||||||
|
| mistral | `mistral-small-4` | `mistralai/mistral-small-2603` |
|
||||||
|
| nvidia | `nemotron-3-ultra` | `nvidia/nemotron-3-ultra-550b-a55b` |
|
||||||
|
| openai | `gpt-5-mini` | `openai/gpt-5.4-mini` |
|
||||||
|
| openai | `gpt-5-nano` | `openai/gpt-5.4-nano` |
|
||||||
|
|
||||||
|
## Schemas
|
||||||
|
|
||||||
|
Schemas are JSON documents selected by a prompt or request
|
||||||
|
`schema_path`. For a directory or `fs.FS` source, paths resolve within the
|
||||||
|
configured source root. Referenced nested schemas resolve relative to the
|
||||||
|
owning schema document. `WithSchemaFile` exposes one schema, addressed by its
|
||||||
|
base name.
|
||||||
|
|
||||||
|
An unreadable, invalid, or unresolvable schema produces an operational
|
||||||
|
validation error. Generated content that is valid JSON but does not satisfy the
|
||||||
|
schema produces a failed validation result.
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
Credential values belong at the request or environment boundary, never in
|
||||||
|
prompt, profile, schema, or example files:
|
||||||
|
|
||||||
|
- a file profile names an environment variable with `api_key_env`;
|
||||||
|
- an in-memory profile may set `APIKeyRequired`;
|
||||||
|
- a request can provide a direct `APIKey` or override `APIKeyEnv`; and
|
||||||
|
- a direct request key takes precedence over environment lookup.
|
||||||
|
|
||||||
|
Promptkit validates required credential availability during preparation.
|
||||||
|
Direct keys are excluded from JSON results and redacted by public string
|
||||||
|
formatters. Environment-variable names may appear in prepared metadata, but
|
||||||
|
their values do not.
|
||||||
88
docs/integrations/openai-compatible-chat.md
Normal file
88
docs/integrations/openai-compatible-chat.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# OpenAI-Compatible Chat Integration
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document defines the outbound HTTP behavior implemented by Promptkit's
|
||||||
|
internal OpenAI-compatible model client. The
|
||||||
|
[internal model-client document](../internal/llm.md) owns implementation flow,
|
||||||
|
errors, and test ownership. The root Promptkit engine uses this client by
|
||||||
|
default unless a consumer injects another implementation. The
|
||||||
|
[framework format reference](../formats.md) owns the profile and prompt values
|
||||||
|
that produce these outbound settings.
|
||||||
|
|
||||||
|
## Endpoint And Method
|
||||||
|
|
||||||
|
Generation sends an HTTP `POST` with `Content-Type: application/json`.
|
||||||
|
A non-empty endpoint from the execution target overrides the client's
|
||||||
|
configured base URL. After trailing slashes are removed,
|
||||||
|
`/chat/completions` is appended. Generation fails before sending when neither
|
||||||
|
source supplies an endpoint.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
A non-empty API key supplied directly on the execution target takes
|
||||||
|
precedence. Otherwise, when an API-key environment-variable name is supplied,
|
||||||
|
the client reads that variable and requires a non-empty value. The selected
|
||||||
|
key is sent as `Authorization: Bearer <key>`. No authorization header is sent
|
||||||
|
when neither mechanism is configured.
|
||||||
|
|
||||||
|
## Request Body
|
||||||
|
|
||||||
|
The request body always contains `model` and `messages`. The execution
|
||||||
|
target's model takes precedence over the client's configured model, and one
|
||||||
|
must be available.
|
||||||
|
|
||||||
|
Each ordinary message contains its `role` and string `content`. A
|
||||||
|
cache-controlled message instead uses a text content block containing `type`,
|
||||||
|
`text`, and `cache_control`; an empty cache-control TTL is omitted.
|
||||||
|
|
||||||
|
A non-empty session ID is trimmed, checked against the internal domain limit,
|
||||||
|
and sent as top-level `session_id`. It is not sent as a session header.
|
||||||
|
|
||||||
|
The client conditionally includes:
|
||||||
|
|
||||||
|
- `temperature`, `max_tokens`, and `top_p` when non-zero or explicitly
|
||||||
|
present;
|
||||||
|
- non-empty `service_tier` and `reasoning_effort`; and
|
||||||
|
- `response_format` for JSON Schema structured output, including its name,
|
||||||
|
strict flag, and schema document.
|
||||||
|
|
||||||
|
Extra parameters are merged directly into the top-level body after JSON
|
||||||
|
serialization is verified. Empty keys and collisions with these reserved
|
||||||
|
fields are rejected before any provider call:
|
||||||
|
|
||||||
|
- `model`
|
||||||
|
- `session_id`
|
||||||
|
- `messages`
|
||||||
|
- `temperature`
|
||||||
|
- `max_tokens`
|
||||||
|
- `top_p`
|
||||||
|
- `service_tier`
|
||||||
|
- `reasoning_effort`
|
||||||
|
- `response_format`
|
||||||
|
|
||||||
|
## Response Handling
|
||||||
|
|
||||||
|
Any 2xx response is decoded as an OpenAI-compatible chat response. The client
|
||||||
|
returns the first choice's non-empty message content and maps prompt,
|
||||||
|
completion, total, cached, and cache-write token counts.
|
||||||
|
|
||||||
|
Invalid JSON, absent choices, and empty first-choice content are malformed
|
||||||
|
responses. For a non-2xx status, the error includes the status code but never
|
||||||
|
the provider response body.
|
||||||
|
|
||||||
|
## Timeout And Cancellation
|
||||||
|
|
||||||
|
Timeouts are layered:
|
||||||
|
|
||||||
|
- the caller context remains the outer cancellation boundary;
|
||||||
|
- a positive generation timeout adds a request context deadline;
|
||||||
|
- zero adds no generation-specific deadline;
|
||||||
|
- a negative generation timeout is invalid; and
|
||||||
|
- the cloned `http.Client` supplies the whole-request transport cap, retaining
|
||||||
|
a positive supplied-client timeout or applying the configured/default
|
||||||
|
timeout when the supplied value is not positive.
|
||||||
|
|
||||||
|
The earliest applicable caller, generation, or transport deadline controls the
|
||||||
|
request. Constructing the internal client does not mutate a supplied
|
||||||
|
`http.Client`.
|
||||||
55
docs/internal/llm.md
Normal file
55
docs/internal/llm.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Internal Model Client
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document describes Promptkit's internal model-client implementation. The
|
||||||
|
[architecture policy](../policy/architecture.md) owns the library boundary,
|
||||||
|
and the
|
||||||
|
[OpenAI-compatible chat integration](../integrations/openai-compatible-chat.md)
|
||||||
|
owns the observable outbound HTTP contract. The
|
||||||
|
[framework format reference](../formats.md) owns the profile and prompt
|
||||||
|
settings consumed by the client.
|
||||||
|
|
||||||
|
The concrete client remains under `internal/llm`. The root engine assembles it
|
||||||
|
as the default implementation behind Promptkit's public client boundary.
|
||||||
|
|
||||||
|
## Components And Flow
|
||||||
|
|
||||||
|
`Client` is the provider-neutral generation boundary consumed by later
|
||||||
|
orchestration. `OpenAICompatibleClient` is the built-in implementation. It
|
||||||
|
uses internal domain values for rendered prompts, execution targets,
|
||||||
|
structured output, responses, and token usage.
|
||||||
|
|
||||||
|
Construction validates the configured base URL and clones any supplied
|
||||||
|
`http.Client` so Promptkit can apply its timeout default without mutating the
|
||||||
|
caller's client. Generation then:
|
||||||
|
|
||||||
|
1. validates request-level timeout and endpoint requirements;
|
||||||
|
2. maps the internal request into the OpenAI-compatible chat payload;
|
||||||
|
3. validates and merges extra parameters;
|
||||||
|
4. resolves authentication;
|
||||||
|
5. performs the outbound request under the applicable deadlines; and
|
||||||
|
6. decodes the first response choice and token usage.
|
||||||
|
|
||||||
|
The implementation has no retry loop, tool-call support, provider catalog,
|
||||||
|
inbound HTTP behavior, or durable session store.
|
||||||
|
|
||||||
|
## Failure Categories
|
||||||
|
|
||||||
|
The package preserves distinct error identities for invalid client
|
||||||
|
configuration, invalid generation requests, request execution failures,
|
||||||
|
non-success provider statuses, and malformed successful responses. Provider
|
||||||
|
response bodies are not included in non-success errors.
|
||||||
|
|
||||||
|
Caller cancellation and deadline failures during the outbound request are
|
||||||
|
reported as request execution failures. The runner classifies these identities
|
||||||
|
without depending on HTTP status mapping.
|
||||||
|
|
||||||
|
## Test Ownership
|
||||||
|
|
||||||
|
The
|
||||||
|
[OpenAI-compatible client tests](../../internal/llm/openai_compatible_client_test.go)
|
||||||
|
own configuration, client cloning, deterministic deadline precedence,
|
||||||
|
authentication, request and response mapping, malformed data, error identity,
|
||||||
|
cancellation, and response-body suppression. They use local test servers and
|
||||||
|
test transports; the default suite makes no live or paid provider requests.
|
||||||
@@ -11,14 +11,26 @@ contributor workflow and validation.
|
|||||||
|
|
||||||
| Component | Implemented responsibility | References |
|
| Component | Implemented responsibility | References |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Root `promptkit` package | Establishes the public package boundary for the Go module. It does not yet provide migrated framework behavior or exported APIs. | [Package declaration](../../doc.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) |
|
||||||
|
| `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) |
|
||||||
|
| `internal/promptdef` | Loads strictly decoded, validated prompt definitions from filesystem and `fs.FS` sources, including version selection and contained file-backed message content. | [Framework formats](../formats.md), [prompt-definition repository](../../internal/promptdef/filesystem_repository.go) |
|
||||||
|
| `internal/profile` | Loads strictly decoded, validated execution profiles from filesystem and `fs.FS` sources and composes repositories with error-preserving fallback. | [Framework formats](../formats.md), [profile repositories](../../internal/profile/filesystem_repository.go) |
|
||||||
|
| `internal/profile/builtin` | Embeds the built-in execution profile catalog and combines it with an optional primary repository. | [Built-in catalog](../formats.md#built-in-profile-catalog), [repository](../../internal/profile/builtin/repository.go) |
|
||||||
|
| `internal/prompt` | Renders prompt messages from Go templates with artifact, variable, session, and cache-control data. | [Go-template renderer](../../internal/prompt/go_renderer.go) |
|
||||||
|
| `internal/artifact` | Resolves ordinary inline and unrestricted caller-selected file references into copied artifacts with metadata and hashes. | [Internal sources and validation](sources.md) |
|
||||||
|
| `internal/validate` | Validates basic, JSON, and JSON Schema output using operating-system filesystem or `fs.FS` schema sources. | [Framework formats](../formats.md#schemas), [internal sources and validation](sources.md) |
|
||||||
|
| `internal/llm` | Defines the internal generation boundary and implements outbound OpenAI-compatible chat requests, response decoding, authentication, and deadline handling. | [Internal model client](llm.md) |
|
||||||
|
| `internal/usecase` | Coordinates preparation and execution across internal sources, rendering, artifact loading, generation, validation, and optional repair. | [Internal runner](runner.md) |
|
||||||
|
|
||||||
The root `promptkit` package is the sole implemented Go package. No internal
|
The root package assembles these internal components without exposing their
|
||||||
framework packages exist yet.
|
representations. Consumers depend only on the root facade.
|
||||||
|
|
||||||
## Maintenance
|
## Maintenance
|
||||||
|
|
||||||
Update this inventory as framework extraction introduces packages or changes
|
Update this inventory as implementation adds packages or changes component
|
||||||
component responsibilities. List only implemented components; proposed package
|
responsibilities. List only implemented components; proposed package
|
||||||
boundaries belong in temporary planning documents until their implementation
|
boundaries belong in temporary planning documents until their implementation
|
||||||
lands.
|
lands.
|
||||||
|
|||||||
85
docs/internal/runner.md
Normal file
85
docs/internal/runner.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Internal Runner
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document describes Promptkit's implemented internal orchestration. The
|
||||||
|
[architecture policy](../policy/architecture.md) owns dependency and consumer
|
||||||
|
boundaries. The [source and validation document](sources.md) owns repository,
|
||||||
|
artifact, rendering, and validation behavior, while the
|
||||||
|
[model-client document](llm.md) owns generation behavior and failure
|
||||||
|
categories.
|
||||||
|
|
||||||
|
The runner remains under `internal/usecase` and is assembled by the root
|
||||||
|
Promptkit engine. Its concrete type is not part of the public API.
|
||||||
|
The [framework format reference](../formats.md) owns prompt, profile, schema,
|
||||||
|
and override semantics consumed by the runner.
|
||||||
|
|
||||||
|
## Collaborators
|
||||||
|
|
||||||
|
`Runner` coordinates narrow internal interfaces for prompt definitions,
|
||||||
|
profiles, artifacts, rendering, model generation, and validation. Schema
|
||||||
|
documents are loaded through the validator's optional schema-loader interface.
|
||||||
|
An output repairer can be injected internally, but the ordinary runner
|
||||||
|
constructor does not enable one.
|
||||||
|
|
||||||
|
Each invocation carries its state in request, prepared-run, and result values.
|
||||||
|
The runner has no durable run or session store.
|
||||||
|
|
||||||
|
## Preparation Flow
|
||||||
|
|
||||||
|
`Prepare` performs the reusable pre-generation workflow:
|
||||||
|
|
||||||
|
1. validate the prompt selection and load the prompt definition;
|
||||||
|
2. hash the loaded definition;
|
||||||
|
3. select the request profile or the prompt's default profile;
|
||||||
|
4. resolve application-neutral defaults, profile values, and explicit request
|
||||||
|
overrides in that order;
|
||||||
|
5. validate endpoint, model, numeric overrides, and credential requirements;
|
||||||
|
6. resolve the output contract and load a structured-output schema when
|
||||||
|
required;
|
||||||
|
7. load and hash input artifacts;
|
||||||
|
8. render and hash the prompt; and
|
||||||
|
9. return the effective settings, source identities, messages, hashes, and
|
||||||
|
preparation timing.
|
||||||
|
|
||||||
|
Pointer-based numeric overrides preserve an explicit zero. Invalid negative or
|
||||||
|
out-of-range values fail as invalid requests. A direct API key takes
|
||||||
|
precedence over environment lookup for execution; secret values remain
|
||||||
|
excluded from serialized metadata.
|
||||||
|
|
||||||
|
## Run Flow
|
||||||
|
|
||||||
|
`Run` calls `Prepare` rather than maintaining a second preparation path. It
|
||||||
|
performs one initial generation call, builds the named output artifact, and
|
||||||
|
validates that artifact. Invalid generated content remains a validation result;
|
||||||
|
an inability to perform validation is an operational error.
|
||||||
|
|
||||||
|
When an internal repairer is present, a JSON or JSON Schema content failure can
|
||||||
|
trigger bounded repair attempts. Repair receives the effective execution
|
||||||
|
target, validation errors, prior output, and structured-output specification.
|
||||||
|
This capability remains internal and is not a public option.
|
||||||
|
|
||||||
|
A successful result includes the output artifact and raw output, validation
|
||||||
|
state, prompt and rendered-prompt hashes, selected profile, effective settings,
|
||||||
|
input hashes, token usage, a generated run identifier, and UTC timing.
|
||||||
|
|
||||||
|
## Failure Categories
|
||||||
|
|
||||||
|
Package errors distinguish invalid requests, required profile selection,
|
||||||
|
credential failures, and prompt, profile, artifact, rendering, generation, and
|
||||||
|
validation failures. Wrapping preserves the package identities mapped by the
|
||||||
|
public facade and retains collaborator identities where they are part of the
|
||||||
|
internal contract. Context cancellation propagates through the invoked
|
||||||
|
collaborator and is classified by the owning operation.
|
||||||
|
|
||||||
|
## Test Ownership And Changes
|
||||||
|
|
||||||
|
The [runner tests](../../internal/usecase/runner_test.go) own preparation order,
|
||||||
|
selection and override precedence, schema-before-generation behavior, hashing,
|
||||||
|
generation and validation outcomes, bounded repair, credentials and redaction,
|
||||||
|
error categories, artifact metadata, usage, and timing.
|
||||||
|
|
||||||
|
Changes to orchestration should continue to use the existing package
|
||||||
|
interfaces, keep request state local to an invocation, and preserve `Run`'s use
|
||||||
|
of `Prepare`. Source, renderer, validator, or model-client contract changes
|
||||||
|
belong first in their owning package and document.
|
||||||
66
docs/internal/sources.md
Normal file
66
docs/internal/sources.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Internal Sources And Validation
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document describes Promptkit's implemented internal source, artifact,
|
||||||
|
rendering, and output-validation behavior. The
|
||||||
|
[architecture policy](../policy/architecture.md) owns the library boundary and
|
||||||
|
dependency rules. None of these internal packages is a supported consumer API,
|
||||||
|
and the root engine assembles them behind its public source options and values.
|
||||||
|
The [framework format reference](../formats.md) owns the exact file fields,
|
||||||
|
validation modes, built-in catalog, and source precedence.
|
||||||
|
|
||||||
|
## Prompt Definitions
|
||||||
|
|
||||||
|
`internal/promptdef` discovers YAML deterministically, decodes and validates
|
||||||
|
definitions, selects an ID and optional version, and resolves file-backed
|
||||||
|
message content within the selected operating-system or `fs.FS` source.
|
||||||
|
|
||||||
|
Its package tests own prompt selection, strict decoding, definition validation,
|
||||||
|
duplicate detection, and source containment:
|
||||||
|
[prompt-definition repository tests](../../internal/promptdef/repository_test.go).
|
||||||
|
|
||||||
|
## Profiles And Built-Ins
|
||||||
|
|
||||||
|
`internal/profile` loads and validates execution profiles from an
|
||||||
|
operating-system filesystem or an `fs.FS`. It supports a primary repository
|
||||||
|
with fallback only when the primary reports that a profile is absent.
|
||||||
|
|
||||||
|
`internal/profile/builtin` embeds the maintained built-in profile catalog and
|
||||||
|
can place a caller-selected repository ahead of that catalog. Profile behavior
|
||||||
|
is owned by the
|
||||||
|
[profile repository tests](../../internal/profile/repository_test.go), while
|
||||||
|
catalog completeness, duplicate IDs, and overlay behavior are owned by the
|
||||||
|
[built-in repository tests](../../internal/profile/builtin/repository_test.go).
|
||||||
|
|
||||||
|
## Ordinary Artifacts
|
||||||
|
|
||||||
|
`internal/artifact` resolves inline references and unrestricted,
|
||||||
|
caller-selected file paths. It copies content into an artifact, records
|
||||||
|
metadata and a content hash, applies a content-type fallback, and honors
|
||||||
|
context cancellation.
|
||||||
|
|
||||||
|
This ordinary reader does not implement an inbound HTTP security boundary. In
|
||||||
|
particular, it does not constrain files to an application root or impose an
|
||||||
|
HTTP request-size policy. Scriptorium's restricted HTTP reader remains an
|
||||||
|
application concern outside Promptkit. The
|
||||||
|
[artifact reader tests](../../internal/artifact/reader_test.go) own the
|
||||||
|
implemented reader behavior and failures.
|
||||||
|
|
||||||
|
## Rendering
|
||||||
|
|
||||||
|
`internal/prompt` renders definition messages as Go templates using named
|
||||||
|
artifacts and variables. It carries message roles, session IDs, and cache
|
||||||
|
control into the rendered prompt. The
|
||||||
|
[renderer tests](../../internal/prompt/renderer_test.go) own rendering behavior.
|
||||||
|
|
||||||
|
## Schemas And Output Validation
|
||||||
|
|
||||||
|
`internal/validate` provides validators backed by an operating-system
|
||||||
|
filesystem or an `fs.FS`. Invalid generated content is returned as a validation
|
||||||
|
result; inability to load, register, or compile a schema is an operational
|
||||||
|
error.
|
||||||
|
|
||||||
|
The [validator tests](../../internal/validate/standard_validator_test.go) own
|
||||||
|
basic, JSON, JSON Schema, source resolution, schema loading, compilation, and
|
||||||
|
content-failure behavior.
|
||||||
@@ -9,15 +9,55 @@ implemented packages without redefining these rules.
|
|||||||
|
|
||||||
## System Shape
|
## System Shape
|
||||||
|
|
||||||
Promptkit is an importable Go library. It does not provide a runnable command,
|
Promptkit is an importable Go library. It does not ship a command, an HTTP
|
||||||
an HTTP service, or another application process.
|
service, or another application process. Repository examples demonstrate
|
||||||
|
library use but are not Promptkit applications or release artifacts.
|
||||||
|
|
||||||
The module root contains package `promptkit`, which is the public facade and the
|
The module root contains package `promptkit`, which is the public facade. It
|
||||||
only implemented Go package in the current repository foundation. It declares
|
provides the supported engine, configuration and source options, requests,
|
||||||
the module's public package boundary but does not yet provide migrated framework
|
results, public values, extension interfaces, profiles, and error sentinels.
|
||||||
behavior or exported APIs. No internal framework packages currently exist.
|
|
||||||
|
|
||||||
Future framework extraction must follow this dependency direction:
|
The implemented internal components consist of:
|
||||||
|
|
||||||
|
- `internal/domain`, which owns framework data values shared by later internal
|
||||||
|
components;
|
||||||
|
- `internal/defaults`, which owns application-neutral framework defaults and
|
||||||
|
constructs the default execution target;
|
||||||
|
- `internal/filecatalog`, which discovers YAML files and provides source-path
|
||||||
|
helpers for filesystem and `fs.FS` consumers;
|
||||||
|
- `internal/promptdef`, which loads and validates prompt definitions from
|
||||||
|
filesystem and `fs.FS` sources;
|
||||||
|
- `internal/profile`, which loads, validates, and overlays execution profiles
|
||||||
|
from filesystem and `fs.FS` sources;
|
||||||
|
- `internal/profile/builtin`, which embeds the built-in execution profile
|
||||||
|
catalog;
|
||||||
|
- `internal/prompt`, which renders prompt messages from Go templates;
|
||||||
|
- `internal/artifact`, which resolves ordinary inline and unrestricted
|
||||||
|
caller-selected file references;
|
||||||
|
- `internal/validate`, which validates basic, JSON, and JSON Schema output
|
||||||
|
using filesystem and `fs.FS` schema sources;
|
||||||
|
- `internal/llm`, which defines the provider-neutral generation boundary and
|
||||||
|
implements outbound OpenAI-compatible chat requests; and
|
||||||
|
- `internal/usecase`, which coordinates preparation and execution across the
|
||||||
|
internal framework components.
|
||||||
|
|
||||||
|
The `examples/go-library/prepare` package is a maintained downstream consumer
|
||||||
|
of the root facade. It does not expose a library package or participate in
|
||||||
|
internal assembly.
|
||||||
|
|
||||||
|
The root facade assembles the internal repositories, renderer, validator,
|
||||||
|
outbound client, and use-case runner while translating public values and
|
||||||
|
errors at the library boundary. The defaults and renderer depend on the domain
|
||||||
|
model. Prompt-definition and profile repositories use the domain model, file
|
||||||
|
catalog, and YAML decoder. The built-in profile repository supplies an
|
||||||
|
embedded `fs.FS` to the profile package. Artifact reading uses the domain model
|
||||||
|
and application-neutral defaults. Validation uses the domain model, file
|
||||||
|
catalog, and JSON Schema implementation. The model client uses the domain
|
||||||
|
model, application-neutral defaults, and an injected or standard-library HTTP
|
||||||
|
client. The use-case runner depends on the narrow interfaces owned by each
|
||||||
|
internal component.
|
||||||
|
|
||||||
|
The current implementation follows this dependency direction:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
downstream consumers, including Scriptorium
|
downstream consumers, including Scriptorium
|
||||||
@@ -32,14 +72,13 @@ downstream consumers, including Scriptorium
|
|||||||
narrow injected abstractions
|
narrow injected abstractions
|
||||||
```
|
```
|
||||||
|
|
||||||
The facade may coordinate internal components. Internal components must depend
|
The facade coordinates internal components and adapts the supported public
|
||||||
on narrow abstractions for behavior supplied from outside the library; they
|
extension interfaces to narrow internal abstractions. Internal components must
|
||||||
must not depend on consumers or on Scriptorium. This diagram constrains future
|
not depend on consumers or on Scriptorium.
|
||||||
work and does not assert that the internal components already exist.
|
|
||||||
|
|
||||||
## Repository And Consumer Boundary
|
## Repository And Consumer Boundary
|
||||||
|
|
||||||
Scriptorium is a downstream application that will consume Promptkit through
|
Scriptorium is a downstream application that consumes Promptkit through
|
||||||
the supported public facade. It is not a Promptkit package and must not become
|
the supported public facade. It is not a Promptkit package and must not become
|
||||||
an internal dependency.
|
an internal dependency.
|
||||||
|
|
||||||
@@ -112,7 +151,6 @@ state.
|
|||||||
|
|
||||||
## Current-State Maintenance
|
## Current-State Maintenance
|
||||||
|
|
||||||
This policy distinguishes present implementation from constraints on future
|
Do not list planned packages as implemented components. When implementation
|
||||||
framework extraction. Do not list planned packages as implemented components.
|
introduces a package, update the internal inventory and the owning contract or
|
||||||
When extraction introduces a package, update the internal inventory and the
|
subsystem document in the same change.
|
||||||
owning contract or subsystem document in the same change.
|
|
||||||
|
|||||||
@@ -75,13 +75,14 @@ mechanisms, not secret values.
|
|||||||
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk-based sufficiency, test boundaries, doubles, coverage guidance, regression policy, and test maintenance. | Subsystem behavior, exact public contracts, subsystem-specific test inventories, and implementation plans. |
|
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk-based sufficiency, test boundaries, doubles, coverage guidance, regression policy, and test maintenance. | Subsystem behavior, exact public contracts, subsystem-specific test inventories, and implementation plans. |
|
||||||
| Release procedure | `docs/release.md`, when present | Required release validation, version and tag procedure, release ordering, and post-publication checks. | General contributor workflow, public API semantics, and decision history. |
|
| Release procedure | `docs/release.md`, when present | Required release validation, version and tag procedure, release ordering, and post-publication checks. | General contributor workflow, public API semantics, and decision history. |
|
||||||
| Exact exported Go API | Go declarations and GoDoc, as APIs are implemented | Exported names, signatures, types, values, errors, and exact behavioral contracts. | Task-oriented consumer walkthroughs, implementation details, and future API proposals. |
|
| Exact exported Go API | Go declarations and GoDoc, as APIs are implemented | Exported names, signatures, types, values, errors, and exact behavioral contracts. | Task-oriented consumer walkthroughs, implementation details, and future API proposals. |
|
||||||
|
| Framework file formats | `docs/formats.md` | Prompt-definition and profile YAML fields, schema references, defaults, validation modes, built-in profiles, credentials, and file-to-request precedence. | Exported Go declarations, outbound wire behavior, internal parsing mechanics, and application configuration. |
|
||||||
| Consumer guidance | `docs/consumers/`, when consumer workflows require dedicated guidance | Task-oriented use of implemented public APIs, minimal examples, and consumer responsibilities. | Exact exported declarations and internal mechanics. |
|
| Consumer guidance | `docs/consumers/`, when consumer workflows require dedicated guidance | Task-oriented use of implemented public APIs, minimal examples, and consumer responsibilities. | Exact exported declarations and internal mechanics. |
|
||||||
| Durable integration contracts | `docs/integrations/`, when integrations exist | External formats and protocols, compatibility behavior, and upstream or downstream responsibilities. | Internal transformations and public Go declarations. |
|
| Durable integration contracts | `docs/integrations/`, when integrations exist | External formats and protocols, compatibility behavior, and upstream or downstream responsibilities. | Internal transformations and public Go declarations. |
|
||||||
| Implemented component inventory | `docs/internal/overview.md` | Current packages and components, their implemented responsibilities, and links to focused internal documents. | Normative architecture, contributor workflow, external contracts, and proposed components. |
|
| Implemented component inventory | `docs/internal/overview.md` | Current packages and components, their implemented responsibilities, and links to focused internal documents. | Normative architecture, contributor workflow, external contracts, and proposed components. |
|
||||||
| Internal subsystem behavior | Other files under `docs/internal/`, when a subsystem needs durable detail | Implementation flow, internal collaborators and state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, public API definitions, and future package plans. |
|
| Internal subsystem behavior | Other files under `docs/internal/`, when a subsystem needs durable detail | Implementation flow, internal collaborators and state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, public API definitions, and future package plans. |
|
||||||
| Architectural decision history | `docs/adr/`, when repository-local decisions require records | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, and task sequencing. |
|
| Architectural decision history | `docs/adr/`, when repository-local decisions require records | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, and task sequencing. |
|
||||||
| Temporary feature roadmaps | `docs/roadmap/`, while planned work needs coordination | Proposed or accepted scope, sequencing, gates, and implementation status. | Implemented behavior reference and durable decision rationale. |
|
| Temporary feature roadmaps | `docs/roadmap/`, while planned work needs coordination | Proposed or accepted scope, sequencing, gates, and implementation status. | Implemented behavior reference and durable decision rationale. |
|
||||||
| Complete copyable artifacts | `examples/`, when maintained examples exist | Valid inputs, Go programs, and other files intended to be copied or run. | Field-by-field reference, exact API declarations, and prose explanation. |
|
| Complete copyable artifacts | `examples/` | Valid inputs, Go programs, and other files intended to be copied or run. | Field-by-field reference, exact API declarations, and prose explanation. |
|
||||||
|
|
||||||
Conditional owners do not require placeholder files or directories. Create a
|
Conditional owners do not require placeholder files or directories. Create a
|
||||||
consumer, integration, subsystem, ADR, roadmap, or example document only when
|
consumer, integration, subsystem, ADR, roadmap, or example document only when
|
||||||
@@ -105,6 +106,9 @@ for a task. Internal documents explain how Promptkit implements them. Internal
|
|||||||
documentation may identify a public type or external format as a dependency,
|
documentation may identify a public type or external format as a dependency,
|
||||||
but must link to its canonical definition rather than restate it.
|
but must link to its canonical definition rather than restate it.
|
||||||
|
|
||||||
|
The [framework format reference](../formats.md) owns exact prompt, profile, and
|
||||||
|
schema-file contracts. Integration documents own external wire formats.
|
||||||
|
|
||||||
### Security Topics
|
### Security Topics
|
||||||
|
|
||||||
This policy owns what documentation and examples may contain. Architecture owns
|
This policy owns what documentation and examples may contain. Architecture owns
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ documentation-link, and repository-hygiene checks before accepting changes.
|
|||||||
Introducing hosted CI later would supplement, not silently redefine, this
|
Introducing hosted CI later would supplement, not silently redefine, this
|
||||||
documented validation model.
|
documented validation model.
|
||||||
|
|
||||||
|
The complete test sequence includes ordinary and race-enabled package tests.
|
||||||
|
The maintained offline consumer workflow is also run from the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./...
|
||||||
|
go test -race ./...
|
||||||
|
go run ./examples/go-library/prepare
|
||||||
|
```
|
||||||
|
|
||||||
Tests in the default suite must be deterministic, offline, and independent of
|
Tests in the default suite must be deterministic, offline, and independent of
|
||||||
real credentials. They must not invoke paid APIs, use live network
|
real credentials. They must not invoke paid APIs, use live network
|
||||||
dependencies, or depend on mutable external services. Tests that require live
|
dependencies, or depend on mutable external services. Tests that require live
|
||||||
@@ -77,6 +86,10 @@ Use each test type where it protects a distinct risk:
|
|||||||
relied upon by consumers.
|
relied upon by consumers.
|
||||||
- Integration tests use real collaborators when correctness depends on their
|
- Integration tests use real collaborators when correctness depends on their
|
||||||
interaction, while replacing live or nondeterministic external boundaries.
|
interaction, while replacing live or nondeterministic external boundaries.
|
||||||
|
- External-package root tests exercise the public facade as a Go consumer,
|
||||||
|
while internal package tests own focused implementation behavior.
|
||||||
|
- The maintained offline preparation example protects one representative
|
||||||
|
assembled consumer workflow without contacting a model provider.
|
||||||
- Fixtures should be minimal, synthetic, versioned with the behavior they
|
- Fixtures should be minimal, synthetic, versioned with the behavior they
|
||||||
exercise, and free of credentials or private data.
|
exercise, and free of credentials or private data.
|
||||||
- Golden files are appropriate only when the complete output is intentionally
|
- Golden files are appropriate only when the complete output is intentionally
|
||||||
@@ -154,7 +167,8 @@ A test failing is not the same as a test needing to be edited. Many tests may co
|
|||||||
|
|
||||||
Configurable thresholds and defaults must not be duplicated throughout the test suite.
|
Configurable thresholds and defaults must not be duplicated throughout the test suite.
|
||||||
|
|
||||||
For example, do not encode an internal concurrency limit indirectly:
|
The following fragments are illustrative rather than standalone Go programs.
|
||||||
|
Do not encode an internal concurrency limit indirectly:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// Production policy:
|
// Production policy:
|
||||||
|
|||||||
@@ -47,8 +47,10 @@ Run the same default Go validation required by the
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./...
|
go test ./...
|
||||||
|
go test -race ./...
|
||||||
go vet ./...
|
go vet ./...
|
||||||
go build ./...
|
go build ./...
|
||||||
|
go run ./examples/go-library/prepare
|
||||||
```
|
```
|
||||||
|
|
||||||
Check every tracked Go file and repository whitespace:
|
Check every tracked Go file and repository whitespace:
|
||||||
|
|||||||
342
engine.go
Normal file
342
engine.go
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
artifactadapter "gitea.maximumdirect.net/eric/promptkit/internal/artifact"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile/builtin"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/prompt"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/validate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalidConfig indicates invalid public engine configuration.
|
||||||
|
var ErrInvalidConfig = errors.New("invalid engine configuration")
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidRequest = errors.New("invalid run request")
|
||||||
|
ErrPromptNotFound = errors.New("prompt not found")
|
||||||
|
ErrProfileNotFound = errors.New("profile not found")
|
||||||
|
ErrProfileRequired = errors.New("profile selection is required")
|
||||||
|
ErrPromptLoad = errors.New("failed to load prompt definition")
|
||||||
|
ErrProfileLoad = errors.New("failed to load execution profile")
|
||||||
|
ErrAPIKeyEnvMissing = errors.New("api_key_env points to an unset environment variable")
|
||||||
|
ErrArtifactLoad = errors.New("failed to load artifact")
|
||||||
|
ErrPromptRender = errors.New("failed to render prompt")
|
||||||
|
ErrLLMGenerate = errors.New("failed to generate output")
|
||||||
|
ErrValidation = errors.New("failed to validate output")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Engine prepares and runs Promptkit prompt requests.
|
||||||
|
type Engine struct {
|
||||||
|
runner *usecase.Runner
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config configures a public Promptkit engine.
|
||||||
|
type Config struct {
|
||||||
|
PromptDir string
|
||||||
|
ProfileDir string
|
||||||
|
SchemaDir string
|
||||||
|
// Timeout is the transport-wide safety cap for the built-in LLM client
|
||||||
|
// when HTTPClient is absent or has a non-positive timeout.
|
||||||
|
Timeout time.Duration
|
||||||
|
// HTTPClient is cloned for the built-in LLM client. Its positive Timeout
|
||||||
|
// takes precedence over Config.Timeout as the transport-wide safety cap.
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option customizes engine construction.
|
||||||
|
type Option interface {
|
||||||
|
apply(*engineOptions) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type optionFunc func(*engineOptions) error
|
||||||
|
|
||||||
|
func (f optionFunc) apply(options *engineOptions) error {
|
||||||
|
return f(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
type engineOptions struct {
|
||||||
|
llmClient llm.Client
|
||||||
|
artifactReader artifactadapter.Reader
|
||||||
|
promptDefs promptdef.Repository
|
||||||
|
profiles profile.Repository
|
||||||
|
memoryProfiles profile.Repository
|
||||||
|
validator validate.Validator
|
||||||
|
promptSource bool
|
||||||
|
profileSource bool
|
||||||
|
memorySource bool
|
||||||
|
validatorSource bool
|
||||||
|
artifactSource bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithLLMClient injects a custom LLM client for execution.
|
||||||
|
func WithLLMClient(client LLMClient) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
if client == nil {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
options.llmClient = publicLLMClientAdapter{client: client}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithArtifactReader injects a reader for every input artifact reference.
|
||||||
|
func WithArtifactReader(reader ArtifactReader) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
if reader == nil {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
options.artifactReader = publicArtifactReaderAdapter{reader: reader}
|
||||||
|
options.artifactSource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPromptFS loads prompt definitions from fsys under root.
|
||||||
|
//
|
||||||
|
// The source uses the same strict prompt YAML rules as configured prompt
|
||||||
|
// directories, and prompt content_file paths resolve within this source.
|
||||||
|
func WithPromptFS(fsys fs.FS, root string) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
if fsys == nil {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(root) == "" {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
options.promptDefs = promptdef.NewFSRepository(fsys, root)
|
||||||
|
options.promptSource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPromptFile loads prompt definitions from the single prompt file at path.
|
||||||
|
//
|
||||||
|
// Relative prompt content_file paths resolve from the file's directory.
|
||||||
|
func WithPromptFile(path string) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
fsys, root, err := fileSource(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
options.promptDefs = promptdef.NewFSRepository(fsys, root)
|
||||||
|
options.promptSource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProfileFS loads execution profiles from fsys under root.
|
||||||
|
//
|
||||||
|
// Profiles from this source overlay built-in profiles. Profile YAML must use
|
||||||
|
// api_key_env for environment-based credentials; raw API keys are rejected.
|
||||||
|
func WithProfileFS(fsys fs.FS, root string) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
if fsys == nil {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(root) == "" {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
options.profiles = profile.NewFSRepository(fsys, root)
|
||||||
|
options.profileSource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProfileFile loads execution profiles from the single profile file at path.
|
||||||
|
//
|
||||||
|
// The profile overlays built-in profiles. Profile YAML must use api_key_env for
|
||||||
|
// environment-based credentials; raw API keys are rejected.
|
||||||
|
func WithProfileFile(path string) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
fsys, root, err := fileSource(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
options.profiles = profile.NewFSRepository(fsys, root)
|
||||||
|
options.profileSource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithProfiles configures in-memory profiles that take precedence over
|
||||||
|
// configured profile files and built-in profiles.
|
||||||
|
func WithProfiles(profiles ...Profile) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
repo, err := newMemoryProfileRepository(profiles)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
options.memoryProfiles = repo
|
||||||
|
options.memorySource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSchemaFS loads JSON Schema documents from fsys under root.
|
||||||
|
//
|
||||||
|
// Prompt schema_path values resolve within this source when schema validation
|
||||||
|
// or structured output is requested.
|
||||||
|
func WithSchemaFS(fsys fs.FS, root string) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
if fsys == nil {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(root) == "" {
|
||||||
|
return ErrInvalidConfig
|
||||||
|
}
|
||||||
|
options.validator = validate.NewFSValidator(fsys, root)
|
||||||
|
options.validatorSource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSchemaFile loads JSON Schema documents from the single schema file at path.
|
||||||
|
//
|
||||||
|
// Prompt schema_path values refer to the file's base name.
|
||||||
|
func WithSchemaFile(path string) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
fsys, root, err := fileSource(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
options.validator = validate.NewFSValidator(fsys, root)
|
||||||
|
options.validatorSource = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEngine constructs an Engine from configuration and options.
|
||||||
|
func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
|
||||||
|
var options engineOptions
|
||||||
|
for _, opt := range opts {
|
||||||
|
if opt == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := opt.apply(&options); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
promptDefs := options.promptDefs
|
||||||
|
if !options.promptSource {
|
||||||
|
if strings.TrimSpace(cfg.PromptDir) == "" {
|
||||||
|
return nil, fmt.Errorf("%w: prompt directory is required", ErrInvalidConfig)
|
||||||
|
}
|
||||||
|
promptDefs = promptdef.NewFilesystemRepository(cfg.PromptDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
profiles := builtin.NewRepositoryWithDirectory(cfg.ProfileDir)
|
||||||
|
if options.profileSource {
|
||||||
|
profiles = builtin.NewRepositoryWithPrimary(options.profiles)
|
||||||
|
}
|
||||||
|
if options.memorySource {
|
||||||
|
profiles = profile.NewOverlayRepository(options.memoryProfiles, profiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
validator := options.validator
|
||||||
|
if !options.validatorSource {
|
||||||
|
schemaDir := cfg.SchemaDir
|
||||||
|
if strings.TrimSpace(schemaDir) == "" {
|
||||||
|
schemaDir = defaults.SchemaDirDefault
|
||||||
|
}
|
||||||
|
validator = validate.NewStandardValidator(schemaDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
llmClient := options.llmClient
|
||||||
|
if llmClient == nil {
|
||||||
|
var err error
|
||||||
|
llmClient, err = llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{
|
||||||
|
Timeout: cfg.Timeout,
|
||||||
|
HTTPClient: cfg.HTTPClient,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
artifacts := options.artifactReader
|
||||||
|
if !options.artifactSource {
|
||||||
|
artifacts = artifactadapter.NewCompositeReader()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Engine{
|
||||||
|
runner: usecase.NewRunner(
|
||||||
|
promptDefs,
|
||||||
|
profiles,
|
||||||
|
artifacts,
|
||||||
|
prompt.NewGoRenderer(),
|
||||||
|
llmClient,
|
||||||
|
validator,
|
||||||
|
),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileSource(name string) (fs.FS, string, error) {
|
||||||
|
cleanName := strings.TrimSpace(name)
|
||||||
|
if cleanName == "" {
|
||||||
|
return nil, "", ErrInvalidConfig
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(cleanName)
|
||||||
|
base := filepath.Base(cleanName)
|
||||||
|
if base == "." || base == string(filepath.Separator) || strings.TrimSpace(base) == "" {
|
||||||
|
return nil, "", ErrInvalidConfig
|
||||||
|
}
|
||||||
|
info, err := os.Stat(cleanName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("%w: failed to access source file %q: %v", ErrInvalidConfig, cleanName, err)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil, "", fmt.Errorf("%w: source path %q must be a file", ErrInvalidConfig, cleanName)
|
||||||
|
}
|
||||||
|
return os.DirFS(dir), filepath.ToSlash(base), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare resolves a prompt request without calling an LLM.
|
||||||
|
func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, error) {
|
||||||
|
if e == nil || e.runner == nil {
|
||||||
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
domainReq, err := toDomainRunRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared, err := e.runner.Prepare(ctx, domainReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapPublicError(err)
|
||||||
|
}
|
||||||
|
return fromDomainPreparedRun(prepared), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes a prompt request and returns the generated artifact and metadata.
|
||||||
|
func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||||
|
if e == nil || e.runner == nil {
|
||||||
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
domainReq, err := toDomainRunRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := e.runner.Run(ctx, domainReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapPublicError(err)
|
||||||
|
}
|
||||||
|
return fromDomainRunResult(result), nil
|
||||||
|
}
|
||||||
2559
engine_test.go
Normal file
2559
engine_test.go
Normal file
File diff suppressed because it is too large
Load Diff
60
errors.go
Normal file
60
errors.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mapPublicError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
publicErr := publicErrorFor(err)
|
||||||
|
if publicErr == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: %w", publicErr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicErrorFor(err error) error {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, promptdef.ErrPromptDefinitionNotFound):
|
||||||
|
return ErrPromptNotFound
|
||||||
|
case errors.Is(err, profile.ErrProfileNotFound):
|
||||||
|
return ErrProfileNotFound
|
||||||
|
case errors.Is(err, usecase.ErrProfileRequired):
|
||||||
|
return errors.Join(ErrInvalidRequest, ErrProfileRequired)
|
||||||
|
case errors.Is(err, usecase.ErrPromptLoad):
|
||||||
|
return ErrPromptLoad
|
||||||
|
case errors.Is(err, usecase.ErrProfileLoad):
|
||||||
|
return ErrProfileLoad
|
||||||
|
case errors.Is(err, promptdef.ErrInvalidYAML), errors.Is(err, promptdef.ErrInvalidPromptDefinition):
|
||||||
|
return ErrPromptLoad
|
||||||
|
case isProfileLoadCause(err):
|
||||||
|
return ErrProfileLoad
|
||||||
|
case errors.Is(err, usecase.ErrAPIKeyEnvMissing):
|
||||||
|
return errors.Join(ErrInvalidRequest, ErrAPIKeyEnvMissing)
|
||||||
|
case errors.Is(err, usecase.ErrArtifactLoad):
|
||||||
|
return ErrArtifactLoad
|
||||||
|
case errors.Is(err, usecase.ErrPromptRender):
|
||||||
|
return ErrPromptRender
|
||||||
|
case errors.Is(err, usecase.ErrLLMGenerate):
|
||||||
|
return ErrLLMGenerate
|
||||||
|
case errors.Is(err, usecase.ErrValidation):
|
||||||
|
return ErrValidation
|
||||||
|
case errors.Is(err, usecase.ErrInvalidRequest):
|
||||||
|
return ErrInvalidRequest
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isProfileLoadCause(err error) bool {
|
||||||
|
return errors.Is(err, profile.ErrInvalidYAML) ||
|
||||||
|
errors.Is(err, profile.ErrInvalidProfile) ||
|
||||||
|
errors.Is(err, profile.ErrRawAPIKeyNotAllowed)
|
||||||
|
}
|
||||||
60
examples/go-library/prepare/main.go
Normal file
60
examples/go-library/prepare/main.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type summary struct {
|
||||||
|
PromptID string `json:"prompt_id"`
|
||||||
|
PromptVersion string `json:"prompt_version"`
|
||||||
|
SelectedProfile string `json:"selected_profile"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
MessageCount int `json:"message_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
engine, err := promptkit.NewEngine(
|
||||||
|
promptkit.Config{},
|
||||||
|
promptkit.WithPromptFile("examples/go-library/prepare/prompt.yaml"),
|
||||||
|
promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: "offline-example",
|
||||||
|
Endpoint: "https://example.invalid/v1",
|
||||||
|
Model: "offline-model",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
exit(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
||||||
|
PromptID: "example.prepare",
|
||||||
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
|
"note": promptkit.Inline("Ada finished the migration review."),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
exit(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encoder := json.NewEncoder(os.Stdout)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
if err := encoder.Encode(summary{
|
||||||
|
PromptID: prepared.PromptID,
|
||||||
|
PromptVersion: prepared.PromptVersion,
|
||||||
|
SelectedProfile: prepared.SelectedProfileID,
|
||||||
|
Model: prepared.EffectiveModelParams.Model,
|
||||||
|
MessageCount: len(prepared.Messages),
|
||||||
|
}); err != nil {
|
||||||
|
exit(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exit(err error) {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
16
examples/go-library/prepare/prompt.yaml
Normal file
16
examples/go-library/prepare/prompt.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
id: example.prepare
|
||||||
|
version: "1.0.0"
|
||||||
|
default_profile: offline-example
|
||||||
|
description: Prepare a prompt without contacting a model provider.
|
||||||
|
inputs:
|
||||||
|
- name: note
|
||||||
|
required: true
|
||||||
|
content_type: text/plain
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content: Summarize the note in one sentence.
|
||||||
|
- role: user
|
||||||
|
content: '{{input "note"}}'
|
||||||
|
output:
|
||||||
|
format: text
|
||||||
|
validation_mode: basic
|
||||||
51
formatting.go
Normal file
51
formatting.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// String returns a concise request summary without exposing direct API keys.
|
||||||
|
func (r RunRequest) String() string {
|
||||||
|
return r.redactedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoString returns a concise request summary without exposing direct API keys.
|
||||||
|
func (r RunRequest) GoString() string {
|
||||||
|
return r.redactedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RunRequest) redactedString() string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"promptkit.RunRequest{PromptID:%q PromptVersion:%q ProfileID:%q APIKeySet:%t Inputs:%d Vars:%d ExecutionSet:%t ValidationSet:%t Metadata:%d}",
|
||||||
|
r.PromptID,
|
||||||
|
r.PromptVersion,
|
||||||
|
r.ProfileID,
|
||||||
|
r.APIKey != "",
|
||||||
|
len(r.Inputs),
|
||||||
|
len(r.Vars),
|
||||||
|
r.Execution != nil,
|
||||||
|
r.Validation != nil,
|
||||||
|
len(r.Metadata),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns a concise request summary without exposing direct API keys or
|
||||||
|
// rendered prompt content.
|
||||||
|
func (r GenerateRequest) String() string {
|
||||||
|
return r.redactedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoString returns a concise request summary without exposing direct API keys or
|
||||||
|
// rendered prompt content.
|
||||||
|
func (r GenerateRequest) GoString() string {
|
||||||
|
return r.redactedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r GenerateRequest) redactedString() string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"promptkit.GenerateRequest{Messages:%d Model:%q APIKeySet:%t StructuredOutputSet:%t ExtraParams:%d}",
|
||||||
|
len(r.Prompt.Messages),
|
||||||
|
r.Target.Model,
|
||||||
|
r.APIKey != "",
|
||||||
|
r.StructuredOutput != nil,
|
||||||
|
len(r.Target.ExtraParams),
|
||||||
|
)
|
||||||
|
}
|
||||||
7
go.mod
7
go.mod
@@ -1,3 +1,10 @@
|
|||||||
module gitea.maximumdirect.net/eric/promptkit
|
module gitea.maximumdirect.net/eric/promptkit
|
||||||
|
|
||||||
go 1.25.5
|
go 1.25.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require golang.org/x/text v0.14.0 // indirect
|
||||||
|
|||||||
10
go.sum
Normal file
10
go.sum
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||||
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
122
internal/artifact/reader.go
Normal file
122
internal/artifact/reader.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package artifact
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUnsupportedRefType = errors.New("unsupported artifact reference type")
|
||||||
|
ErrMissingInlineBody = errors.New("missing body for inline artifact")
|
||||||
|
ErrMissingFilePath = errors.New("missing file path for file artifact")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reader resolves artifact references into actual artifacts.
|
||||||
|
type Reader interface {
|
||||||
|
Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompositeReader routes artifact resolution based on the reference type.
|
||||||
|
type CompositeReader struct {
|
||||||
|
inlineReader *inlineReader
|
||||||
|
fileReader Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCompositeReader() Reader {
|
||||||
|
return &CompositeReader{
|
||||||
|
inlineReader: &inlineReader{},
|
||||||
|
fileReader: &fileReader{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CompositeReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ref.Type {
|
||||||
|
case domain.ArtifactRefInline:
|
||||||
|
return c.inlineReader.Read(ctx, ref)
|
||||||
|
case domain.ArtifactRefFile:
|
||||||
|
return c.fileReader.Read(ctx, ref)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedRefType, ref.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type inlineReader struct{}
|
||||||
|
|
||||||
|
func (r *inlineReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if ref.Body == "" {
|
||||||
|
return nil, ErrMissingInlineBody
|
||||||
|
}
|
||||||
|
|
||||||
|
body := []byte(ref.Body)
|
||||||
|
return &domain.Artifact{
|
||||||
|
ContentType: defaults.ContentTypeTextPlain,
|
||||||
|
Body: body,
|
||||||
|
Size: int64(len(body)),
|
||||||
|
Hash: fmt.Sprintf("%x", sha256.Sum256(body)),
|
||||||
|
URI: ref.URI,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileReader struct{}
|
||||||
|
|
||||||
|
func (r *fileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if ref.URI == "" {
|
||||||
|
return nil, ErrMissingFilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
return readFileArtifact(ref.URI)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFileArtifact(path string) (*domain.Artifact, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := mime.TypeByExtension(filepath.Ext(path))
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = defaults.ContentTypeTextPlain
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.Artifact{
|
||||||
|
Name: filepath.Base(path),
|
||||||
|
ContentType: contentType,
|
||||||
|
Body: data,
|
||||||
|
URI: path,
|
||||||
|
Size: int64(len(data)),
|
||||||
|
Hash: fmt.Sprintf("%x", sha256.Sum256(data)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
180
internal/artifact/reader_test.go
Normal file
180
internal/artifact/reader_test.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package artifact
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompositeReader_Read(t *testing.T) {
|
||||||
|
reader := NewCompositeReader()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("inline artifact", func(t *testing.T) {
|
||||||
|
ref := domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefInline,
|
||||||
|
Body: "hello world",
|
||||||
|
}
|
||||||
|
art, err := reader.Read(ctx, ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(art.Body) != "hello world" {
|
||||||
|
t.Errorf("expected 'hello world', got %s", string(art.Body))
|
||||||
|
}
|
||||||
|
if art.ContentType != "text/plain" {
|
||||||
|
t.Errorf("expected text/plain content type, got %q", art.ContentType)
|
||||||
|
}
|
||||||
|
if art.Hash != "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" {
|
||||||
|
t.Errorf("unexpected hash: %s", art.Hash)
|
||||||
|
}
|
||||||
|
if art.Size != int64(len(ref.Body)) {
|
||||||
|
t.Errorf("expected size %d, got %d", len(ref.Body), art.Size)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("inline artifact missing body", func(t *testing.T) {
|
||||||
|
ref := domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefInline,
|
||||||
|
Body: "",
|
||||||
|
}
|
||||||
|
_, err := reader.Read(ctx, ref)
|
||||||
|
if !errors.Is(err, ErrMissingInlineBody) {
|
||||||
|
t.Errorf("expected ErrMissingInlineBody, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unsupported ref type", func(t *testing.T) {
|
||||||
|
ref := domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefType("unsupported"),
|
||||||
|
URI: "unsupported://bucket/key",
|
||||||
|
}
|
||||||
|
_, err := reader.Read(ctx, ref)
|
||||||
|
if !errors.Is(err, ErrUnsupportedRefType) {
|
||||||
|
t.Error("expected error for unsupported type")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompositeReaderCopiesInlineData(t *testing.T) {
|
||||||
|
reader := NewCompositeReader()
|
||||||
|
ref := domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefInline,
|
||||||
|
Body: "hello",
|
||||||
|
URI: "inline:greeting",
|
||||||
|
}
|
||||||
|
|
||||||
|
first, err := reader.Read(context.Background(), ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read first artifact: %v", err)
|
||||||
|
}
|
||||||
|
first.Body[0] = 'j'
|
||||||
|
|
||||||
|
second, err := reader.Read(context.Background(), ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read second artifact: %v", err)
|
||||||
|
}
|
||||||
|
if got := string(second.Body); got != ref.Body {
|
||||||
|
t.Fatalf("expected an independent body %q, got %q", ref.Body, got)
|
||||||
|
}
|
||||||
|
if second.URI != ref.URI {
|
||||||
|
t.Fatalf("expected URI %q, got %q", ref.URI, second.URI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompositeReaderHonorsCancellation(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err := NewCompositeReader().Read(ctx, domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefInline,
|
||||||
|
Body: "ignored",
|
||||||
|
})
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("expected context cancellation, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileReader_Read(t *testing.T) {
|
||||||
|
content := []byte("test file content")
|
||||||
|
filePath := filepath.Join(t.TempDir(), "artifact.txt")
|
||||||
|
if err := os.WriteFile(filePath, content, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := NewCompositeReader()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("file artifact loading", func(t *testing.T) {
|
||||||
|
ref := domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefFile,
|
||||||
|
URI: filePath,
|
||||||
|
}
|
||||||
|
art, err := reader.Read(ctx, ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(art.Body) != string(content) {
|
||||||
|
t.Errorf("expected %s, got %s", string(content), string(art.Body))
|
||||||
|
}
|
||||||
|
if art.Name != filepath.Base(filePath) {
|
||||||
|
t.Errorf("expected name %q, got %q", filepath.Base(filePath), art.Name)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(art.ContentType, "text/plain") {
|
||||||
|
t.Errorf("expected text content type, got %q", art.ContentType)
|
||||||
|
}
|
||||||
|
if art.URI != filePath {
|
||||||
|
t.Errorf("expected URI %q, got %q", filePath, art.URI)
|
||||||
|
}
|
||||||
|
if art.Size != int64(len(content)) {
|
||||||
|
t.Errorf("expected size %d, got %d", len(content), art.Size)
|
||||||
|
}
|
||||||
|
if art.Hash != "60f5237ed4049f0382661ef009d2bc42e48c3ceb3edb6600f7024e7ab3b838f3" {
|
||||||
|
t.Errorf("unexpected hash: %s", art.Hash)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing file path", func(t *testing.T) {
|
||||||
|
ref := domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefFile,
|
||||||
|
URI: "",
|
||||||
|
}
|
||||||
|
_, err := reader.Read(ctx, ref)
|
||||||
|
if !errors.Is(err, ErrMissingFilePath) {
|
||||||
|
t.Errorf("expected ErrMissingFilePath, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing file", func(t *testing.T) {
|
||||||
|
ref := domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefFile,
|
||||||
|
URI: filepath.Join(t.TempDir(), "missing.txt"),
|
||||||
|
}
|
||||||
|
if _, err := reader.Read(ctx, ref); err == nil {
|
||||||
|
t.Fatal("expected missing file error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown extension uses text fallback", func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "artifact.unknownextension")
|
||||||
|
if err := os.WriteFile(path, content, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
art, err := reader.Read(ctx, domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefFile,
|
||||||
|
URI: path,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if art.ContentType != "text/plain" {
|
||||||
|
t.Errorf("expected text/plain fallback, got %q", art.ContentType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
34
internal/defaults/defaults.go
Normal file
34
internal/defaults/defaults.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package defaults
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SchemaDirDefault = "."
|
||||||
|
OutputArtifactName = "output"
|
||||||
|
ContentTypeTextPlain = "text/plain"
|
||||||
|
ContentTypeTextMarkdown = "text/markdown"
|
||||||
|
ContentTypeApplicationJSON = "application/json"
|
||||||
|
OpenAIChatCompletionsPath = "/chat/completions"
|
||||||
|
|
||||||
|
ExecutionDefaultTemperature = 0.0
|
||||||
|
ExecutionDefaultMaxTokens = 0
|
||||||
|
ExecutionDefaultTopP = 1.0
|
||||||
|
ExecutionDefaultTimeoutSeconds = 600
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
LLMRequestTimeoutDefault = 10 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExecutionTargetDefault() domain.ExecutionTarget {
|
||||||
|
return domain.ExecutionTarget{
|
||||||
|
Temperature: ExecutionDefaultTemperature,
|
||||||
|
MaxTokens: ExecutionDefaultMaxTokens,
|
||||||
|
TopP: ExecutionDefaultTopP,
|
||||||
|
TimeoutSeconds: ExecutionDefaultTimeoutSeconds,
|
||||||
|
}
|
||||||
|
}
|
||||||
288
internal/domain/domain.go
Normal file
288
internal/domain/domain.go
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ArtifactRefType defines how an artifact is referenced.
|
||||||
|
type ArtifactRefType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ArtifactRefInline ArtifactRefType = "inline"
|
||||||
|
ArtifactRefFile ArtifactRefType = "file"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OutputFormat defines the desired format of the generated artifact.
|
||||||
|
type OutputFormat string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FormatText OutputFormat = "text"
|
||||||
|
FormatMarkdown OutputFormat = "markdown"
|
||||||
|
FormatJSON OutputFormat = "json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidationMode defines how the output should be validated.
|
||||||
|
type ValidationMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ValidationNone ValidationMode = "none"
|
||||||
|
ValidationBasic ValidationMode = "basic"
|
||||||
|
ValidationJSON ValidationMode = "json"
|
||||||
|
ValidationJSONSchema ValidationMode = "json_schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidationStatus defines the result of a validation check.
|
||||||
|
type ValidationStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ValidationPassed ValidationStatus = "passed"
|
||||||
|
ValidationFailed ValidationStatus = "failed"
|
||||||
|
ValidationSkipped ValidationStatus = "skipped"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CacheControlType defines provider cache behavior for prompt content.
|
||||||
|
type CacheControlType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CacheControlEphemeral CacheControlType = "ephemeral"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SessionIDMaxLength is OpenRouter's documented maximum session_id length.
|
||||||
|
SessionIDMaxLength = 256
|
||||||
|
)
|
||||||
|
|
||||||
|
// CacheControl describes provider cache metadata attached to prompt content.
|
||||||
|
type CacheControl struct {
|
||||||
|
Type CacheControlType `yaml:"type" json:"type"`
|
||||||
|
TTL string `yaml:"ttl,omitempty" json:"ttl,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunRequest represents a request to generate a single artifact.
|
||||||
|
type RunRequest struct {
|
||||||
|
PromptID string
|
||||||
|
PromptVersion string
|
||||||
|
ProfileID string
|
||||||
|
APIKey string `json:"-" yaml:"-"`
|
||||||
|
Inputs map[string]ArtifactRef
|
||||||
|
Vars map[string]string
|
||||||
|
Execution *ExecutionTargetOverride
|
||||||
|
Validation *OutputContract
|
||||||
|
Metadata map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunResult represents the complete result of a prompt execution run.
|
||||||
|
type RunResult struct {
|
||||||
|
RunID string
|
||||||
|
Artifact Artifact
|
||||||
|
RawOutput string
|
||||||
|
Validation ValidationResult
|
||||||
|
PromptID string
|
||||||
|
PromptVersion string
|
||||||
|
PromptHash string
|
||||||
|
RenderedPromptHash string
|
||||||
|
SelectedProfileID string
|
||||||
|
ModelName string
|
||||||
|
Endpoint string
|
||||||
|
EffectiveModelParams ExecutionTarget
|
||||||
|
InputHashes map[string]string
|
||||||
|
Usage TokenUsage
|
||||||
|
StartTime time.Time
|
||||||
|
EndTime time.Time
|
||||||
|
Duration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreparedRun contains pre-LLM execution state from the prepare/render phase.
|
||||||
|
// It must never include resolved API key values, model output, or validation data.
|
||||||
|
type PreparedRun struct {
|
||||||
|
PromptID string `json:"prompt_id"`
|
||||||
|
PromptVersion string `json:"prompt_version,omitempty"`
|
||||||
|
PromptHash string `json:"prompt_hash,omitempty"`
|
||||||
|
SelectedProfileID string `json:"selected_profile_id"`
|
||||||
|
EffectiveModelParams ExecutionTarget `json:"effective_model_params"`
|
||||||
|
TargetPresence ExecutionTargetPresence `json:"-"`
|
||||||
|
OutputContract OutputContract `json:"output_contract"`
|
||||||
|
StructuredOutput *StructuredOutputSpec `json:"structured_output,omitempty"`
|
||||||
|
InputHashes map[string]string `json:"input_hashes,omitempty"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
RenderedPromptHash string `json:"rendered_prompt_hash"`
|
||||||
|
Messages []RenderedMessage `json:"messages"`
|
||||||
|
StartTime time.Time `json:"start_time,omitempty"`
|
||||||
|
EndTime time.Time `json:"end_time,omitempty"`
|
||||||
|
DurationMS int64 `json:"duration_ms,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtifactRef represents a reference to an input artifact.
|
||||||
|
type ArtifactRef struct {
|
||||||
|
Type ArtifactRefType
|
||||||
|
URI string
|
||||||
|
Body string // Used for inline
|
||||||
|
}
|
||||||
|
|
||||||
|
// Artifact represents the actual loaded content of a reference.
|
||||||
|
type Artifact struct {
|
||||||
|
Name string
|
||||||
|
ContentType string
|
||||||
|
Body []byte
|
||||||
|
URI string
|
||||||
|
Size int64
|
||||||
|
Hash string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptDefinition represents a configured prompt execution definition.
|
||||||
|
type PromptDefinition struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
DefaultProfile string `yaml:"default_profile"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
SessionID string `yaml:"session_id" json:"session_id,omitempty"`
|
||||||
|
Inputs []PromptInput `yaml:"inputs"`
|
||||||
|
Templates []PromptMessageTemplate `yaml:"templates"`
|
||||||
|
OutputFormat OutputFormat `yaml:"output_format"`
|
||||||
|
Validation OutputContract `yaml:"validation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptInput describes one named input expected by a prompt definition.
|
||||||
|
type PromptInput struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Required bool `yaml:"required"`
|
||||||
|
ContentType string `yaml:"content_type"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptMessageTemplate defines a template for a chat message.
|
||||||
|
type PromptMessageTemplate struct {
|
||||||
|
Role string `yaml:"role"`
|
||||||
|
Content string `yaml:"content"`
|
||||||
|
ContentFile string `yaml:"content_file"`
|
||||||
|
CacheControl *CacheControl `yaml:"cache_control,omitempty" json:"cache_control,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutionProfile describes how and where to execute a model.
|
||||||
|
type ExecutionProfile struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Endpoint string `yaml:"endpoint"`
|
||||||
|
Model string `yaml:"model"`
|
||||||
|
Temperature float64 `yaml:"temperature"`
|
||||||
|
MaxTokens int `yaml:"max_tokens"`
|
||||||
|
TopP float64 `yaml:"top_p"`
|
||||||
|
TimeoutSeconds int `yaml:"timeout_seconds"`
|
||||||
|
ServiceTier string `yaml:"service_tier"`
|
||||||
|
ReasoningEffort string `yaml:"reasoning_effort"`
|
||||||
|
APIKeyEnv string `yaml:"api_key_env"`
|
||||||
|
APIKeyRequired bool `yaml:"-" json:"-"`
|
||||||
|
ExtraParams map[string]any `yaml:"extra_params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutionTargetOverride represents per-request runtime setting overrides.
|
||||||
|
type ExecutionTargetOverride struct {
|
||||||
|
Endpoint string `json:"endpoint,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||||
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
|
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
|
||||||
|
ServiceTier string `json:"service_tier,omitempty"`
|
||||||
|
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||||
|
APIKeyEnv string `json:"api_key_env,omitempty"`
|
||||||
|
ExtraParams map[string]any `json:"extra_params,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutionTargetPresence tracks which effective runtime fields came from an
|
||||||
|
// explicit request override even when the resolved value is a zero value.
|
||||||
|
type ExecutionTargetPresence struct {
|
||||||
|
Temperature bool
|
||||||
|
MaxTokens bool
|
||||||
|
TopP bool
|
||||||
|
TimeoutSeconds bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutionTarget represents effective model runtime settings for a run.
|
||||||
|
type ExecutionTarget struct {
|
||||||
|
Endpoint string `yaml:"endpoint" json:"endpoint"`
|
||||||
|
Model string `yaml:"model" json:"model"`
|
||||||
|
Temperature float64 `yaml:"temperature" json:"temperature"`
|
||||||
|
MaxTokens int `yaml:"max_tokens" json:"max_tokens"`
|
||||||
|
TopP float64 `yaml:"top_p" json:"top_p"`
|
||||||
|
TimeoutSeconds int `yaml:"timeout_seconds" json:"timeout_seconds"`
|
||||||
|
ServiceTier string `yaml:"service_tier" json:"service_tier"`
|
||||||
|
ReasoningEffort string `yaml:"reasoning_effort" json:"reasoning_effort"`
|
||||||
|
APIKeyEnv string `yaml:"api_key_env" json:"api_key_env"`
|
||||||
|
APIKey string `yaml:"-" json:"-"`
|
||||||
|
APIKeyRequired bool `yaml:"-" json:"-"`
|
||||||
|
ExtraParams map[string]any `yaml:"extra_params" json:"extra_params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutputContract defines the requirements for the output artifact.
|
||||||
|
type OutputContract struct {
|
||||||
|
Format OutputFormat `yaml:"format"`
|
||||||
|
ValidationMode ValidationMode `yaml:"validation_mode"`
|
||||||
|
SchemaPath string `yaml:"schema_path"`
|
||||||
|
RepairAttempts int `yaml:"repair_attempts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderedPrompt represents the prompt after template application.
|
||||||
|
type RenderedPrompt struct {
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
Messages []RenderedMessage `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderedMessage is a single message in a rendered prompt.
|
||||||
|
type RenderedMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
CacheControl *CacheControl `json:"cache_control,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateRequest is the internal request passed to the LLM client.
|
||||||
|
type GenerateRequest struct {
|
||||||
|
Prompt RenderedPrompt
|
||||||
|
Target ExecutionTarget
|
||||||
|
TargetPresence ExecutionTargetPresence
|
||||||
|
StructuredOutput *StructuredOutputSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
// StructuredOutputType indicates which provider-level output mode is requested.
|
||||||
|
type StructuredOutputType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StructuredOutputJSONSchema StructuredOutputType = "json_schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StructuredOutputSpec describes provider-level structured output requirements.
|
||||||
|
type StructuredOutputSpec struct {
|
||||||
|
Type StructuredOutputType `json:"type"`
|
||||||
|
JSONSchema *StructuredOutputJSONSpec `json:"json_schema,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StructuredOutputJSONSpec contains json_schema output constraints.
|
||||||
|
type StructuredOutputJSONSpec struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Strict bool `json:"strict"`
|
||||||
|
Schema any `json:"schema"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateResponse is the response received from the LLM client.
|
||||||
|
type GenerateResponse struct {
|
||||||
|
Content string
|
||||||
|
Usage TokenUsage
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenUsage tracks token consumption.
|
||||||
|
type TokenUsage struct {
|
||||||
|
PromptTokens int
|
||||||
|
CompletionTokens int
|
||||||
|
TotalTokens int
|
||||||
|
CachedTokens int
|
||||||
|
CacheWriteTokens int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidationResult represents the outcome of an output validation.
|
||||||
|
type ValidationResult struct {
|
||||||
|
Status ValidationStatus
|
||||||
|
Mode ValidationMode
|
||||||
|
Errors []string
|
||||||
|
SchemaPath string
|
||||||
|
RepairAttempts int
|
||||||
|
IsValid bool
|
||||||
|
}
|
||||||
141
internal/domain/prepared_run_test.go
Normal file
141
internal/domain/prepared_run_test.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPreparedRunJSONDoesNotIncludeSecretValues(t *testing.T) {
|
||||||
|
const envName = "PROMPTKIT_TEST_API_KEY"
|
||||||
|
const secret = "super-secret-value"
|
||||||
|
t.Setenv(envName, secret)
|
||||||
|
|
||||||
|
prepared := PreparedRun{
|
||||||
|
PromptID: "prompt.id",
|
||||||
|
PromptVersion: "v1",
|
||||||
|
PromptHash: "prompt-hash",
|
||||||
|
SelectedProfileID: "local-fast",
|
||||||
|
EffectiveModelParams: ExecutionTarget{
|
||||||
|
Endpoint: "http://llm/v1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
APIKeyEnv: envName,
|
||||||
|
APIKey: secret,
|
||||||
|
},
|
||||||
|
InputHashes: map[string]string{"transcript": "hash-1"},
|
||||||
|
RenderedPromptHash: "rendered-hash",
|
||||||
|
Messages: []RenderedMessage{
|
||||||
|
{Role: "system", Content: "You are helpful."},
|
||||||
|
{Role: "user", Content: "Summarize this."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(prepared)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := string(b)
|
||||||
|
if strings.Contains(out, secret) {
|
||||||
|
t.Fatalf("prepared run JSON unexpectedly contains secret value: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, `"api_key_env":"`+envName+`"`) {
|
||||||
|
t.Fatalf("prepared run JSON should include api_key_env name: %s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
var top map[string]any
|
||||||
|
if err := json.Unmarshal(b, &top); err != nil {
|
||||||
|
t.Fatalf("unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, forbidden := range []string{"raw_output", "validation", "artifact"} {
|
||||||
|
if _, ok := top[forbidden]; ok {
|
||||||
|
t.Fatalf("prepared run JSON should not include %q", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparedRunJSONIncludesMessageCacheControlOnlyWhenPresent(t *testing.T) {
|
||||||
|
prepared := PreparedRun{
|
||||||
|
PromptID: "prompt.id",
|
||||||
|
SelectedProfileID: "local-fast",
|
||||||
|
EffectiveModelParams: ExecutionTarget{
|
||||||
|
Endpoint: "http://llm/v1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
},
|
||||||
|
RenderedPromptHash: "rendered-hash",
|
||||||
|
Messages: []RenderedMessage{
|
||||||
|
{
|
||||||
|
Role: "system",
|
||||||
|
Content: "You are helpful.",
|
||||||
|
CacheControl: &CacheControl{
|
||||||
|
Type: CacheControlEphemeral,
|
||||||
|
TTL: "1h",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{Role: "user", Content: "Summarize this."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(prepared)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded struct {
|
||||||
|
Messages []map[string]any `json:"messages"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &decoded); err != nil {
|
||||||
|
t.Fatalf("unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(decoded.Messages) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages, got %d", len(decoded.Messages))
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheControl, ok := decoded.Messages[0]["cache_control"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected cache_control on first message, got %#v", decoded.Messages[0])
|
||||||
|
}
|
||||||
|
if cacheControl["type"] != string(CacheControlEphemeral) || cacheControl["ttl"] != "1h" {
|
||||||
|
t.Fatalf("unexpected cache_control payload: %#v", cacheControl)
|
||||||
|
}
|
||||||
|
if _, ok := decoded.Messages[1]["cache_control"]; ok {
|
||||||
|
t.Fatalf("expected second message to omit cache_control, got %#v", decoded.Messages[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparedRunJSONIncludesSessionIDOnlyWhenPresent(t *testing.T) {
|
||||||
|
prepared := PreparedRun{
|
||||||
|
PromptID: "prompt.id",
|
||||||
|
SelectedProfileID: "local-fast",
|
||||||
|
EffectiveModelParams: ExecutionTarget{
|
||||||
|
Endpoint: "http://llm/v1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
},
|
||||||
|
SessionID: "session-123",
|
||||||
|
RenderedPromptHash: "rendered-hash",
|
||||||
|
Messages: []RenderedMessage{{Role: "user", Content: "Summarize this."}},
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(prepared)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(b, &decoded); err != nil {
|
||||||
|
t.Fatalf("unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
if decoded["session_id"] != "session-123" {
|
||||||
|
t.Fatalf("expected session_id in prepared run JSON, got %#v", decoded["session_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared.SessionID = ""
|
||||||
|
b, err = json.Marshal(prepared)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(b), "session_id") {
|
||||||
|
t.Fatalf("expected empty session_id to be omitted, got %s", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
142
internal/filecatalog/catalog.go
Normal file
142
internal/filecatalog/catalog.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package filecatalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FindYAMLFiles returns sorted full paths for .yaml and .yml files under root.
|
||||||
|
func FindYAMLFiles(ctx context.Context, root string) ([]string, error) {
|
||||||
|
var files []string
|
||||||
|
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !IsYAMLFile(d.Name()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
files = append(files, path)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
sort.Strings(files)
|
||||||
|
return files, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindFSYAMLFiles returns sorted paths for .yaml and .yml files under root in fsys.
|
||||||
|
func FindFSYAMLFiles(ctx context.Context, fsys fs.FS, root string) ([]string, error) {
|
||||||
|
cleanRoot := CleanFSRoot(root)
|
||||||
|
var files []string
|
||||||
|
err := fs.WalkDir(fsys, cleanRoot, func(name string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !IsYAMLFile(d.Name()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
files = append(files, name)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
sort.Strings(files)
|
||||||
|
return files, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelativePath computes a clean relative path from root to path.
|
||||||
|
func RelativePath(root string, filePath string) string {
|
||||||
|
rel, err := filepath.Rel(root, filePath)
|
||||||
|
if err != nil {
|
||||||
|
return filepath.Clean(filePath)
|
||||||
|
}
|
||||||
|
return filepath.Clean(rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanFSRoot normalizes a root path for use with fs.FS.
|
||||||
|
func CleanFSRoot(root string) string {
|
||||||
|
root = strings.TrimSpace(root)
|
||||||
|
if root == "" || root == "." {
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
return path.Clean(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisplayPath returns name relative to root for messages about fs.FS paths.
|
||||||
|
func DisplayPath(root string, name string) string {
|
||||||
|
cleanRoot := CleanFSRoot(root)
|
||||||
|
cleanName := path.Clean(name)
|
||||||
|
if cleanRoot == "." {
|
||||||
|
return cleanName
|
||||||
|
}
|
||||||
|
prefix := strings.TrimSuffix(cleanRoot, "/") + "/"
|
||||||
|
if strings.HasPrefix(cleanName, prefix) {
|
||||||
|
return strings.TrimPrefix(cleanName, prefix)
|
||||||
|
}
|
||||||
|
return cleanName
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveFSPath resolves userPath from baseDir and keeps it inside root.
|
||||||
|
func ResolveFSPath(root string, baseDir string, userPath string) (string, string, error) {
|
||||||
|
cleanRoot := CleanFSRoot(root)
|
||||||
|
cleanBase := path.Clean(strings.TrimSpace(baseDir))
|
||||||
|
if cleanBase == "" {
|
||||||
|
cleanBase = cleanRoot
|
||||||
|
}
|
||||||
|
if !containsFSPath(cleanRoot, cleanBase) {
|
||||||
|
return "", "", fmt.Errorf("base path %q is outside source root %q", cleanBase, cleanRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanUserPath := strings.TrimSpace(userPath)
|
||||||
|
if cleanUserPath == "" {
|
||||||
|
return "", "", fmt.Errorf("path is required")
|
||||||
|
}
|
||||||
|
cleanUserPath = path.Clean(cleanUserPath)
|
||||||
|
if path.IsAbs(cleanUserPath) {
|
||||||
|
return "", "", fmt.Errorf("path %q must be relative", userPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved := path.Clean(path.Join(cleanBase, cleanUserPath))
|
||||||
|
if !containsFSPath(cleanRoot, resolved) {
|
||||||
|
return "", "", fmt.Errorf("path %q escapes source root %q", userPath, cleanRoot)
|
||||||
|
}
|
||||||
|
return resolved, DisplayPath(cleanRoot, resolved), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsFSPath(root string, name string) bool {
|
||||||
|
root = CleanFSRoot(root)
|
||||||
|
name = path.Clean(name)
|
||||||
|
if root == "." {
|
||||||
|
return name == "." || (name != ".." && !strings.HasPrefix(name, "../"))
|
||||||
|
}
|
||||||
|
return name == root || strings.HasPrefix(name, strings.TrimSuffix(root, "/")+"/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stem strips .yaml or .yml from a file name.
|
||||||
|
func Stem(name string) string {
|
||||||
|
name = strings.TrimSuffix(name, ".yaml")
|
||||||
|
name = strings.TrimSuffix(name, ".yml")
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsYAMLFile(name string) bool {
|
||||||
|
return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml")
|
||||||
|
}
|
||||||
270
internal/filecatalog/catalog_test.go
Normal file
270
internal/filecatalog/catalog_test.go
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
package filecatalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFindYAMLFilesNestedSortedAndFiltered(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mustWriteFile(t, filepath.Join(root, "z", "prompt.yml"), "id: z")
|
||||||
|
mustWriteFile(t, filepath.Join(root, "a", "profile.yaml"), "id: a")
|
||||||
|
mustWriteFile(t, filepath.Join(root, "a", "ignore.txt"), "not yaml")
|
||||||
|
mustWriteFile(t, filepath.Join(root, "b", "ignore.yaml.bak"), "not yaml")
|
||||||
|
|
||||||
|
got, err := FindYAMLFiles(context.Background(), root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{
|
||||||
|
filepath.Join(root, "a", "profile.yaml"),
|
||||||
|
filepath.Join(root, "z", "prompt.yml"),
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("expected sorted YAML files %v, got %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindYAMLFilesHonorsContextCancellation(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mustWriteFile(t, filepath.Join(root, "one.yaml"), "id: one")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err := FindYAMLFiles(ctx, root)
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("expected context.Canceled, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindFSYAMLFilesNestedSortedAndFiltered(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"prompts/z/prompt.yml": &fstest.MapFile{Data: []byte("id: z")},
|
||||||
|
"prompts/a/profile.yaml": &fstest.MapFile{Data: []byte("id: a")},
|
||||||
|
"prompts/a/ignore.txt": &fstest.MapFile{Data: []byte("not yaml")},
|
||||||
|
"prompts/b/ignore.yaml.bak": &fstest.MapFile{Data: []byte("not yaml")},
|
||||||
|
"other/ignored.yaml": &fstest.MapFile{Data: []byte("id: ignored")},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := FindFSYAMLFiles(context.Background(), fsys, " prompts ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{
|
||||||
|
"prompts/a/profile.yaml",
|
||||||
|
"prompts/z/prompt.yml",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("expected sorted YAML files %v, got %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindFSYAMLFilesHonorsContextCancellation(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"one.yaml": &fstest.MapFile{Data: []byte("id: one")},
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, err := FindFSYAMLFiles(ctx, fsys, ".")
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("expected context.Canceled, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelativePathNested(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
path := filepath.Join(root, "nested", "profiles", "local.yaml")
|
||||||
|
got := RelativePath(root, path)
|
||||||
|
want := filepath.Join("nested", "profiles", "local.yaml")
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("expected relative path %q, got %q", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanFSRoot(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
root string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "empty", root: "", want: "."},
|
||||||
|
{name: "dot", root: ".", want: "."},
|
||||||
|
{name: "trimmed", root: " prompts/../profiles ", want: "profiles"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := CleanFSRoot(tc.root); got != tc.want {
|
||||||
|
t.Fatalf("expected %q, got %q", tc.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisplayPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
root string
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "root dot", root: ".", path: "profiles/local.yaml", want: "profiles/local.yaml"},
|
||||||
|
{name: "nested root", root: "profiles", path: "profiles/local.yaml", want: "local.yaml"},
|
||||||
|
{name: "outside root", root: "profiles", path: "other/local.yaml", want: "other/local.yaml"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := DisplayPath(tc.root, tc.path); got != tc.want {
|
||||||
|
t.Fatalf("expected %q, got %q", tc.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveFSPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
root string
|
||||||
|
baseDir string
|
||||||
|
userPath string
|
||||||
|
wantPath string
|
||||||
|
wantDisplay string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "sibling inside root",
|
||||||
|
root: "prompts",
|
||||||
|
baseDir: "prompts/nested",
|
||||||
|
userPath: "./messages/user.tmpl",
|
||||||
|
wantPath: "prompts/nested/messages/user.tmpl",
|
||||||
|
wantDisplay: "nested/messages/user.tmpl",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parent inside root",
|
||||||
|
root: "prompts",
|
||||||
|
baseDir: "prompts/nested",
|
||||||
|
userPath: "../shared/user.tmpl",
|
||||||
|
wantPath: "prompts/shared/user.tmpl",
|
||||||
|
wantDisplay: "shared/user.tmpl",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "escape rejected",
|
||||||
|
root: "prompts",
|
||||||
|
baseDir: "prompts/nested",
|
||||||
|
userPath: "../../outside.tmpl",
|
||||||
|
wantErr: "escapes source root",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "absolute path rejected",
|
||||||
|
root: "prompts",
|
||||||
|
baseDir: "prompts/nested",
|
||||||
|
userPath: "/outside.tmpl",
|
||||||
|
wantErr: "must be relative",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty path rejected",
|
||||||
|
root: "prompts",
|
||||||
|
baseDir: "prompts/nested",
|
||||||
|
userPath: " ",
|
||||||
|
wantErr: "path is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dot root allows normal relative path",
|
||||||
|
root: ".",
|
||||||
|
baseDir: ".",
|
||||||
|
userPath: "schemas/events.schema.json",
|
||||||
|
wantPath: "schemas/events.schema.json",
|
||||||
|
wantDisplay: "schemas/events.schema.json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dot root rejects parent escape",
|
||||||
|
root: ".",
|
||||||
|
baseDir: ".",
|
||||||
|
userPath: "../outside.tmpl",
|
||||||
|
wantErr: "escapes source root",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
gotPath, gotDisplay, err := ResolveFSPath(tc.root, tc.baseDir, tc.userPath)
|
||||||
|
if tc.wantErr != "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error containing %q", tc.wantErr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||||
|
t.Fatalf("expected error to contain %q, got %v", tc.wantErr, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if gotPath != tc.wantPath || gotDisplay != tc.wantDisplay {
|
||||||
|
t.Fatalf("expected path/display %q/%q, got %q/%q", tc.wantPath, tc.wantDisplay, gotPath, gotDisplay)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStemStripsYAMLExtensions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "yaml", in: "prompt.yaml", want: "prompt"},
|
||||||
|
{name: "yml", in: "profile.yml", want: "profile"},
|
||||||
|
{name: "other", in: "file.txt", want: "file.txt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := Stem(tc.in); got != tc.want {
|
||||||
|
t.Fatalf("expected %q, got %q", tc.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsYAMLFile(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "yaml", in: "prompt.yaml", want: true},
|
||||||
|
{name: "yml", in: "profile.yml", want: true},
|
||||||
|
{name: "backup", in: "profile.yaml.bak", want: false},
|
||||||
|
{name: "uppercase", in: "profile.YAML", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := IsYAMLFile(tc.in); got != tc.want {
|
||||||
|
t.Fatalf("expected %v, got %v", tc.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustWriteFile(t *testing.T, path string, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("failed to create directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write file %q: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
internal/llm/client.go
Normal file
11
internal/llm/client.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client executes a rendered prompt against an LLM endpoint.
|
||||||
|
type Client interface {
|
||||||
|
Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error)
|
||||||
|
}
|
||||||
385
internal/llm/openai_compatible_client.go
Normal file
385
internal/llm/openai_compatible_client.go
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidConfig = errors.New("invalid llm client configuration")
|
||||||
|
ErrInvalidRequest = errors.New("invalid generate request")
|
||||||
|
ErrRequestFailed = errors.New("llm request failed")
|
||||||
|
ErrUnexpectedStatus = errors.New("llm returned non-success status")
|
||||||
|
ErrMalformedResponse = errors.New("malformed llm response")
|
||||||
|
)
|
||||||
|
|
||||||
|
type OpenAICompatibleConfig struct {
|
||||||
|
BaseURL string
|
||||||
|
Model string
|
||||||
|
Timeout time.Duration
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenAICompatibleClient struct {
|
||||||
|
baseURL string
|
||||||
|
defaultModel string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOpenAICompatibleClient(cfg OpenAICompatibleConfig) (*OpenAICompatibleClient, error) {
|
||||||
|
baseURL := strings.TrimSpace(cfg.BaseURL)
|
||||||
|
if baseURL != "" {
|
||||||
|
if _, err := url.ParseRequestURI(baseURL); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: invalid base URL: %v", ErrInvalidConfig, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := cfg.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = defaults.LLMRequestTimeoutDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
var client *http.Client
|
||||||
|
if cfg.HTTPClient != nil {
|
||||||
|
cloned := *cfg.HTTPClient
|
||||||
|
if cloned.Timeout <= 0 {
|
||||||
|
cloned.Timeout = timeout
|
||||||
|
}
|
||||||
|
client = &cloned
|
||||||
|
} else {
|
||||||
|
client = &http.Client{Timeout: timeout}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &OpenAICompatibleClient{
|
||||||
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
defaultModel: cfg.Model,
|
||||||
|
httpClient: client,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
|
||||||
|
if req.Target.TimeoutSeconds < 0 {
|
||||||
|
return nil, fmt.Errorf("%w: timeout_seconds must be greater than or equal to 0", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := strings.TrimSpace(req.Target.Endpoint)
|
||||||
|
if endpoint == "" {
|
||||||
|
endpoint = c.baseURL
|
||||||
|
}
|
||||||
|
if endpoint == "" {
|
||||||
|
return nil, fmt.Errorf("%w: endpoint is required", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
endpoint = strings.TrimRight(endpoint, "/") + defaults.OpenAIChatCompletionsPath
|
||||||
|
|
||||||
|
wireReq, err := openAIChatRequestFromGenerateRequest(req, c.defaultModel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wirePayload, err := openAIChatRequestPayload(wireReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(wirePayload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: failed to encode request: %v", ErrRequestFailed, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestContext := ctx
|
||||||
|
if req.Target.TimeoutSeconds > 0 {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
requestContext, cancel = context.WithTimeout(
|
||||||
|
ctx,
|
||||||
|
time.Duration(req.Target.TimeoutSeconds)*time.Second,
|
||||||
|
)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(requestContext, http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: failed to create request: %v", ErrRequestFailed, err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
if apiKey := strings.TrimSpace(req.Target.APIKey); apiKey != "" {
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
} else if envName := strings.TrimSpace(req.Target.APIKeyEnv); envName != "" {
|
||||||
|
apiKey := strings.TrimSpace(os.Getenv(envName))
|
||||||
|
if apiKey == "" {
|
||||||
|
return nil, fmt.Errorf("%w: api key environment variable %q is not set", ErrInvalidRequest, envName)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpClient := c.httpClient
|
||||||
|
if httpClient == nil {
|
||||||
|
httpClient = &http.Client{Timeout: defaults.LLMRequestTimeoutDefault}
|
||||||
|
}
|
||||||
|
|
||||||
|
httpResp, err := httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrRequestFailed, err)
|
||||||
|
}
|
||||||
|
defer httpResp.Body.Close()
|
||||||
|
|
||||||
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(httpResp.Body, 4096))
|
||||||
|
return nil, fmt.Errorf("%w: status=%d", ErrUnexpectedStatus, httpResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wireResp openAIChatResponse
|
||||||
|
if err := json.NewDecoder(httpResp.Body).Decode(&wireResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: failed to decode response: %v", ErrMalformedResponse, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(wireResp.Choices) == 0 {
|
||||||
|
return nil, fmt.Errorf("%w: no choices returned", ErrMalformedResponse)
|
||||||
|
}
|
||||||
|
content := wireResp.Choices[0].Message.Content
|
||||||
|
if content == "" {
|
||||||
|
return nil, fmt.Errorf("%w: first choice has empty message content", ErrMalformedResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.GenerateResponse{
|
||||||
|
Content: content,
|
||||||
|
Usage: domain.TokenUsage{
|
||||||
|
PromptTokens: wireResp.Usage.PromptTokens,
|
||||||
|
CompletionTokens: wireResp.Usage.CompletionTokens,
|
||||||
|
TotalTokens: wireResp.Usage.TotalTokens,
|
||||||
|
CachedTokens: wireResp.Usage.PromptTokensDetails.CachedTokens,
|
||||||
|
CacheWriteTokens: wireResp.Usage.CacheWriteTokens,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openAIChatRequestFromGenerateRequest(req domain.GenerateRequest, defaultModel string) (openAIChatRequest, error) {
|
||||||
|
model := strings.TrimSpace(req.Target.Model)
|
||||||
|
if model == "" {
|
||||||
|
model = strings.TrimSpace(defaultModel)
|
||||||
|
}
|
||||||
|
if model == "" {
|
||||||
|
return openAIChatRequest{}, errors.New("model is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
wireReq := openAIChatRequest{
|
||||||
|
Model: model,
|
||||||
|
}
|
||||||
|
if sessionID := strings.TrimSpace(req.Prompt.SessionID); sessionID != "" {
|
||||||
|
if n := utf8.RuneCountInString(sessionID); n > domain.SessionIDMaxLength {
|
||||||
|
return openAIChatRequest{}, fmt.Errorf("session_id length %d exceeds maximum %d", n, domain.SessionIDMaxLength)
|
||||||
|
}
|
||||||
|
wireReq.SessionID = sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
wireReq.Messages = make([]openAIChatRequestMessage, 0, len(req.Prompt.Messages))
|
||||||
|
for _, msg := range req.Prompt.Messages {
|
||||||
|
wireReq.Messages = append(wireReq.Messages, openAIChatRequestMessageFromRenderedMessage(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Target.Temperature != 0 || req.TargetPresence.Temperature {
|
||||||
|
wireReq.Temperature = &req.Target.Temperature
|
||||||
|
}
|
||||||
|
if req.Target.MaxTokens != 0 || req.TargetPresence.MaxTokens {
|
||||||
|
wireReq.MaxTokens = &req.Target.MaxTokens
|
||||||
|
}
|
||||||
|
if req.Target.TopP != 0 || req.TargetPresence.TopP {
|
||||||
|
wireReq.TopP = &req.Target.TopP
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Target.ServiceTier) != "" {
|
||||||
|
wireReq.ServiceTier = req.Target.ServiceTier
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Target.ReasoningEffort) != "" {
|
||||||
|
wireReq.ReasoningEffort = req.Target.ReasoningEffort
|
||||||
|
}
|
||||||
|
if len(req.Target.ExtraParams) > 0 {
|
||||||
|
wireReq.ExtraParams = req.Target.ExtraParams
|
||||||
|
}
|
||||||
|
if req.StructuredOutput != nil {
|
||||||
|
responseFormat, err := toOpenAIResponseFormat(req.StructuredOutput)
|
||||||
|
if err != nil {
|
||||||
|
return openAIChatRequest{}, err
|
||||||
|
}
|
||||||
|
wireReq.ResponseFormat = responseFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
return wireReq, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIChatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
Messages []openAIChatRequestMessage `json:"messages"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||||
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
|
ServiceTier string `json:"service_tier,omitempty"`
|
||||||
|
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||||
|
ResponseFormat *openAIResponseFormat `json:"response_format,omitempty"`
|
||||||
|
ExtraParams map[string]any `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func openAIChatRequestPayload(req openAIChatRequest) (map[string]any, error) {
|
||||||
|
out := map[string]any{
|
||||||
|
"model": req.Model,
|
||||||
|
"messages": req.Messages,
|
||||||
|
}
|
||||||
|
if req.SessionID != "" {
|
||||||
|
out["session_id"] = req.SessionID
|
||||||
|
}
|
||||||
|
if req.Temperature != nil {
|
||||||
|
out["temperature"] = *req.Temperature
|
||||||
|
}
|
||||||
|
if req.MaxTokens != nil {
|
||||||
|
out["max_tokens"] = *req.MaxTokens
|
||||||
|
}
|
||||||
|
if req.TopP != nil {
|
||||||
|
out["top_p"] = *req.TopP
|
||||||
|
}
|
||||||
|
if req.ServiceTier != "" {
|
||||||
|
out["service_tier"] = req.ServiceTier
|
||||||
|
}
|
||||||
|
if req.ReasoningEffort != "" {
|
||||||
|
out["reasoning_effort"] = req.ReasoningEffort
|
||||||
|
}
|
||||||
|
if req.ResponseFormat != nil {
|
||||||
|
out["response_format"] = req.ResponseFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range req.ExtraParams {
|
||||||
|
if key == "" {
|
||||||
|
return nil, errors.New("extra_params key must not be empty")
|
||||||
|
}
|
||||||
|
if _, reserved := reservedOpenAIChatRequestFields[key]; reserved {
|
||||||
|
return nil, fmt.Errorf("extra_params key %q collides with reserved request field", key)
|
||||||
|
}
|
||||||
|
if _, err := json.Marshal(value); err != nil {
|
||||||
|
return nil, fmt.Errorf("extra_params.%s must be JSON-serializable: %w", key, err)
|
||||||
|
}
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIChatTextContentBlock struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
CacheControl *openAICacheControl `json:"cache_control,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAICacheControl struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
TTL string `json:"ttl,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIChatResponseMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIChatResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message openAIChatResponseMessage `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Usage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
PromptTokensDetails struct {
|
||||||
|
CachedTokens int `json:"cached_tokens"`
|
||||||
|
} `json:"prompt_tokens_details"`
|
||||||
|
CacheWriteTokens int `json:"cache_write_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIResponseFormat struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
JSONSchema *openAIJSONSchemaEnvelope `json:"json_schema,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openAIJSONSchemaEnvelope struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Strict bool `json:"strict"`
|
||||||
|
Schema any `json:"schema"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func openAIChatRequestMessageFromRenderedMessage(msg domain.RenderedMessage) openAIChatRequestMessage {
|
||||||
|
wireMsg := openAIChatRequestMessage{
|
||||||
|
Role: msg.Role,
|
||||||
|
Content: msg.Content,
|
||||||
|
}
|
||||||
|
if msg.CacheControl == nil {
|
||||||
|
return wireMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
wireMsg.Content = []openAIChatTextContentBlock{
|
||||||
|
{
|
||||||
|
Type: "text",
|
||||||
|
Text: msg.Content,
|
||||||
|
CacheControl: &openAICacheControl{
|
||||||
|
Type: string(msg.CacheControl.Type),
|
||||||
|
TTL: msg.CacheControl.TTL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return wireMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
func toOpenAIResponseFormat(spec *domain.StructuredOutputSpec) (*openAIResponseFormat, error) {
|
||||||
|
if spec == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch spec.Type {
|
||||||
|
case domain.StructuredOutputJSONSchema:
|
||||||
|
if spec.JSONSchema == nil {
|
||||||
|
return nil, errors.New("json_schema structured output requires schema payload")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(spec.JSONSchema.Name) == "" {
|
||||||
|
return nil, errors.New("json_schema structured output requires non-empty schema name")
|
||||||
|
}
|
||||||
|
if spec.JSONSchema.Schema == nil {
|
||||||
|
return nil, errors.New("json_schema structured output requires schema document")
|
||||||
|
}
|
||||||
|
return &openAIResponseFormat{
|
||||||
|
Type: "json_schema",
|
||||||
|
JSONSchema: &openAIJSONSchemaEnvelope{
|
||||||
|
Name: spec.JSONSchema.Name,
|
||||||
|
Strict: spec.JSONSchema.Strict,
|
||||||
|
Schema: spec.JSONSchema.Schema,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported structured output type %q", spec.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
1192
internal/llm/openai_compatible_client_test.go
Normal file
1192
internal/llm/openai_compatible_client_test.go
Normal file
File diff suppressed because it is too large
Load Diff
9
internal/profile/builtin/assets/aion-labs/aion-2.yml
Normal file
9
internal/profile/builtin/assets/aion-labs/aion-2.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
id: aion-2
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: aion-labs/aion-2.0
|
||||||
|
temperature: 0.72
|
||||||
|
reasoning_effort: high
|
||||||
|
top_p: 0.95
|
||||||
|
timeout_seconds: 180
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
id: claude-fable-latest
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: "~anthropic/claude-fable-latest"
|
||||||
|
reasoning_effort: high
|
||||||
|
timeout_seconds: 600
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
id: claude-haiku-latest
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: "~anthropic/claude-haiku-latest"
|
||||||
|
reasoning_effort: medium
|
||||||
|
timeout_seconds: 240
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
id: claude-opus-latest
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: "~anthropic/claude-opus-latest"
|
||||||
|
reasoning_effort: high
|
||||||
|
timeout_seconds: 240
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
id: claude-sonnet-latest
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: "~anthropic/claude-sonnet-latest"
|
||||||
|
reasoning_effort: high
|
||||||
|
timeout_seconds: 240
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
id: deepseek-3-2
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: deepseek/deepseek-v3.2
|
||||||
|
reasoning_effort: high
|
||||||
|
timeout_seconds: 180
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
id: deepseek-4-flash
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: deepseek/deepseek-v4-flash
|
||||||
|
#reasoning_effort: medium
|
||||||
|
timeout_seconds: 180
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
id: deepseek-4-pro
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: deepseek/deepseek-v4-pro
|
||||||
|
reasoning_effort: high
|
||||||
|
timeout_seconds: 180
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
id: gemini-2-flash-lite
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: "google/gemini-2.5-flash-lite"
|
||||||
|
#temperature: 0.15
|
||||||
|
reasoning_effort: high
|
||||||
|
#top_p: 0.98
|
||||||
|
timeout_seconds: 240
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
id: gemini-2-flash
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: "google/gemini-2.5-flash"
|
||||||
|
#temperature: 0.15
|
||||||
|
reasoning_effort: high
|
||||||
|
#top_p: 0.98
|
||||||
|
timeout_seconds: 240
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
service_tier: flex
|
||||||
9
internal/profile/builtin/assets/google/gemini-2-pro.yml
Normal file
9
internal/profile/builtin/assets/google/gemini-2-pro.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
id: gemini-2-pro
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: "google/gemini-2.5-pro"
|
||||||
|
#temperature: 0.15
|
||||||
|
reasoning_effort: high
|
||||||
|
#top_p: 0.98
|
||||||
|
timeout_seconds: 240
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
id: gemini-3-flash-lite
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: "google/gemini-3.1-flash-lite"
|
||||||
|
#temperature: 0.15
|
||||||
|
reasoning_effort: high
|
||||||
|
#top_p: 0.98
|
||||||
|
timeout_seconds: 240
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
id: gemini-flash-latest
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: "~google/gemini-flash-latest"
|
||||||
|
#temperature: 0.15
|
||||||
|
reasoning_effort: high
|
||||||
|
#top_p: 0.98
|
||||||
|
timeout_seconds: 240
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
id: gemini-pro-latest
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: "~google/gemini-pro-latest"
|
||||||
|
#temperature: 0.15
|
||||||
|
reasoning_effort: high
|
||||||
|
#top_p: 0.98
|
||||||
|
timeout_seconds: 240
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
service_tier: flex
|
||||||
9
internal/profile/builtin/assets/google/gemma-4-31b.yml
Normal file
9
internal/profile/builtin/assets/google/gemma-4-31b.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
id: gemma-4-31b
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: google/gemma-4-31b-it:exacto
|
||||||
|
temperature: 0.15
|
||||||
|
reasoning_effort: high
|
||||||
|
top_p: 0.98
|
||||||
|
timeout_seconds: 240
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
service_tier: flex
|
||||||
9
internal/profile/builtin/assets/minimax/minimax-m2.yml
Normal file
9
internal/profile/builtin/assets/minimax/minimax-m2.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
id: minimax-m2
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: minimax/minimax-m2.5
|
||||||
|
temperature: 0.5
|
||||||
|
reasoning_effort: high
|
||||||
|
top_p: 0.95
|
||||||
|
timeout_seconds: 180
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
service_tier: flex
|
||||||
9
internal/profile/builtin/assets/minimax/minimax-m3.yml
Normal file
9
internal/profile/builtin/assets/minimax/minimax-m3.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
id: minimax-m3
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: minimax/minimax-m3
|
||||||
|
#temperature: 0.5
|
||||||
|
reasoning_effort: high
|
||||||
|
#top_p: 0.95
|
||||||
|
timeout_seconds: 180
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
service_tier: flex
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
id: mistral-large-2512
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: mistralai/mistral-large-2512
|
||||||
|
temperature: 0.15
|
||||||
|
top_p: 0.98
|
||||||
|
timeout_seconds: 180
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
id: mistral-medium-3-5
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: mistralai/mistral-medium-3-5
|
||||||
|
temperature: 0.15
|
||||||
|
reasoning_effort: high
|
||||||
|
top_p: 0.98
|
||||||
|
timeout_seconds: 180
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
id: mistral-small-3
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: mistralai/mistral-small-3.2-24b-instruct
|
||||||
|
temperature: 0.05
|
||||||
|
top_p: 1.0
|
||||||
|
timeout_seconds: 180
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
id: mistral-small-4
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: mistralai/mistral-small-2603
|
||||||
|
temperature: 0.1
|
||||||
|
reasoning_effort: high
|
||||||
|
top_p: 0.98
|
||||||
|
timeout_seconds: 180
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
id: nemotron-3-ultra
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: nvidia/nemotron-3-ultra-550b-a55b
|
||||||
|
reasoning_effort: high
|
||||||
|
timeout_seconds: 180
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
service_tier: flex
|
||||||
7
internal/profile/builtin/assets/openai/gpt-5-mini.yml
Normal file
7
internal/profile/builtin/assets/openai/gpt-5-mini.yml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
id: gpt-5-mini
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: "openai/gpt-5.4-mini"
|
||||||
|
reasoning_effort: high
|
||||||
|
timeout_seconds: 240
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
service_tier: flex
|
||||||
7
internal/profile/builtin/assets/openai/gpt-5-nano.yml
Normal file
7
internal/profile/builtin/assets/openai/gpt-5-nano.yml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
id: gpt-5-nano
|
||||||
|
endpoint: https://openrouter.ai/api/v1
|
||||||
|
model: "openai/gpt-5.4-nano"
|
||||||
|
reasoning_effort: high
|
||||||
|
timeout_seconds: 240
|
||||||
|
api_key_env: OPENROUTER_API_KEY
|
||||||
|
service_tier: flex
|
||||||
31
internal/profile/builtin/repository.go
Normal file
31
internal/profile/builtin/repository.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
const assetRoot = "assets"
|
||||||
|
|
||||||
|
//go:embed assets/**/*.yml
|
||||||
|
var assets embed.FS
|
||||||
|
|
||||||
|
func NewRepository() profile.Repository {
|
||||||
|
return profile.NewFSRepository(assets, assetRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepositoryWithPrimary(primary profile.Repository) profile.Repository {
|
||||||
|
if primary == nil {
|
||||||
|
return NewRepository()
|
||||||
|
}
|
||||||
|
return profile.NewOverlayRepository(primary, NewRepository())
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepositoryWithDirectory(dir string) profile.Repository {
|
||||||
|
if strings.TrimSpace(dir) == "" {
|
||||||
|
return NewRepository()
|
||||||
|
}
|
||||||
|
return NewRepositoryWithPrimary(profile.NewFilesystemRepository(dir))
|
||||||
|
}
|
||||||
127
internal/profile/builtin/repository_test.go
Normal file
127
internal/profile/builtin/repository_test.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuiltInProfilesValidateThroughRepository(t *testing.T) {
|
||||||
|
repo := NewRepository()
|
||||||
|
ids := loadBuiltInProfileIDs(t)
|
||||||
|
if len(ids) == 0 {
|
||||||
|
t.Fatal("expected built-in profiles")
|
||||||
|
}
|
||||||
|
|
||||||
|
for id := range ids {
|
||||||
|
t.Run(id, func(t *testing.T) {
|
||||||
|
p, err := repo.GetProfile(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected built-in profile %q to load, got %v", id, err)
|
||||||
|
}
|
||||||
|
if p.ID != id {
|
||||||
|
t.Fatalf("expected profile id %q, got %q", id, p.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltInProfilesDoNotContainDuplicateIDsOrRawAPIKeys(t *testing.T) {
|
||||||
|
loadBuiltInProfileIDs(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadBuiltInProfileIDs(t *testing.T) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ids := map[string]string{}
|
||||||
|
err := fs.WalkDir(assets, assetRoot, func(name string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() || !strings.HasSuffix(name, ".yml") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := assets.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read built-in profile %s: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw map[string]any
|
||||||
|
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||||
|
t.Fatalf("failed to decode built-in profile %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if _, ok := raw["api_key"]; ok {
|
||||||
|
t.Fatalf("built-in profile %s contains raw api_key", name)
|
||||||
|
}
|
||||||
|
id, ok := raw["id"].(string)
|
||||||
|
if !ok || strings.TrimSpace(id) == "" {
|
||||||
|
t.Fatalf("built-in profile %s has missing id", name)
|
||||||
|
}
|
||||||
|
if previous, ok := ids[id]; ok {
|
||||||
|
t.Fatalf("duplicate built-in profile id %q in %s and %s", id, previous, name)
|
||||||
|
}
|
||||||
|
ids[id] = name
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to walk built-in profiles: %v", err)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepositoryWithPrimaryUsesPrimaryBeforeBuiltIns(t *testing.T) {
|
||||||
|
repo := NewRepositoryWithPrimary(staticProfileRepo{
|
||||||
|
profiles: map[string]string{"mistral-small-3": "custom-model"},
|
||||||
|
})
|
||||||
|
|
||||||
|
p, err := repo.GetProfile(context.Background(), "mistral-small-3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected profile to load, got %v", err)
|
||||||
|
}
|
||||||
|
if p.Model != "custom-model" {
|
||||||
|
t.Fatalf("expected primary profile to override built-in, got %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepositoryWithPrimaryFallsBackToBuiltIns(t *testing.T) {
|
||||||
|
repo := NewRepositoryWithPrimary(staticProfileRepo{})
|
||||||
|
|
||||||
|
p, err := repo.GetProfile(context.Background(), "mistral-small-3")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected built-in profile to load, got %v", err)
|
||||||
|
}
|
||||||
|
if p.ID != "mistral-small-3" {
|
||||||
|
t.Fatalf("unexpected profile: %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepositoryWithPrimaryDoesNotFallBackAfterPrimaryError(t *testing.T) {
|
||||||
|
repo := NewRepositoryWithPrimary(staticProfileRepo{err: profile.ErrInvalidProfile})
|
||||||
|
|
||||||
|
_, err := repo.GetProfile(context.Background(), "mistral-small-3")
|
||||||
|
if !errors.Is(err, profile.ErrInvalidProfile) {
|
||||||
|
t.Fatalf("expected primary error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type staticProfileRepo struct {
|
||||||
|
profiles map[string]string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r staticProfileRepo) GetProfile(_ context.Context, id string) (*domain.ExecutionProfile, error) {
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, r.err
|
||||||
|
}
|
||||||
|
if model, ok := r.profiles[id]; ok {
|
||||||
|
return &domain.ExecutionProfile{ID: id, Endpoint: "http://primary/v1", Model: model}, nil
|
||||||
|
}
|
||||||
|
return nil, profile.ErrProfileNotFound
|
||||||
|
}
|
||||||
213
internal/profile/filesystem_repository.go
Normal file
213
internal/profile/filesystem_repository.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package profile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/filecatalog"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrProfileNotFound = errors.New("execution profile not found")
|
||||||
|
ErrInvalidYAML = errors.New("invalid YAML format")
|
||||||
|
ErrInvalidProfile = errors.New("invalid execution profile configuration")
|
||||||
|
ErrRawAPIKeyNotAllowed = errors.New("raw api_key is not allowed; use api_key_env")
|
||||||
|
)
|
||||||
|
|
||||||
|
type filesystemRepository struct {
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFilesystemRepository(dir string) Repository {
|
||||||
|
return &filesystemRepository{dir: dir}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *filesystemRepository) GetProfile(ctx context.Context, id string) (*domain.ExecutionProfile, error) {
|
||||||
|
return loadProfile(ctx, os.DirFS(r.dir), ".", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
type fsRepository struct {
|
||||||
|
fsys fs.FS
|
||||||
|
root string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFSRepository(fsys fs.FS, root string) Repository {
|
||||||
|
return &fsRepository{fsys: fsys, root: root}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fsRepository) GetProfile(ctx context.Context, id string) (*domain.ExecutionProfile, error) {
|
||||||
|
return loadProfile(ctx, r.fsys, r.root, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
type overlayRepository struct {
|
||||||
|
primary Repository
|
||||||
|
fallback Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOverlayRepository(primary, fallback Repository) Repository {
|
||||||
|
return &overlayRepository{primary: primary, fallback: fallback}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *overlayRepository) GetProfile(ctx context.Context, id string) (*domain.ExecutionProfile, error) {
|
||||||
|
if r.primary != nil {
|
||||||
|
prof, err := r.primary.GetProfile(ctx, id)
|
||||||
|
if err == nil {
|
||||||
|
return prof, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrProfileNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.fallback == nil {
|
||||||
|
return nil, ErrProfileNotFound
|
||||||
|
}
|
||||||
|
return r.fallback.GetProfile(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadProfile(ctx context.Context, fsys fs.FS, root string, id string) (*domain.ExecutionProfile, error) {
|
||||||
|
if strings.TrimSpace(id) == "" {
|
||||||
|
return nil, fmt.Errorf("%w: profile id is required", ErrInvalidProfile)
|
||||||
|
}
|
||||||
|
if fsys == nil {
|
||||||
|
return nil, fmt.Errorf("failed to read profile directory: filesystem is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := filecatalog.FindFSYAMLFiles(ctx, fsys, root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read profile directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var matches []profileMatch
|
||||||
|
for _, fullPath := range files {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath := filecatalog.DisplayPath(root, fullPath)
|
||||||
|
fileMatch := filecatalog.Stem(path.Base(fullPath)) == id
|
||||||
|
data, err := fs.ReadFile(fsys, fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read profile file %s: %w", relPath, err)
|
||||||
|
}
|
||||||
|
metadata := readProfileFileMetadata(data)
|
||||||
|
idMatch := fileMatch || metadata.id == id
|
||||||
|
if metadata.hasRawAPIKey {
|
||||||
|
if idMatch {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrRawAPIKeyNotAllowed, relPath)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var prof domain.ExecutionProfile
|
||||||
|
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.KnownFields(true)
|
||||||
|
if err := decoder.Decode(&prof); err != nil {
|
||||||
|
if idMatch {
|
||||||
|
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, relPath, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if prof.ID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := validateProfile(&prof); err != nil {
|
||||||
|
if errors.Is(err, ErrRawAPIKeyNotAllowed) {
|
||||||
|
return nil, fmt.Errorf("%w: %s", err, relPath)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidProfile, relPath, err)
|
||||||
|
}
|
||||||
|
matches = append(matches, profileMatch{
|
||||||
|
profile: &prof,
|
||||||
|
path: relPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) > 1 {
|
||||||
|
paths := make([]string, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
paths = append(paths, match.path)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%w: duplicate execution profile id %q found in: %s", ErrInvalidProfile, id, strings.Join(paths, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) == 1 {
|
||||||
|
return matches[0].profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, ErrProfileNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
type profileMatch struct {
|
||||||
|
profile *domain.ExecutionProfile
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
type profileFileMetadata struct {
|
||||||
|
id string
|
||||||
|
hasRawAPIKey bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func readProfileFileMetadata(data []byte) profileFileMetadata {
|
||||||
|
var node yaml.Node
|
||||||
|
if err := yaml.NewDecoder(bytes.NewReader(data)).Decode(&node); err != nil {
|
||||||
|
return profileFileMetadata{}
|
||||||
|
}
|
||||||
|
if node.Kind != yaml.DocumentNode || len(node.Content) == 0 {
|
||||||
|
return profileFileMetadata{}
|
||||||
|
}
|
||||||
|
mapping := node.Content[0]
|
||||||
|
if mapping.Kind != yaml.MappingNode {
|
||||||
|
return profileFileMetadata{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata profileFileMetadata
|
||||||
|
for i := 0; i+1 < len(mapping.Content); i += 2 {
|
||||||
|
key := mapping.Content[i]
|
||||||
|
value := mapping.Content[i+1]
|
||||||
|
switch key.Value {
|
||||||
|
case "id":
|
||||||
|
metadata.id = strings.TrimSpace(value.Value)
|
||||||
|
case "api_key":
|
||||||
|
metadata.hasRawAPIKey = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateProfile(p *domain.ExecutionProfile) error {
|
||||||
|
if strings.TrimSpace(p.ID) == "" {
|
||||||
|
return errors.New("id is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.Endpoint) == "" {
|
||||||
|
return errors.New("endpoint is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.Model) == "" {
|
||||||
|
return errors.New("model is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Temperature < 0 || p.Temperature > 2 {
|
||||||
|
return errors.New("temperature must be between 0 and 2")
|
||||||
|
}
|
||||||
|
if p.MaxTokens < 0 {
|
||||||
|
return errors.New("max_tokens must be greater than or equal to 0")
|
||||||
|
}
|
||||||
|
if p.TopP < 0 || p.TopP > 1 {
|
||||||
|
return errors.New("top_p must be between 0 and 1")
|
||||||
|
}
|
||||||
|
if p.TimeoutSeconds < 0 {
|
||||||
|
return errors.New("timeout_seconds must be greater than or equal to 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
12
internal/profile/repository.go
Normal file
12
internal/profile/repository.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package profile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repository loads execution profiles.
|
||||||
|
type Repository interface {
|
||||||
|
GetProfile(ctx context.Context, id string) (*domain.ExecutionProfile, error)
|
||||||
|
}
|
||||||
479
internal/profile/repository_test.go
Normal file
479
internal/profile/repository_test.go
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
package profile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFilesystemRepository_GetProfile(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "execution_profile_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
files, err := os.ReadDir("testdata")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read testdata: %v", err)
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
src := filepath.Join("testdata", f.Name())
|
||||||
|
dst := filepath.Join(tmpDir, f.Name())
|
||||||
|
data, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dst, data, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := NewFilesystemRepository(tmpDir)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("valid local profile", func(t *testing.T) {
|
||||||
|
p, err := repo.GetProfile(ctx, "local-default")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if p.ID != "local-default" {
|
||||||
|
t.Fatalf("unexpected id: %q", p.ID)
|
||||||
|
}
|
||||||
|
if p.Endpoint == "" || p.Model == "" {
|
||||||
|
t.Fatalf("expected endpoint/model to be set: %+v", p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid profile with api_key_env", func(t *testing.T) {
|
||||||
|
p, err := repo.GetProfile(ctx, "local-secure")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if p.APIKeyEnv != "PROMPTKIT_API_KEY" {
|
||||||
|
t.Fatalf("unexpected api_key_env: %q", p.APIKeyEnv)
|
||||||
|
}
|
||||||
|
if p.ReasoningEffort != "medium" {
|
||||||
|
t.Fatalf("unexpected reasoning_effort: %q", p.ReasoningEffort)
|
||||||
|
}
|
||||||
|
if p.ServiceTier != "priority" {
|
||||||
|
t.Fatalf("unexpected service_tier: %q", p.ServiceTier)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid nested profile", func(t *testing.T) {
|
||||||
|
nestedDir := filepath.Join(tmpDir, "local")
|
||||||
|
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeProfileTestFile(t, filepath.Join(nestedDir, "nested-local.yaml"), `
|
||||||
|
id: nested-local
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: nested-model
|
||||||
|
temperature: 0.1
|
||||||
|
`)
|
||||||
|
|
||||||
|
p, err := repo.GetProfile(ctx, "nested-local")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if p.Model != "nested-model" {
|
||||||
|
t.Fatalf("unexpected model: %q", p.Model)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid profile with JSON-compatible extra params", func(t *testing.T) {
|
||||||
|
writeProfileTestFile(t, filepath.Join(tmpDir, "json-extra-params.yaml"), `
|
||||||
|
id: json-extra-params
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: nested-model
|
||||||
|
extra_params:
|
||||||
|
string_value: enabled
|
||||||
|
number_value: 42
|
||||||
|
boolean_value: true
|
||||||
|
object_value:
|
||||||
|
nested: value
|
||||||
|
count: 2
|
||||||
|
array_value:
|
||||||
|
- first
|
||||||
|
- 3
|
||||||
|
- false
|
||||||
|
`)
|
||||||
|
|
||||||
|
p, err := repo.GetProfile(ctx, "json-extra-params")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got map[string]any
|
||||||
|
encoded, err := json.Marshal(p.ExtraParams)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected extra_params to marshal as JSON, got %v", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(encoded, &got); err != nil {
|
||||||
|
t.Fatalf("expected extra_params JSON to decode, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got["string_value"] != "enabled" {
|
||||||
|
t.Fatalf("unexpected string extra param: %#v", got["string_value"])
|
||||||
|
}
|
||||||
|
if got["number_value"] != float64(42) {
|
||||||
|
t.Fatalf("unexpected number extra param: %#v", got["number_value"])
|
||||||
|
}
|
||||||
|
if got["boolean_value"] != true {
|
||||||
|
t.Fatalf("unexpected boolean extra param: %#v", got["boolean_value"])
|
||||||
|
}
|
||||||
|
objectValue, ok := got["object_value"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected object extra param, got %#v", got["object_value"])
|
||||||
|
}
|
||||||
|
if objectValue["nested"] != "value" || objectValue["count"] != float64(2) {
|
||||||
|
t.Fatalf("unexpected object extra param: %#v", objectValue)
|
||||||
|
}
|
||||||
|
arrayValue, ok := got["array_value"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected array extra param, got %#v", got["array_value"])
|
||||||
|
}
|
||||||
|
if len(arrayValue) != 3 || arrayValue[0] != "first" || arrayValue[1] != float64(3) || arrayValue[2] != false {
|
||||||
|
t.Fatalf("unexpected array extra param: %#v", arrayValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("duplicate profile IDs fail as ambiguous", func(t *testing.T) {
|
||||||
|
writeProfileTestFile(t, filepath.Join(tmpDir, "duplicate-profile-a.yaml"), `
|
||||||
|
id: duplicate-profile
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: first-model
|
||||||
|
`)
|
||||||
|
nestedDir := filepath.Join(tmpDir, "duplicates")
|
||||||
|
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeProfileTestFile(t, filepath.Join(nestedDir, "duplicate-profile-b.yaml"), `
|
||||||
|
id: duplicate-profile
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: second-model
|
||||||
|
`)
|
||||||
|
|
||||||
|
_, err := repo.GetProfile(ctx, "duplicate-profile")
|
||||||
|
if !errors.Is(err, ErrInvalidProfile) {
|
||||||
|
t.Fatalf("expected duplicate profile to return ErrInvalidProfile, got %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"duplicate execution profile id", "duplicate-profile-a.yaml", filepath.Join("duplicates", "duplicate-profile-b.yaml")} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("expected error to contain %q, got %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nested raw api_key rejected for likely target file", func(t *testing.T) {
|
||||||
|
nestedDir := filepath.Join(tmpDir, "secure")
|
||||||
|
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeProfileTestFile(t, filepath.Join(nestedDir, "not_named_like_id.yaml"), `
|
||||||
|
id: nested_raw_api_key
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: m
|
||||||
|
api_key: secret
|
||||||
|
`)
|
||||||
|
|
||||||
|
_, err := repo.GetProfile(ctx, "nested_raw_api_key")
|
||||||
|
if !errors.Is(err, ErrRawAPIKeyNotAllowed) {
|
||||||
|
t.Fatalf("expected ErrRawAPIKeyNotAllowed, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), filepath.Join("secure", "not_named_like_id.yaml")) {
|
||||||
|
t.Fatalf("expected nested path in error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("raw api_key in non-target profile is ignored", func(t *testing.T) {
|
||||||
|
writeProfileTestFile(t, filepath.Join(tmpDir, "raw-api-key-non-target.yaml"), `
|
||||||
|
id: raw-api-key-non-target
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: m
|
||||||
|
api_key: secret
|
||||||
|
`)
|
||||||
|
|
||||||
|
_, err := repo.GetProfile(ctx, "does-not-exist-with-raw-key-nearby")
|
||||||
|
if !errors.Is(err, ErrProfileNotFound) {
|
||||||
|
t.Fatalf("expected ErrProfileNotFound for non-target raw api_key file, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid yaml", func(t *testing.T) {
|
||||||
|
_, err := repo.GetProfile(ctx, "invalid_yaml")
|
||||||
|
if !errors.Is(err, ErrInvalidYAML) {
|
||||||
|
t.Fatalf("expected ErrInvalidYAML, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing id", func(t *testing.T) {
|
||||||
|
_, err := repo.GetProfile(ctx, "missing_id")
|
||||||
|
if !errors.Is(err, ErrProfileNotFound) {
|
||||||
|
t.Fatalf("expected ErrProfileNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing endpoint", func(t *testing.T) {
|
||||||
|
_, err := repo.GetProfile(ctx, "missing-endpoint")
|
||||||
|
if !errors.Is(err, ErrInvalidProfile) {
|
||||||
|
t.Fatalf("expected ErrInvalidProfile, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing model", func(t *testing.T) {
|
||||||
|
_, err := repo.GetProfile(ctx, "missing-model")
|
||||||
|
if !errors.Is(err, ErrInvalidProfile) {
|
||||||
|
t.Fatalf("expected ErrInvalidProfile, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown field", func(t *testing.T) {
|
||||||
|
_, err := repo.GetProfile(ctx, "unknown_field")
|
||||||
|
if !errors.Is(err, ErrInvalidYAML) {
|
||||||
|
t.Fatalf("expected ErrInvalidYAML for strict decode unknown field, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("raw api_key rejected", func(t *testing.T) {
|
||||||
|
_, err := repo.GetProfile(ctx, "raw_api_key")
|
||||||
|
if !errors.Is(err, ErrRawAPIKeyNotAllowed) {
|
||||||
|
t.Fatalf("expected ErrRawAPIKeyNotAllowed, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("profile not found", func(t *testing.T) {
|
||||||
|
_, err := repo.GetProfile(ctx, "does-not-exist")
|
||||||
|
if !errors.Is(err, ErrProfileNotFound) {
|
||||||
|
t.Fatalf("expected ErrProfileNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeProfileTestFile(t *testing.T, path string, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, []byte(strings.TrimLeft(content, "\n")), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write profile test file %q: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFSRepository(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("loads valid profiles from nested directories", func(t *testing.T) {
|
||||||
|
repo := NewFSRepository(fstest.MapFS{
|
||||||
|
"profiles/provider/nested.yaml": profileMapFile(`
|
||||||
|
id: nested-profile
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: nested-model
|
||||||
|
temperature: 0.1
|
||||||
|
`),
|
||||||
|
}, "profiles")
|
||||||
|
|
||||||
|
p, err := repo.GetProfile(ctx, "nested-profile")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if p.ID != "nested-profile" || p.Model != "nested-model" {
|
||||||
|
t.Fatalf("unexpected profile: %+v", p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects unknown YAML fields", func(t *testing.T) {
|
||||||
|
repo := NewFSRepository(fstest.MapFS{
|
||||||
|
"profiles/unknown.yaml": profileMapFile(`
|
||||||
|
id: unknown-profile
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: model
|
||||||
|
unknown: value
|
||||||
|
`),
|
||||||
|
}, "profiles")
|
||||||
|
|
||||||
|
_, err := repo.GetProfile(ctx, "unknown-profile")
|
||||||
|
if !errors.Is(err, ErrInvalidYAML) {
|
||||||
|
t.Fatalf("expected ErrInvalidYAML, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects raw api_key in selected profile", func(t *testing.T) {
|
||||||
|
repo := NewFSRepository(fstest.MapFS{
|
||||||
|
"profiles/raw.yaml": profileMapFile(`
|
||||||
|
id: raw-profile
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: model
|
||||||
|
api_key: secret
|
||||||
|
`),
|
||||||
|
}, "profiles")
|
||||||
|
|
||||||
|
_, err := repo.GetProfile(ctx, "raw-profile")
|
||||||
|
if !errors.Is(err, ErrRawAPIKeyNotAllowed) {
|
||||||
|
t.Fatalf("expected ErrRawAPIKeyNotAllowed, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ignores raw api_key in non-selected profiles", func(t *testing.T) {
|
||||||
|
repo := NewFSRepository(fstest.MapFS{
|
||||||
|
"profiles/raw.yaml": profileMapFile(`
|
||||||
|
id: raw-profile
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: model
|
||||||
|
api_key: secret
|
||||||
|
`),
|
||||||
|
"profiles/valid.yaml": profileMapFile(`
|
||||||
|
id: valid-profile
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: model
|
||||||
|
`),
|
||||||
|
}, "profiles")
|
||||||
|
|
||||||
|
p, err := repo.GetProfile(ctx, "valid-profile")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if p.ID != "valid-profile" {
|
||||||
|
t.Fatalf("unexpected profile: %+v", p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects duplicate IDs within one source", func(t *testing.T) {
|
||||||
|
repo := NewFSRepository(fstest.MapFS{
|
||||||
|
"profiles/a.yaml": profileMapFile(`
|
||||||
|
id: duplicate-profile
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: first
|
||||||
|
`),
|
||||||
|
"profiles/nested/b.yaml": profileMapFile(`
|
||||||
|
id: duplicate-profile
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: second
|
||||||
|
`),
|
||||||
|
}, "profiles")
|
||||||
|
|
||||||
|
_, err := repo.GetProfile(ctx, "duplicate-profile")
|
||||||
|
if !errors.Is(err, ErrInvalidProfile) {
|
||||||
|
t.Fatalf("expected ErrInvalidProfile, got %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"duplicate execution profile id", "a.yaml", "nested/b.yaml"} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("expected error to contain %q, got %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayRepository(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
primaryProfile := &domain.ExecutionProfile{ID: "shared", Endpoint: "http://primary", Model: "primary"}
|
||||||
|
fallbackProfile := &domain.ExecutionProfile{ID: "shared", Endpoint: "http://fallback", Model: "fallback"}
|
||||||
|
|
||||||
|
t.Run("returns primary matches before fallback matches", func(t *testing.T) {
|
||||||
|
repo := NewOverlayRepository(
|
||||||
|
staticProfileRepo{profiles: map[string]*domain.ExecutionProfile{"shared": primaryProfile}},
|
||||||
|
staticProfileRepo{profiles: map[string]*domain.ExecutionProfile{"shared": fallbackProfile}},
|
||||||
|
)
|
||||||
|
|
||||||
|
p, err := repo.GetProfile(ctx, "shared")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if p.Model != "primary" {
|
||||||
|
t.Fatalf("expected primary profile, got %+v", p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("falls back on primary not found", func(t *testing.T) {
|
||||||
|
repo := NewOverlayRepository(
|
||||||
|
staticProfileRepo{},
|
||||||
|
staticProfileRepo{profiles: map[string]*domain.ExecutionProfile{"shared": fallbackProfile}},
|
||||||
|
)
|
||||||
|
|
||||||
|
p, err := repo.GetProfile(ctx, "shared")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if p.Model != "fallback" {
|
||||||
|
t.Fatalf("expected fallback profile, got %+v", p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("does not fall back after primary load errors", func(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{name: "invalid yaml", err: ErrInvalidYAML},
|
||||||
|
{name: "invalid profile", err: ErrInvalidProfile},
|
||||||
|
{name: "raw api key", err: ErrRawAPIKeyNotAllowed},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
repo := NewOverlayRepository(
|
||||||
|
staticProfileRepo{err: tc.err},
|
||||||
|
staticProfileRepo{profiles: map[string]*domain.ExecutionProfile{"shared": fallbackProfile}},
|
||||||
|
)
|
||||||
|
|
||||||
|
_, err := repo.GetProfile(ctx, "shared")
|
||||||
|
if !errors.Is(err, tc.err) {
|
||||||
|
t.Fatalf("expected %v, got %v", tc.err, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns not found when both sources miss", func(t *testing.T) {
|
||||||
|
repo := NewOverlayRepository(staticProfileRepo{}, staticProfileRepo{})
|
||||||
|
|
||||||
|
_, err := repo.GetProfile(ctx, "missing")
|
||||||
|
if !errors.Is(err, ErrProfileNotFound) {
|
||||||
|
t.Fatalf("expected ErrProfileNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil primary uses fallback", func(t *testing.T) {
|
||||||
|
repo := NewOverlayRepository(nil, staticProfileRepo{profiles: map[string]*domain.ExecutionProfile{"shared": fallbackProfile}})
|
||||||
|
|
||||||
|
p, err := repo.GetProfile(ctx, "shared")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if p.Model != "fallback" {
|
||||||
|
t.Fatalf("expected fallback profile, got %+v", p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil fallback returns not found after primary miss", func(t *testing.T) {
|
||||||
|
repo := NewOverlayRepository(staticProfileRepo{}, nil)
|
||||||
|
|
||||||
|
_, err := repo.GetProfile(ctx, "missing")
|
||||||
|
if !errors.Is(err, ErrProfileNotFound) {
|
||||||
|
t.Fatalf("expected ErrProfileNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func profileMapFile(content string) *fstest.MapFile {
|
||||||
|
return &fstest.MapFile{Data: []byte(strings.TrimLeft(content, "\n"))}
|
||||||
|
}
|
||||||
|
|
||||||
|
type staticProfileRepo struct {
|
||||||
|
profiles map[string]*domain.ExecutionProfile
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r staticProfileRepo) GetProfile(_ context.Context, id string) (*domain.ExecutionProfile, error) {
|
||||||
|
if r.err != nil {
|
||||||
|
return nil, r.err
|
||||||
|
}
|
||||||
|
if p, ok := r.profiles[id]; ok {
|
||||||
|
cp := *p
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
return nil, ErrProfileNotFound
|
||||||
|
}
|
||||||
3
internal/profile/testdata/invalid_yaml.yaml
vendored
Normal file
3
internal/profile/testdata/invalid_yaml.yaml
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
id: invalid_yaml
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: [broken
|
||||||
2
internal/profile/testdata/missing_endpoint.yaml
vendored
Normal file
2
internal/profile/testdata/missing_endpoint.yaml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
id: missing-endpoint
|
||||||
|
model: gpt-4o-mini
|
||||||
2
internal/profile/testdata/missing_id.yaml
vendored
Normal file
2
internal/profile/testdata/missing_id.yaml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: gpt-4o-mini
|
||||||
2
internal/profile/testdata/missing_model.yaml
vendored
Normal file
2
internal/profile/testdata/missing_model.yaml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
id: missing-model
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
4
internal/profile/testdata/raw_api_key.yaml
vendored
Normal file
4
internal/profile/testdata/raw_api_key.yaml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
id: raw-api-key
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: gpt-4o-mini
|
||||||
|
api_key: super-secret-should-not-be-here
|
||||||
4
internal/profile/testdata/unknown_field.yaml
vendored
Normal file
4
internal/profile/testdata/unknown_field.yaml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
id: unknown-field
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: gpt-4o-mini
|
||||||
|
foo: bar
|
||||||
7
internal/profile/testdata/valid_local_profile.yaml
vendored
Normal file
7
internal/profile/testdata/valid_local_profile.yaml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
id: local-default
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: gpt-4o-mini
|
||||||
|
temperature: 0.2
|
||||||
|
max_tokens: 700
|
||||||
|
top_p: 1.0
|
||||||
|
timeout_seconds: 120
|
||||||
8
internal/profile/testdata/valid_with_api_key_env.yaml
vendored
Normal file
8
internal/profile/testdata/valid_with_api_key_env.yaml
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
id: local-secure
|
||||||
|
endpoint: http://localhost:8000/v1
|
||||||
|
model: gpt-4o-mini
|
||||||
|
api_key_env: PROMPTKIT_API_KEY
|
||||||
|
service_tier: priority
|
||||||
|
reasoning_effort: medium
|
||||||
|
extra_params:
|
||||||
|
provider: local
|
||||||
125
internal/prompt/go_renderer.go
Normal file
125
internal/prompt/go_renderer.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package prompt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrMissingRequiredInput = errors.New("missing required input artifact")
|
||||||
|
ErrUnknownInput = errors.New("referenced unknown input artifact")
|
||||||
|
ErrInvalidTemplate = errors.New("invalid prompt template")
|
||||||
|
ErrRenderFailure = errors.New("prompt render failure")
|
||||||
|
ErrInvalidMessageRole = errors.New("invalid or empty message role")
|
||||||
|
)
|
||||||
|
|
||||||
|
type goRenderer struct{}
|
||||||
|
|
||||||
|
func NewGoRenderer() Renderer {
|
||||||
|
return &goRenderer{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *goRenderer) Render(ctx context.Context, definition *domain.PromptDefinition, inputs map[string]*domain.Artifact, vars map[string]string) (*domain.RenderedPrompt, error) {
|
||||||
|
if definition == nil {
|
||||||
|
return nil, fmt.Errorf("%w: nil prompt definition", ErrRenderFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Verify required inputs
|
||||||
|
for _, in := range definition.Inputs {
|
||||||
|
if !in.Required {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
art, ok := inputs[in.Name]
|
||||||
|
if !ok || art == nil {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrMissingRequiredInput, in.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Setup template functions
|
||||||
|
funcs := template.FuncMap{
|
||||||
|
"input": func(name string) (string, error) {
|
||||||
|
art, ok := inputs[name]
|
||||||
|
if !ok || art == nil {
|
||||||
|
return "", fmt.Errorf("%w: %s", ErrUnknownInput, name)
|
||||||
|
}
|
||||||
|
return string(art.Body), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID, err := renderSessionID(definition.SessionID, funcs, vars)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var renderedMessages []domain.RenderedMessage
|
||||||
|
|
||||||
|
for i, tmplMsg := range definition.Templates {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if tmplMsg.Role == "" {
|
||||||
|
return nil, fmt.Errorf("%w: message %d", ErrInvalidMessageRole, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and execute template
|
||||||
|
tmpl, err := template.New(fmt.Sprintf("msg_%d", i)).Funcs(funcs).Option("missingkey=error").Parse(tmplMsg.Content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: message %d: %v", ErrInvalidTemplate, i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, vars); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: message %d: %w", ErrRenderFailure, i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderedMessages = append(renderedMessages, domain.RenderedMessage{
|
||||||
|
Role: tmplMsg.Role,
|
||||||
|
Content: buf.String(),
|
||||||
|
CacheControl: cloneCacheControl(tmplMsg.CacheControl),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.RenderedPrompt{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Messages: renderedMessages,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderSessionID(raw string, funcs template.FuncMap, vars map[string]string) (string, error) {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.New("session_id").Funcs(funcs).Option("missingkey=error").Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%w: session_id: %v", ErrInvalidTemplate, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, vars); err != nil {
|
||||||
|
return "", fmt.Errorf("%w: session_id: %w", ErrRenderFailure, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID := strings.TrimSpace(buf.String())
|
||||||
|
if n := utf8.RuneCountInString(sessionID); n > domain.SessionIDMaxLength {
|
||||||
|
return "", fmt.Errorf("%w: session_id length %d exceeds maximum %d", ErrRenderFailure, n, domain.SessionIDMaxLength)
|
||||||
|
}
|
||||||
|
return sessionID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneCacheControl(in *domain.CacheControl) *domain.CacheControl {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := *in
|
||||||
|
return &out
|
||||||
|
}
|
||||||
11
internal/prompt/renderer.go
Normal file
11
internal/prompt/renderer.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package prompt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Renderer renders prompt templates using named artifacts and variables.
|
||||||
|
type Renderer interface {
|
||||||
|
Render(ctx context.Context, definition *domain.PromptDefinition, inputs map[string]*domain.Artifact, vars map[string]string) (*domain.RenderedPrompt, error)
|
||||||
|
}
|
||||||
345
internal/prompt/renderer_test.go
Normal file
345
internal/prompt/renderer_test.go
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
package prompt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGoRenderer_Render(t *testing.T) {
|
||||||
|
renderer := NewGoRenderer()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
inputs := map[string]*domain.Artifact{
|
||||||
|
"transcript": {Body: []byte("The quick brown fox.")},
|
||||||
|
}
|
||||||
|
vars := map[string]string{
|
||||||
|
"role": "helpful assistant",
|
||||||
|
"tone": "concise",
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("rendering inline message content", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "user", Content: "Analyze this: {{input \"transcript\"}}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Messages) != 1 {
|
||||||
|
t.Fatalf("expected 1 message, got %d", len(res.Messages))
|
||||||
|
}
|
||||||
|
if res.Messages[0].Content != "Analyze this: The quick brown fox." {
|
||||||
|
t.Fatalf("unexpected rendered content: %q", res.Messages[0].Content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rendering file-backed message content loaded into prompt definition", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "user", Content: "From file: {{input \"transcript\"}}", ContentFile: "/tmp/user.tmpl"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got := res.Messages[0].Content; got != "From file: The quick brown fox." {
|
||||||
|
t.Fatalf("unexpected file-backed render result: %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rendering system and user messages", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "system", Content: "You are a {{.role}}."},
|
||||||
|
{Role: "user", Content: "Analyze this: {{input \"transcript\"}}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Messages) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages, got %d", len(res.Messages))
|
||||||
|
}
|
||||||
|
if res.Messages[0].Role != "system" || res.Messages[1].Role != "user" {
|
||||||
|
t.Fatalf("unexpected roles: %#v", res.Messages)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("copying cache control to rendered messages", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{
|
||||||
|
Role: "system",
|
||||||
|
Content: "You are concise.",
|
||||||
|
CacheControl: &domain.CacheControl{
|
||||||
|
Type: domain.CacheControlEphemeral,
|
||||||
|
TTL: "1h",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{Role: "user", Content: "Analyze this: {{input \"transcript\"}}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Messages) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages, got %d", len(res.Messages))
|
||||||
|
}
|
||||||
|
if res.Messages[0].CacheControl == nil {
|
||||||
|
t.Fatal("expected rendered cache control")
|
||||||
|
}
|
||||||
|
if res.Messages[0].CacheControl.Type != domain.CacheControlEphemeral {
|
||||||
|
t.Fatalf("unexpected cache control type: %q", res.Messages[0].CacheControl.Type)
|
||||||
|
}
|
||||||
|
if res.Messages[0].CacheControl.TTL != "1h" {
|
||||||
|
t.Fatalf("unexpected cache control ttl: %q", res.Messages[0].CacheControl.TTL)
|
||||||
|
}
|
||||||
|
if res.Messages[1].CacheControl != nil {
|
||||||
|
t.Fatalf("expected no cache control on second message, got %#v", res.Messages[1].CacheControl)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rendered cache control does not alias source template", func(t *testing.T) {
|
||||||
|
source := &domain.CacheControl{Type: domain.CacheControlEphemeral, TTL: "1h"}
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "system", Content: "You are concise.", CacheControl: source},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if res.Messages[0].CacheControl == source {
|
||||||
|
t.Fatal("expected rendered cache control to be cloned")
|
||||||
|
}
|
||||||
|
|
||||||
|
res.Messages[0].CacheControl.TTL = ""
|
||||||
|
if source.TTL != "1h" {
|
||||||
|
t.Fatalf("source cache control was mutated, ttl=%q", source.TTL)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("accessing vars", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "system", Content: "Speak in a {{.tone}} tone."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if res.Messages[0].Content != "Speak in a concise tone." {
|
||||||
|
t.Fatalf("unexpected vars rendering: %q", res.Messages[0].Content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rendering session id from vars", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
SessionID: " {{ .session_id }} ",
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "system", Content: "Speak in a {{.tone}} tone."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := renderer.Render(ctx, def, inputs, map[string]string{
|
||||||
|
"tone": "concise",
|
||||||
|
"session_id": "agent-session-123",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if res.SessionID != "agent-session-123" {
|
||||||
|
t.Fatalf("unexpected session id: %q", res.SessionID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty rendered session id is omitted", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
SessionID: " ",
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "system", Content: "Speak in a {{.tone}} tone."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if res.SessionID != "" {
|
||||||
|
t.Fatalf("expected empty session id, got %q", res.SessionID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing session id var fails rendering", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
SessionID: "{{ .session_id }}",
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "system", Content: "Speak in a {{.tone}} tone."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if !errors.Is(err, ErrRenderFailure) {
|
||||||
|
t.Fatalf("expected ErrRenderFailure, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("too long rendered session id fails rendering", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
SessionID: "{{ .session_id }}",
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "system", Content: "Speak in a {{.tone}} tone."},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := renderer.Render(ctx, def, inputs, map[string]string{
|
||||||
|
"tone": "concise",
|
||||||
|
"session_id": strings.Repeat("x", domain.SessionIDMaxLength+1),
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrRenderFailure) {
|
||||||
|
t.Fatalf("expected ErrRenderFailure, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("inserting required input artifact", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "user", Content: "{{input \"transcript\"}}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if res.Messages[0].Content != "The quick brown fox." {
|
||||||
|
t.Fatalf("unexpected required input rendering: %q", res.Messages[0].Content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("optional input absent and not referenced", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{
|
||||||
|
{Name: "transcript", Required: true},
|
||||||
|
{Name: "glossary", Required: false},
|
||||||
|
},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "user", Content: "Transcript: {{input \"transcript\"}}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Messages) != 1 {
|
||||||
|
t.Fatalf("expected one rendered message, got %d", len(res.Messages))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("optional input absent but referenced, expecting failure", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{
|
||||||
|
{Name: "transcript", Required: true},
|
||||||
|
{Name: "glossary", Required: false},
|
||||||
|
},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "user", Content: "Glossary: {{input \"glossary\"}}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if !errors.Is(err, ErrRenderFailure) {
|
||||||
|
t.Fatalf("expected ErrRenderFailure, got %v", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrUnknownInput) {
|
||||||
|
t.Fatalf("expected ErrUnknownInput, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("required input missing, expecting failure", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "user", Content: "Analyze this: {{input \"transcript\"}}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := renderer.Render(ctx, def, map[string]*domain.Artifact{}, vars)
|
||||||
|
if !errors.Is(err, ErrMissingRequiredInput) {
|
||||||
|
t.Fatalf("expected ErrMissingRequiredInput, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid template syntax", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "user", Content: "Hello {{.unclosed"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if !errors.Is(err, ErrInvalidTemplate) {
|
||||||
|
t.Fatalf("expected ErrInvalidTemplate, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown input reference", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "user", Content: "Hello {{input \"ghost\"}}"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if !errors.Is(err, ErrRenderFailure) {
|
||||||
|
t.Fatalf("expected ErrRenderFailure, got %v", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrUnknownInput) {
|
||||||
|
t.Fatalf("expected ErrUnknownInput, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty message role", func(t *testing.T) {
|
||||||
|
def := &domain.PromptDefinition{
|
||||||
|
Inputs: []domain.PromptInput{{Name: "transcript", Required: true}},
|
||||||
|
Templates: []domain.PromptMessageTemplate{
|
||||||
|
{Role: "", Content: "Hello"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := renderer.Render(ctx, def, inputs, vars)
|
||||||
|
if !errors.Is(err, ErrInvalidMessageRole) {
|
||||||
|
t.Fatalf("expected ErrInvalidMessageRole, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
484
internal/promptdef/filesystem_repository.go
Normal file
484
internal/promptdef/filesystem_repository.go
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
package promptdef
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/filecatalog"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrPromptDefinitionNotFound = errors.New("prompt definition not found")
|
||||||
|
ErrInvalidYAML = errors.New("invalid YAML format")
|
||||||
|
ErrInvalidPromptDefinition = errors.New("invalid prompt definition configuration")
|
||||||
|
)
|
||||||
|
|
||||||
|
type filesystemRepository struct {
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
type fsRepository struct {
|
||||||
|
fsys fs.FS
|
||||||
|
root string
|
||||||
|
}
|
||||||
|
|
||||||
|
type promptDefinitionFile struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
DefaultProfile *string `yaml:"default_profile"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
SessionID string `yaml:"session_id"`
|
||||||
|
Inputs []promptInputFile `yaml:"inputs"`
|
||||||
|
Messages []promptMessageFile `yaml:"messages"`
|
||||||
|
Output promptOutputContractFile `yaml:"output"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type promptInputFile struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Required bool `yaml:"required"`
|
||||||
|
ContentType string `yaml:"content_type"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type promptMessageFile struct {
|
||||||
|
Role string `yaml:"role"`
|
||||||
|
Content string `yaml:"content"`
|
||||||
|
ContentFile string `yaml:"content_file"`
|
||||||
|
CacheControl *cacheControlFile `yaml:"cache_control"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cacheControlFile struct {
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
TTL string `yaml:"ttl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type promptOutputContractFile struct {
|
||||||
|
Format domain.OutputFormat `yaml:"format"`
|
||||||
|
ValidationMode domain.ValidationMode `yaml:"validation_mode"`
|
||||||
|
SchemaPath string `yaml:"schema_path"`
|
||||||
|
RepairAttempts int `yaml:"repair_attempts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFilesystemRepository(dir string) Repository {
|
||||||
|
return &filesystemRepository{dir: dir}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFSRepository(fsys fs.FS, root string) Repository {
|
||||||
|
return &fsRepository{fsys: fsys, root: root}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *filesystemRepository) GetPromptDefinition(ctx context.Context, id string, version string) (*domain.PromptDefinition, error) {
|
||||||
|
if strings.TrimSpace(id) == "" {
|
||||||
|
return nil, fmt.Errorf("%w: prompt id is required", ErrInvalidPromptDefinition)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := filecatalog.FindYAMLFiles(ctx, r.dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read prompt definition directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var matches []promptDefinitionMatch
|
||||||
|
for _, fullPath := range files {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath := filecatalog.RelativePath(r.dir, fullPath)
|
||||||
|
fileMatch := filecatalog.Stem(filepath.Base(fullPath)) == id
|
||||||
|
|
||||||
|
raw, err := loadPromptDefinitionFile(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
if fileMatch || promptDefinitionFileHasID(fullPath, id) {
|
||||||
|
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, relPath, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
def, err := normalizePromptDefinition(raw, fullPath)
|
||||||
|
if err != nil {
|
||||||
|
if fileMatch || strings.TrimSpace(raw.ID) == id {
|
||||||
|
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidPromptDefinition, relPath, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if def.ID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if version != "" && def.Version != version {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches = append(matches, promptDefinitionMatch{
|
||||||
|
def: def,
|
||||||
|
path: relPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) > 1 {
|
||||||
|
paths := make([]string, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
paths = append(paths, match.path)
|
||||||
|
}
|
||||||
|
if version != "" {
|
||||||
|
return nil, fmt.Errorf("%w: duplicate prompt definition id %q version %q found in: %s", ErrInvalidPromptDefinition, id, version, strings.Join(paths, ", "))
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%w: duplicate prompt definition id %q found in: %s", ErrInvalidPromptDefinition, id, strings.Join(paths, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) == 1 {
|
||||||
|
return matches[0].def, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, ErrPromptDefinitionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fsRepository) GetPromptDefinition(ctx context.Context, id string, version string) (*domain.PromptDefinition, error) {
|
||||||
|
return loadPromptDefinition(ctx, r.fsys, r.root, id, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
type promptDefinitionMatch struct {
|
||||||
|
def *domain.PromptDefinition
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPromptDefinitionFile(path string) (*promptDefinitionFile, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read prompt definition file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw promptDefinitionFile
|
||||||
|
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.KnownFields(true)
|
||||||
|
if err := decoder.Decode(&raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptDefinitionFileHasID(path string, id string) bool {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var raw struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
}
|
||||||
|
if err := yaml.NewDecoder(bytes.NewReader(data)).Decode(&raw); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(raw.ID) == id
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPromptDefinition(ctx context.Context, fsys fs.FS, root string, id string, version string) (*domain.PromptDefinition, error) {
|
||||||
|
if strings.TrimSpace(id) == "" {
|
||||||
|
return nil, fmt.Errorf("%w: prompt id is required", ErrInvalidPromptDefinition)
|
||||||
|
}
|
||||||
|
if fsys == nil {
|
||||||
|
return nil, fmt.Errorf("failed to read prompt definition directory: filesystem is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := filecatalog.FindFSYAMLFiles(ctx, fsys, root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read prompt definition directory: %w", err)
|
||||||
|
}
|
||||||
|
cleanRoot := filecatalog.CleanFSRoot(root)
|
||||||
|
rootInfo, err := fs.Stat(fsys, cleanRoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read prompt definition directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var matches []promptDefinitionMatch
|
||||||
|
for _, fullPath := range files {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath := filecatalog.DisplayPath(root, fullPath)
|
||||||
|
fileMatch := filecatalog.Stem(path.Base(fullPath)) == id
|
||||||
|
data, err := fs.ReadFile(fsys, fullPath)
|
||||||
|
if err != nil {
|
||||||
|
if fileMatch {
|
||||||
|
return nil, fmt.Errorf("%w: %s: failed to read prompt definition file: %v", ErrInvalidYAML, relPath, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := decodePromptDefinition(data)
|
||||||
|
if err != nil {
|
||||||
|
if fileMatch || promptDefinitionDataHasID(data, id) {
|
||||||
|
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, relPath, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
def, err := normalizePromptDefinitionFromFS(raw, fsys, root, fullPath, rootInfo.IsDir())
|
||||||
|
if err != nil {
|
||||||
|
if fileMatch || strings.TrimSpace(raw.ID) == id {
|
||||||
|
return nil, fmt.Errorf("%w: %s: %v", ErrInvalidPromptDefinition, relPath, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if def.ID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if version != "" && def.Version != version {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches = append(matches, promptDefinitionMatch{
|
||||||
|
def: def,
|
||||||
|
path: relPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) > 1 {
|
||||||
|
paths := make([]string, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
paths = append(paths, match.path)
|
||||||
|
}
|
||||||
|
if version != "" {
|
||||||
|
return nil, fmt.Errorf("%w: duplicate prompt definition id %q version %q found in: %s", ErrInvalidPromptDefinition, id, version, strings.Join(paths, ", "))
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%w: duplicate prompt definition id %q found in: %s", ErrInvalidPromptDefinition, id, strings.Join(paths, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) == 1 {
|
||||||
|
return matches[0].def, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, ErrPromptDefinitionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodePromptDefinition(data []byte) (*promptDefinitionFile, error) {
|
||||||
|
var raw promptDefinitionFile
|
||||||
|
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.KnownFields(true)
|
||||||
|
if err := decoder.Decode(&raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptDefinitionDataHasID(data []byte, id string) bool {
|
||||||
|
var raw struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
}
|
||||||
|
if err := yaml.NewDecoder(bytes.NewReader(data)).Decode(&raw); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(raw.ID) == id
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePromptDefinition(raw *promptDefinitionFile, sourcePath string) (*domain.PromptDefinition, error) {
|
||||||
|
promptDir := filepath.Dir(sourcePath)
|
||||||
|
return normalizePromptDefinitionWithContent(raw, func(contentFile string) (string, string, error) {
|
||||||
|
resolvedPath := strings.TrimSpace(contentFile)
|
||||||
|
if !filepath.IsAbs(resolvedPath) {
|
||||||
|
resolvedPath = filepath.Join(promptDir, resolvedPath)
|
||||||
|
}
|
||||||
|
resolvedPath = filepath.Clean(resolvedPath)
|
||||||
|
|
||||||
|
body, err := os.ReadFile(resolvedPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return string(body), resolvedPath, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePromptDefinitionFromFS(raw *promptDefinitionFile, fsys fs.FS, root string, sourcePath string, rootIsDir bool) (*domain.PromptDefinition, error) {
|
||||||
|
promptDir := path.Dir(sourcePath)
|
||||||
|
return normalizePromptDefinitionWithContent(raw, func(contentFile string) (string, string, error) {
|
||||||
|
var resolvedPath string
|
||||||
|
if rootIsDir {
|
||||||
|
var err error
|
||||||
|
resolvedPath, _, err = filecatalog.ResolveFSPath(root, promptDir, contentFile)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolvedPath = strings.TrimSpace(contentFile)
|
||||||
|
if !path.IsAbs(resolvedPath) {
|
||||||
|
resolvedPath = path.Join(promptDir, resolvedPath)
|
||||||
|
}
|
||||||
|
resolvedPath = strings.TrimPrefix(path.Clean(resolvedPath), "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := fs.ReadFile(fsys, resolvedPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return string(body), resolvedPath, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePromptDefinitionWithContent(raw *promptDefinitionFile, readContentFile func(string) (string, string, error)) (*domain.PromptDefinition, error) {
|
||||||
|
if raw == nil {
|
||||||
|
return nil, errors.New("prompt definition is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strings.TrimSpace(raw.ID)
|
||||||
|
if id == "" {
|
||||||
|
return nil, errors.New("id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
version := strings.TrimSpace(raw.Version)
|
||||||
|
if version == "" {
|
||||||
|
return nil, errors.New("version is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(raw.Messages) == 0 {
|
||||||
|
return nil, errors.New("at least one message is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
inputs := make([]domain.PromptInput, 0, len(raw.Inputs))
|
||||||
|
seenInputNames := make(map[string]struct{}, len(raw.Inputs))
|
||||||
|
for i, in := range raw.Inputs {
|
||||||
|
name := strings.TrimSpace(in.Name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("input %d has empty name", i)
|
||||||
|
}
|
||||||
|
if _, exists := seenInputNames[name]; exists {
|
||||||
|
return nil, fmt.Errorf("duplicate input name %q", name)
|
||||||
|
}
|
||||||
|
seenInputNames[name] = struct{}{}
|
||||||
|
|
||||||
|
inputs = append(inputs, domain.PromptInput{
|
||||||
|
Name: name,
|
||||||
|
Required: in.Required,
|
||||||
|
ContentType: strings.TrimSpace(in.ContentType),
|
||||||
|
Description: strings.TrimSpace(in.Description),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
templates := make([]domain.PromptMessageTemplate, 0, len(raw.Messages))
|
||||||
|
for i, msg := range raw.Messages {
|
||||||
|
role := strings.TrimSpace(msg.Role)
|
||||||
|
if role == "" {
|
||||||
|
return nil, fmt.Errorf("message %d role is required", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasContent := strings.TrimSpace(msg.Content) != ""
|
||||||
|
hasContentFile := strings.TrimSpace(msg.ContentFile) != ""
|
||||||
|
if hasContent == hasContentFile {
|
||||||
|
return nil, fmt.Errorf("message %d (%s) must set exactly one of content or content_file", i, role)
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheControl, err := normalizeCacheControl(msg.CacheControl)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("message %d (%s) cache_control: %w", i, role, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
templateContent := msg.Content
|
||||||
|
resolvedContentFile := ""
|
||||||
|
if hasContentFile {
|
||||||
|
body, resolvedPath, err := readContentFile(msg.ContentFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("prompt %q message %d (%s): failed to read content_file %q: %w", id, i, role, msg.ContentFile, err)
|
||||||
|
}
|
||||||
|
templateContent = body
|
||||||
|
resolvedContentFile = resolvedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
templates = append(templates, domain.PromptMessageTemplate{
|
||||||
|
Role: role,
|
||||||
|
Content: templateContent,
|
||||||
|
ContentFile: resolvedContentFile,
|
||||||
|
CacheControl: cacheControl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidOutputFormat(raw.Output.Format) {
|
||||||
|
return nil, fmt.Errorf("invalid output format: %q", raw.Output.Format)
|
||||||
|
}
|
||||||
|
if !isValidValidationMode(raw.Output.ValidationMode) {
|
||||||
|
return nil, fmt.Errorf("invalid validation mode: %q", raw.Output.ValidationMode)
|
||||||
|
}
|
||||||
|
if raw.Output.ValidationMode == domain.ValidationJSONSchema && strings.TrimSpace(raw.Output.SchemaPath) == "" {
|
||||||
|
return nil, errors.New("output.schema_path is required when output.validation_mode is json_schema")
|
||||||
|
}
|
||||||
|
if raw.Output.RepairAttempts < 0 {
|
||||||
|
return nil, errors.New("output.repair_attempts must be greater than or equal to 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultProfile := ""
|
||||||
|
if raw.DefaultProfile != nil {
|
||||||
|
defaultProfile = strings.TrimSpace(*raw.DefaultProfile)
|
||||||
|
if defaultProfile == "" {
|
||||||
|
return nil, errors.New("default_profile must be a non-empty string when set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.PromptDefinition{
|
||||||
|
ID: id,
|
||||||
|
Version: version,
|
||||||
|
DefaultProfile: defaultProfile,
|
||||||
|
Description: strings.TrimSpace(raw.Description),
|
||||||
|
SessionID: strings.TrimSpace(raw.SessionID),
|
||||||
|
Inputs: inputs,
|
||||||
|
Templates: templates,
|
||||||
|
OutputFormat: raw.Output.Format,
|
||||||
|
Validation: domain.OutputContract{
|
||||||
|
Format: raw.Output.Format,
|
||||||
|
ValidationMode: raw.Output.ValidationMode,
|
||||||
|
SchemaPath: strings.TrimSpace(raw.Output.SchemaPath),
|
||||||
|
RepairAttempts: raw.Output.RepairAttempts,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCacheControl(raw *cacheControlFile) (*domain.CacheControl, error) {
|
||||||
|
if raw == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheType := strings.TrimSpace(raw.Type)
|
||||||
|
if cacheType == "" {
|
||||||
|
return nil, errors.New("type is required")
|
||||||
|
}
|
||||||
|
if domain.CacheControlType(cacheType) != domain.CacheControlEphemeral {
|
||||||
|
return nil, fmt.Errorf("unsupported type %q", cacheType)
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl := strings.TrimSpace(raw.TTL)
|
||||||
|
if ttl != "" && ttl != "1h" {
|
||||||
|
return nil, fmt.Errorf("unsupported ttl %q", ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.CacheControl{
|
||||||
|
Type: domain.CacheControlType(cacheType),
|
||||||
|
TTL: ttl,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidOutputFormat(f domain.OutputFormat) bool {
|
||||||
|
switch f {
|
||||||
|
case domain.FormatText, domain.FormatMarkdown, domain.FormatJSON:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidValidationMode(m domain.ValidationMode) bool {
|
||||||
|
switch m {
|
||||||
|
case domain.ValidationNone, domain.ValidationBasic, domain.ValidationJSON, domain.ValidationJSONSchema:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
12
internal/promptdef/repository.go
Normal file
12
internal/promptdef/repository.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package promptdef
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repository loads prompt definitions.
|
||||||
|
type Repository interface {
|
||||||
|
GetPromptDefinition(ctx context.Context, id string, version string) (*domain.PromptDefinition, error)
|
||||||
|
}
|
||||||
526
internal/promptdef/repository_test.go
Normal file
526
internal/promptdef/repository_test.go
Normal file
@@ -0,0 +1,526 @@
|
|||||||
|
package promptdef
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFilesystemRepository_GetPromptDefinition(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
if err := copyTree("testdata", tmpDir); err != nil {
|
||||||
|
t.Fatalf("failed to copy testdata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := NewFilesystemRepository(tmpDir)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("valid inline prompt", func(t *testing.T) {
|
||||||
|
p, err := repo.GetPromptDefinition(ctx, "valid-inline", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if p.ID != "valid-inline" {
|
||||||
|
t.Fatalf("unexpected id: %q", p.ID)
|
||||||
|
}
|
||||||
|
if p.Version != "1.0.0" {
|
||||||
|
t.Fatalf("unexpected version: %q", p.Version)
|
||||||
|
}
|
||||||
|
if p.OutputFormat != domain.FormatMarkdown {
|
||||||
|
t.Fatalf("unexpected output format: %q", p.OutputFormat)
|
||||||
|
}
|
||||||
|
if p.Validation.ValidationMode != domain.ValidationBasic {
|
||||||
|
t.Fatalf("unexpected validation mode: %q", p.Validation.ValidationMode)
|
||||||
|
}
|
||||||
|
if len(p.Templates) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages, got %d", len(p.Templates))
|
||||||
|
}
|
||||||
|
if len(p.Inputs) != 1 {
|
||||||
|
t.Fatalf("expected 1 input, got %d", len(p.Inputs))
|
||||||
|
}
|
||||||
|
if p.Inputs[0].ContentType != "text/markdown" {
|
||||||
|
t.Fatalf("expected input content_type to be preserved, got %q", p.Inputs[0].ContentType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid file-backed prompt", func(t *testing.T) {
|
||||||
|
p, err := repo.GetPromptDefinition(ctx, "valid-file-backed", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if len(p.Templates) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages, got %d", len(p.Templates))
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Templates[1].Content, "{{input \"transcript\"}}") {
|
||||||
|
t.Fatalf("expected content_file template body to be loaded, got %q", p.Templates[1].Content)
|
||||||
|
}
|
||||||
|
if p.Templates[1].ContentFile == "" {
|
||||||
|
t.Fatal("expected ContentFile source metadata to be preserved")
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(p.Templates[1].ContentFile) {
|
||||||
|
t.Fatalf("expected resolved content_file path to be absolute, got %q", p.Templates[1].ContentFile)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid cache control with ttl", func(t *testing.T) {
|
||||||
|
p, err := repo.GetPromptDefinition(ctx, "valid-cache-control-ttl", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if len(p.Templates) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages, got %d", len(p.Templates))
|
||||||
|
}
|
||||||
|
assertCacheControl(t, p.Templates[0].CacheControl, domain.CacheControlEphemeral, "1h")
|
||||||
|
if p.Templates[1].CacheControl != nil {
|
||||||
|
t.Fatalf("expected second message cache control to be nil, got %#v", p.Templates[1].CacheControl)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid cache control without ttl", func(t *testing.T) {
|
||||||
|
p, err := repo.GetPromptDefinition(ctx, "valid-cache-control-without-ttl", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if len(p.Templates) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages, got %d", len(p.Templates))
|
||||||
|
}
|
||||||
|
assertCacheControl(t, p.Templates[0].CacheControl, domain.CacheControlEphemeral, "")
|
||||||
|
if p.Templates[1].CacheControl != nil {
|
||||||
|
t.Fatalf("expected second message cache control to be nil, got %#v", p.Templates[1].CacheControl)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid session id template", func(t *testing.T) {
|
||||||
|
p, err := repo.GetPromptDefinition(ctx, "valid-session-id", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if p.SessionID != "{{ .session_id }}" {
|
||||||
|
t.Fatalf("expected trimmed session_id template, got %q", p.SessionID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid nested file-backed prompt resolves content file relative to nested YAML", func(t *testing.T) {
|
||||||
|
nestedDir := filepath.Join(tmpDir, "dnd", "recap")
|
||||||
|
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writePromptTestFile(t, filepath.Join(nestedDir, "nested_recap.yaml"), `
|
||||||
|
id: nested-recap
|
||||||
|
version: "1.0.0"
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content_file: ./nested_recap.user.tmpl
|
||||||
|
output:
|
||||||
|
format: markdown
|
||||||
|
validation_mode: basic
|
||||||
|
repair_attempts: 0
|
||||||
|
`)
|
||||||
|
writePromptTestFile(t, filepath.Join(nestedDir, "nested_recap.user.tmpl"), `Nested recap: {{input "transcript"}}`)
|
||||||
|
|
||||||
|
p, err := repo.GetPromptDefinition(ctx, "nested-recap", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if len(p.Templates) != 1 {
|
||||||
|
t.Fatalf("expected one template, got %d", len(p.Templates))
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Templates[0].Content, "Nested recap") {
|
||||||
|
t.Fatalf("expected nested content file body, got %q", p.Templates[0].Content)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Templates[0].ContentFile, filepath.Join("dnd", "recap", "nested_recap.user.tmpl")) {
|
||||||
|
t.Fatalf("expected nested content file path, got %q", p.Templates[0].ContentFile)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("prompt with default_profile", func(t *testing.T) {
|
||||||
|
p, err := repo.GetPromptDefinition(ctx, "with-default-profile", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if p.DefaultProfile != "local-default" {
|
||||||
|
t.Fatalf("unexpected default profile: %q", p.DefaultProfile)
|
||||||
|
}
|
||||||
|
if len(p.Inputs) != 1 {
|
||||||
|
t.Fatalf("expected one input, got %d", len(p.Inputs))
|
||||||
|
}
|
||||||
|
if p.Inputs[0].ContentType != "" {
|
||||||
|
t.Fatalf("expected missing content_type to remain empty, got %q", p.Inputs[0].ContentType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("duplicate prompt IDs fail as ambiguous", func(t *testing.T) {
|
||||||
|
writePromptTestFile(t, filepath.Join(tmpDir, "duplicate_a.yaml"), `
|
||||||
|
id: duplicate-prompt
|
||||||
|
version: "1.0.0"
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content: First duplicate.
|
||||||
|
output:
|
||||||
|
format: markdown
|
||||||
|
validation_mode: basic
|
||||||
|
repair_attempts: 0
|
||||||
|
`)
|
||||||
|
nestedDir := filepath.Join(tmpDir, "nested")
|
||||||
|
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writePromptTestFile(t, filepath.Join(nestedDir, "duplicate_b.yaml"), `
|
||||||
|
id: duplicate-prompt
|
||||||
|
version: "2.0.0"
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content: Second duplicate.
|
||||||
|
output:
|
||||||
|
format: markdown
|
||||||
|
validation_mode: basic
|
||||||
|
repair_attempts: 0
|
||||||
|
`)
|
||||||
|
|
||||||
|
_, err := repo.GetPromptDefinition(ctx, "duplicate-prompt", "")
|
||||||
|
if !errors.Is(err, ErrInvalidPromptDefinition) {
|
||||||
|
t.Fatalf("expected duplicate prompt to return ErrInvalidPromptDefinition, got %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"duplicate prompt definition id", "duplicate_a.yaml", filepath.Join("nested", "duplicate_b.yaml")} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("expected error to contain %q, got %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("duplicate prompt ID and requested version fails as ambiguous", func(t *testing.T) {
|
||||||
|
writePromptTestFile(t, filepath.Join(tmpDir, "version_duplicate_a.yaml"), `
|
||||||
|
id: duplicate-version-prompt
|
||||||
|
version: "1.0.0"
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content: First duplicate version.
|
||||||
|
output:
|
||||||
|
format: markdown
|
||||||
|
validation_mode: basic
|
||||||
|
repair_attempts: 0
|
||||||
|
`)
|
||||||
|
nestedDir := filepath.Join(tmpDir, "versioned")
|
||||||
|
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writePromptTestFile(t, filepath.Join(nestedDir, "version_duplicate_b.yaml"), `
|
||||||
|
id: duplicate-version-prompt
|
||||||
|
version: "1.0.0"
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content: Second duplicate version.
|
||||||
|
output:
|
||||||
|
format: markdown
|
||||||
|
validation_mode: basic
|
||||||
|
repair_attempts: 0
|
||||||
|
`)
|
||||||
|
|
||||||
|
_, err := repo.GetPromptDefinition(ctx, "duplicate-version-prompt", "1.0.0")
|
||||||
|
if !errors.Is(err, ErrInvalidPromptDefinition) {
|
||||||
|
t.Fatalf("expected duplicate prompt version to return ErrInvalidPromptDefinition, got %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"duplicate prompt definition id", "version \"1.0.0\"", "version_duplicate_a.yaml", filepath.Join("versioned", "version_duplicate_b.yaml")} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Fatalf("expected error to contain %q, got %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-matching malformed nested prompt is ignored for not found lookup", func(t *testing.T) {
|
||||||
|
nestedDir := filepath.Join(tmpDir, "broken")
|
||||||
|
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writePromptTestFile(t, filepath.Join(nestedDir, "unrelated.yaml"), "id: [")
|
||||||
|
|
||||||
|
_, err := repo.GetPromptDefinition(ctx, "does-not-exist-even-with-broken-nested-file", "")
|
||||||
|
if !errors.Is(err, ErrPromptDefinitionNotFound) {
|
||||||
|
t.Fatalf("expected ErrPromptDefinitionNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("strict decode failure in nested prompt matches by YAML ID", func(t *testing.T) {
|
||||||
|
nestedDir := filepath.Join(tmpDir, "strict")
|
||||||
|
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writePromptTestFile(t, filepath.Join(nestedDir, "not_named_like_id.yaml"), `
|
||||||
|
id: nested-strict-error
|
||||||
|
version: "1.0.0"
|
||||||
|
unknown_field: true
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content: Invalid because of unknown field.
|
||||||
|
output:
|
||||||
|
format: markdown
|
||||||
|
validation_mode: basic
|
||||||
|
repair_attempts: 0
|
||||||
|
`)
|
||||||
|
|
||||||
|
_, err := repo.GetPromptDefinition(ctx, "nested-strict-error", "")
|
||||||
|
if !errors.Is(err, ErrInvalidYAML) {
|
||||||
|
t.Fatalf("expected ErrInvalidYAML, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), filepath.Join("strict", "not_named_like_id.yaml")) {
|
||||||
|
t.Fatalf("expected nested path in error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("version lookup", func(t *testing.T) {
|
||||||
|
_, err := repo.GetPromptDefinition(ctx, "valid-inline", "9.9.9")
|
||||||
|
if !errors.Is(err, ErrPromptDefinitionNotFound) {
|
||||||
|
t.Fatalf("expected ErrPromptDefinitionNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
id string
|
||||||
|
targetErr error
|
||||||
|
errSubstrs []string
|
||||||
|
}{
|
||||||
|
{name: "invalid YAML", id: "invalid_yaml", targetErr: ErrInvalidYAML},
|
||||||
|
{name: "missing id", id: "missing_id", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"id is required"}},
|
||||||
|
{name: "no messages", id: "no_messages", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"at least one message is required"}},
|
||||||
|
{name: "both content and content_file", id: "both_content_and_content_file", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"exactly one"}},
|
||||||
|
{name: "neither content nor content_file", id: "neither_content_nor_content_file", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"exactly one"}},
|
||||||
|
{name: "missing content_file", id: "missing_content_file", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"failed to read content_file"}},
|
||||||
|
{name: "duplicate input names", id: "duplicate_input_names", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"duplicate input name"}},
|
||||||
|
{name: "invalid validation mode", id: "invalid_validation_mode", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"invalid validation mode"}},
|
||||||
|
{name: "json_schema without schema_path", id: "json_schema_without_schema_path", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"schema_path"}},
|
||||||
|
{name: "unknown input field", id: "unknown_input_field", targetErr: ErrInvalidYAML, errSubstrs: []string{"field unknown_input_setting not found"}},
|
||||||
|
{name: "empty cache control type", id: "empty_cache_control_type", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"cache_control", "type is required"}},
|
||||||
|
{name: "unsupported cache control type", id: "unsupported_cache_control_type", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"cache_control", "unsupported type"}},
|
||||||
|
{name: "unsupported cache control ttl", id: "unsupported_cache_control_ttl", targetErr: ErrInvalidPromptDefinition, errSubstrs: []string{"cache_control", "unsupported ttl"}},
|
||||||
|
{name: "unknown cache control field", id: "unknown_cache_control_field", targetErr: ErrInvalidYAML, errSubstrs: []string{"field unexpected not found"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := repo.GetPromptDefinition(ctx, tc.id, "")
|
||||||
|
if !errors.Is(err, tc.targetErr) {
|
||||||
|
t.Fatalf("expected %v, got %v", tc.targetErr, err)
|
||||||
|
}
|
||||||
|
for _, sub := range tc.errSubstrs {
|
||||||
|
if !strings.Contains(err.Error(), sub) {
|
||||||
|
t.Fatalf("expected error to contain %q, got %v", sub, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("prompt definition not found", func(t *testing.T) {
|
||||||
|
_, err := repo.GetPromptDefinition(ctx, "does-not-exist", "")
|
||||||
|
if !errors.Is(err, ErrPromptDefinitionNotFound) {
|
||||||
|
t.Fatalf("expected ErrPromptDefinitionNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFSRepositoryGetPromptDefinition(t *testing.T) {
|
||||||
|
repo := NewFSRepository(fstest.MapFS{
|
||||||
|
"prompts/nested/prompt.yaml": &fstest.MapFile{Data: []byte(`
|
||||||
|
id: fs-prompt
|
||||||
|
version: "1.0.0"
|
||||||
|
inputs:
|
||||||
|
- name: transcript
|
||||||
|
required: true
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content_file: ./messages/user.tmpl
|
||||||
|
output:
|
||||||
|
format: markdown
|
||||||
|
validation_mode: basic
|
||||||
|
repair_attempts: 0
|
||||||
|
`)},
|
||||||
|
"prompts/nested/messages/user.tmpl": &fstest.MapFile{Data: []byte(`Summarize {{input "transcript"}}.`)},
|
||||||
|
}, "prompts")
|
||||||
|
|
||||||
|
got, err := repo.GetPromptDefinition(context.Background(), "fs-prompt", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != "fs-prompt" {
|
||||||
|
t.Fatalf("unexpected prompt id: %q", got.ID)
|
||||||
|
}
|
||||||
|
if len(got.Templates) != 1 || !strings.Contains(got.Templates[0].Content, `{{input "transcript"}}`) {
|
||||||
|
t.Fatalf("expected content_file body to be loaded, got %+v", got.Templates)
|
||||||
|
}
|
||||||
|
if got.Templates[0].ContentFile != "prompts/nested/messages/user.tmpl" {
|
||||||
|
t.Fatalf("unexpected content file path: %q", got.Templates[0].ContentFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFSRepositoryContentFileContainment(t *testing.T) {
|
||||||
|
t.Run("nested prompt can reference file inside root", func(t *testing.T) {
|
||||||
|
repo := NewFSRepository(fstest.MapFS{
|
||||||
|
"prompts/nested/prompt.yaml": &fstest.MapFile{Data: []byte(`
|
||||||
|
id: fs-contained-prompt
|
||||||
|
version: "1.0.0"
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content_file: ../shared/user.tmpl
|
||||||
|
output:
|
||||||
|
format: markdown
|
||||||
|
validation_mode: basic
|
||||||
|
repair_attempts: 0
|
||||||
|
`)},
|
||||||
|
"prompts/shared/user.tmpl": &fstest.MapFile{Data: []byte(`Inside root.`)},
|
||||||
|
}, "prompts")
|
||||||
|
|
||||||
|
got, err := repo.GetPromptDefinition(context.Background(), "fs-contained-prompt", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
if len(got.Templates) != 1 || got.Templates[0].Content != "Inside root." {
|
||||||
|
t.Fatalf("expected contained content file, got %+v", got.Templates)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
contentFile string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "parent escape rejected", contentFile: "../outside.tmpl", wantErr: "escapes source root"},
|
||||||
|
{name: "absolute path rejected", contentFile: "/outside.tmpl", wantErr: "must be relative"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
repo := NewFSRepository(fstest.MapFS{
|
||||||
|
"prompts/prompt.yaml": &fstest.MapFile{Data: []byte(`
|
||||||
|
id: fs-escaped-prompt
|
||||||
|
version: "1.0.0"
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content_file: ` + tc.contentFile + `
|
||||||
|
output:
|
||||||
|
format: markdown
|
||||||
|
validation_mode: basic
|
||||||
|
repair_attempts: 0
|
||||||
|
`)},
|
||||||
|
"outside.tmpl": &fstest.MapFile{Data: []byte(`Outside root.`)},
|
||||||
|
}, "prompts")
|
||||||
|
|
||||||
|
_, err := repo.GetPromptDefinition(context.Background(), "fs-escaped-prompt", "")
|
||||||
|
if !errors.Is(err, ErrInvalidPromptDefinition) {
|
||||||
|
t.Fatalf("expected ErrInvalidPromptDefinition, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||||
|
t.Fatalf("expected error to contain %q, got %v", tc.wantErr, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFSRepositoryRejectsDuplicatePromptIDs(t *testing.T) {
|
||||||
|
repo := NewFSRepository(fstest.MapFS{
|
||||||
|
"one.yaml": &fstest.MapFile{Data: []byte(`
|
||||||
|
id: duplicate-fs-prompt
|
||||||
|
version: "1.0.0"
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content: First.
|
||||||
|
output:
|
||||||
|
format: text
|
||||||
|
validation_mode: none
|
||||||
|
repair_attempts: 0
|
||||||
|
`)},
|
||||||
|
"nested/two.yaml": &fstest.MapFile{Data: []byte(`
|
||||||
|
id: duplicate-fs-prompt
|
||||||
|
version: "1.0.0"
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content: Second.
|
||||||
|
output:
|
||||||
|
format: text
|
||||||
|
validation_mode: none
|
||||||
|
repair_attempts: 0
|
||||||
|
`)},
|
||||||
|
}, ".")
|
||||||
|
|
||||||
|
_, err := repo.GetPromptDefinition(context.Background(), "duplicate-fs-prompt", "")
|
||||||
|
if !errors.Is(err, ErrInvalidPromptDefinition) {
|
||||||
|
t.Fatalf("expected ErrInvalidPromptDefinition, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "one.yaml") || !strings.Contains(err.Error(), "nested/two.yaml") {
|
||||||
|
t.Fatalf("expected duplicate paths in error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFSRepositoryRejectsUnknownYAMLFields(t *testing.T) {
|
||||||
|
repo := NewFSRepository(fstest.MapFS{
|
||||||
|
"not_named_like_id.yaml": &fstest.MapFile{Data: []byte(`
|
||||||
|
id: strict-fs-prompt
|
||||||
|
version: "1.0.0"
|
||||||
|
unknown: true
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content: Invalid.
|
||||||
|
output:
|
||||||
|
format: text
|
||||||
|
validation_mode: none
|
||||||
|
repair_attempts: 0
|
||||||
|
`)},
|
||||||
|
}, ".")
|
||||||
|
|
||||||
|
_, err := repo.GetPromptDefinition(context.Background(), "strict-fs-prompt", "")
|
||||||
|
if !errors.Is(err, ErrInvalidYAML) {
|
||||||
|
t.Fatalf("expected ErrInvalidYAML, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCacheControl(t *testing.T, got *domain.CacheControl, wantType domain.CacheControlType, wantTTL string) {
|
||||||
|
t.Helper()
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expected cache control, got nil")
|
||||||
|
}
|
||||||
|
if got.Type != wantType {
|
||||||
|
t.Fatalf("unexpected cache control type: got %q want %q", got.Type, wantType)
|
||||||
|
}
|
||||||
|
if got.TTL != wantTTL {
|
||||||
|
t.Fatalf("unexpected cache control ttl: got %q want %q", got.TTL, wantTTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePromptTestFile(t *testing.T, path string, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, []byte(strings.TrimLeft(content, "\n")), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to write prompt test file %q: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyTree(src, dst string) error {
|
||||||
|
return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(src, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rel == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
target := filepath.Join(dst, rel)
|
||||||
|
if d.IsDir() {
|
||||||
|
return os.MkdirAll(target, 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(target, data, 0o644)
|
||||||
|
})
|
||||||
|
}
|
||||||
10
internal/promptdef/testdata/both_content_and_content_file.yaml
vendored
Normal file
10
internal/promptdef/testdata/both_content_and_content_file.yaml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
id: both-content-and-content-file
|
||||||
|
version: "1.0.0"
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content: "Hi"
|
||||||
|
content_file: ./messages/user_prompt.tmpl
|
||||||
|
output:
|
||||||
|
format: text
|
||||||
|
validation_mode: none
|
||||||
|
repair_attempts: 0
|
||||||
14
internal/promptdef/testdata/duplicate_input_names.yaml
vendored
Normal file
14
internal/promptdef/testdata/duplicate_input_names.yaml
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
id: duplicate-input-names
|
||||||
|
version: "1.0.0"
|
||||||
|
inputs:
|
||||||
|
- name: transcript
|
||||||
|
required: true
|
||||||
|
- name: transcript
|
||||||
|
required: false
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content: "Hi"
|
||||||
|
output:
|
||||||
|
format: text
|
||||||
|
validation_mode: none
|
||||||
|
repair_attempts: 0
|
||||||
10
internal/promptdef/testdata/empty_cache_control_type.yaml
vendored
Normal file
10
internal/promptdef/testdata/empty_cache_control_type.yaml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
id: empty-cache-control-type
|
||||||
|
version: "1.0.0"
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content: "Use cached instructions."
|
||||||
|
cache_control: {}
|
||||||
|
output:
|
||||||
|
format: markdown
|
||||||
|
validation_mode: basic
|
||||||
|
repair_attempts: 0
|
||||||
9
internal/promptdef/testdata/invalid_validation_mode.yaml
vendored
Normal file
9
internal/promptdef/testdata/invalid_validation_mode.yaml
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
id: invalid-validation-mode
|
||||||
|
version: "1.0.0"
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content: "Hi"
|
||||||
|
output:
|
||||||
|
format: text
|
||||||
|
validation_mode: nope
|
||||||
|
repair_attempts: 0
|
||||||
9
internal/promptdef/testdata/invalid_yaml.yaml
vendored
Normal file
9
internal/promptdef/testdata/invalid_yaml.yaml
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
id: invalid-yaml
|
||||||
|
version: "1.0.0"
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content: [broken
|
||||||
|
output:
|
||||||
|
format: text
|
||||||
|
validation_mode: none
|
||||||
|
repair_attempts: 0
|
||||||
9
internal/promptdef/testdata/json_schema_without_schema_path.yaml
vendored
Normal file
9
internal/promptdef/testdata/json_schema_without_schema_path.yaml
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
id: json-schema-without-schema-path
|
||||||
|
version: "1.0.0"
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content: "Return JSON"
|
||||||
|
output:
|
||||||
|
format: json
|
||||||
|
validation_mode: json_schema
|
||||||
|
repair_attempts: 0
|
||||||
2
internal/promptdef/testdata/messages/user_prompt.tmpl
vendored
Normal file
2
internal/promptdef/testdata/messages/user_prompt.tmpl
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Use transcript:
|
||||||
|
{{input "transcript"}}
|
||||||
9
internal/promptdef/testdata/missing_content_file.yaml
vendored
Normal file
9
internal/promptdef/testdata/missing_content_file.yaml
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
id: missing-content-file
|
||||||
|
version: "1.0.0"
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content_file: ./messages/does_not_exist.tmpl
|
||||||
|
output:
|
||||||
|
format: text
|
||||||
|
validation_mode: none
|
||||||
|
repair_attempts: 0
|
||||||
8
internal/promptdef/testdata/missing_id.yaml
vendored
Normal file
8
internal/promptdef/testdata/missing_id.yaml
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
version: "1.0.0"
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content: "Hi"
|
||||||
|
output:
|
||||||
|
format: text
|
||||||
|
validation_mode: none
|
||||||
|
repair_attempts: 0
|
||||||
8
internal/promptdef/testdata/neither_content_nor_content_file.yaml
vendored
Normal file
8
internal/promptdef/testdata/neither_content_nor_content_file.yaml
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
id: neither-content-nor-content-file
|
||||||
|
version: "1.0.0"
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
output:
|
||||||
|
format: text
|
||||||
|
validation_mode: none
|
||||||
|
repair_attempts: 0
|
||||||
6
internal/promptdef/testdata/no_messages.yaml
vendored
Normal file
6
internal/promptdef/testdata/no_messages.yaml
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
id: no-messages
|
||||||
|
version: "1.0.0"
|
||||||
|
output:
|
||||||
|
format: text
|
||||||
|
validation_mode: none
|
||||||
|
repair_attempts: 0
|
||||||
12
internal/promptdef/testdata/unknown_cache_control_field.yaml
vendored
Normal file
12
internal/promptdef/testdata/unknown_cache_control_field.yaml
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
id: unknown-cache-control-field
|
||||||
|
version: "1.0.0"
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content: "Use cached instructions."
|
||||||
|
cache_control:
|
||||||
|
type: ephemeral
|
||||||
|
unexpected: true
|
||||||
|
output:
|
||||||
|
format: markdown
|
||||||
|
validation_mode: basic
|
||||||
|
repair_attempts: 0
|
||||||
13
internal/promptdef/testdata/unknown_input_field.yaml
vendored
Normal file
13
internal/promptdef/testdata/unknown_input_field.yaml
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
id: unknown-input-field
|
||||||
|
version: "1.0.0"
|
||||||
|
inputs:
|
||||||
|
- name: transcript
|
||||||
|
required: true
|
||||||
|
unknown_input_setting: true
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content: "Hi"
|
||||||
|
output:
|
||||||
|
format: text
|
||||||
|
validation_mode: none
|
||||||
|
repair_attempts: 0
|
||||||
12
internal/promptdef/testdata/unsupported_cache_control_ttl.yaml
vendored
Normal file
12
internal/promptdef/testdata/unsupported_cache_control_ttl.yaml
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
id: unsupported-cache-control-ttl
|
||||||
|
version: "1.0.0"
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content: "Use cached instructions."
|
||||||
|
cache_control:
|
||||||
|
type: ephemeral
|
||||||
|
ttl: 5m
|
||||||
|
output:
|
||||||
|
format: markdown
|
||||||
|
validation_mode: basic
|
||||||
|
repair_attempts: 0
|
||||||
11
internal/promptdef/testdata/unsupported_cache_control_type.yaml
vendored
Normal file
11
internal/promptdef/testdata/unsupported_cache_control_type.yaml
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
id: unsupported-cache-control-type
|
||||||
|
version: "1.0.0"
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content: "Use cached instructions."
|
||||||
|
cache_control:
|
||||||
|
type: persistent
|
||||||
|
output:
|
||||||
|
format: markdown
|
||||||
|
validation_mode: basic
|
||||||
|
repair_attempts: 0
|
||||||
14
internal/promptdef/testdata/valid_cache_control_ttl.yaml
vendored
Normal file
14
internal/promptdef/testdata/valid_cache_control_ttl.yaml
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
id: valid-cache-control-ttl
|
||||||
|
version: "1.0.0"
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content: "Use cached instructions."
|
||||||
|
cache_control:
|
||||||
|
type: ephemeral
|
||||||
|
ttl: 1h
|
||||||
|
- role: user
|
||||||
|
content: "Summarize the input."
|
||||||
|
output:
|
||||||
|
format: markdown
|
||||||
|
validation_mode: basic
|
||||||
|
repair_attempts: 0
|
||||||
13
internal/promptdef/testdata/valid_cache_control_without_ttl.yaml
vendored
Normal file
13
internal/promptdef/testdata/valid_cache_control_without_ttl.yaml
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
id: valid-cache-control-without-ttl
|
||||||
|
version: "1.0.0"
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content: "Use cached instructions."
|
||||||
|
cache_control:
|
||||||
|
type: ephemeral
|
||||||
|
- role: user
|
||||||
|
content: "Summarize the input."
|
||||||
|
output:
|
||||||
|
format: markdown
|
||||||
|
validation_mode: basic
|
||||||
|
repair_attempts: 0
|
||||||
14
internal/promptdef/testdata/valid_file_backed.yaml
vendored
Normal file
14
internal/promptdef/testdata/valid_file_backed.yaml
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
id: valid-file-backed
|
||||||
|
version: "1.0.0"
|
||||||
|
inputs:
|
||||||
|
- name: transcript
|
||||||
|
required: true
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content: "Return markdown."
|
||||||
|
- role: user
|
||||||
|
content_file: ./messages/user_prompt.tmpl
|
||||||
|
output:
|
||||||
|
format: markdown
|
||||||
|
validation_mode: basic
|
||||||
|
repair_attempts: 0
|
||||||
18
internal/promptdef/testdata/valid_inline.yaml
vendored
Normal file
18
internal/promptdef/testdata/valid_inline.yaml
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
id: valid-inline
|
||||||
|
version: "1.0.0"
|
||||||
|
inputs:
|
||||||
|
- name: transcript
|
||||||
|
required: true
|
||||||
|
content_type: text/markdown
|
||||||
|
description: Transcript content
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content: "You are concise."
|
||||||
|
- role: user
|
||||||
|
content: |
|
||||||
|
Summarize:
|
||||||
|
{{input "transcript"}}
|
||||||
|
output:
|
||||||
|
format: markdown
|
||||||
|
validation_mode: basic
|
||||||
|
repair_attempts: 0
|
||||||
10
internal/promptdef/testdata/valid_session_id.yaml
vendored
Normal file
10
internal/promptdef/testdata/valid_session_id.yaml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
id: valid-session-id
|
||||||
|
version: "1.0.0"
|
||||||
|
session_id: " {{ .session_id }} "
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content: Hello.
|
||||||
|
output:
|
||||||
|
format: markdown
|
||||||
|
validation_mode: basic
|
||||||
|
repair_attempts: 0
|
||||||
13
internal/promptdef/testdata/with_default_profile.yaml
vendored
Normal file
13
internal/promptdef/testdata/with_default_profile.yaml
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
id: with-default-profile
|
||||||
|
version: "1.0.0"
|
||||||
|
default_profile: local-default
|
||||||
|
inputs:
|
||||||
|
- name: transcript
|
||||||
|
required: true
|
||||||
|
messages:
|
||||||
|
- role: user
|
||||||
|
content: "Write output"
|
||||||
|
output:
|
||||||
|
format: text
|
||||||
|
validation_mode: none
|
||||||
|
repair_attempts: 0
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user