Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12d6d80024 | |||
| d8147cac49 | |||
| a16c2a38d6 | |||
| 843d07b636 | |||
| dc6fd2752d | |||
| 15e4067954 | |||
| 6e34fb9de5 | |||
| 5843ceef82 | |||
| 1cd0c611f2 | |||
| 6ed582f4b5 | |||
| db05b56166 | |||
| 0c08038b4f | |||
| 48378a3269 | |||
| cef8524b31 | |||
| 1cc754c29e | |||
| 93e53d1199 | |||
| 63bedf2a97 | |||
| 0e1ad784d1 | |||
| be07f6d873 | |||
| 836919ec4b | |||
| 07a131bd33 | |||
| ae4540822e | |||
| 65d5894648 | |||
| d31a563ca1 | |||
| ea94d66fc2 | |||
| 056bfd0289 | |||
| bd975f17f2 | |||
| 4431f34bc9 | |||
| 1580b25ea6 | |||
| 6dfd6bd719 | |||
| 9f69da716b | |||
| 5dd5b2f3cc | |||
| d79f1e9562 | |||
| c8e4abcdbd | |||
| 6ce54c1d79 | |||
| 2356f7c73d | |||
| ba1e85a805 | |||
| 4c3828857c | |||
| 02fd09955d | |||
| 7389410966 | |||
| 3bc624ec3f | |||
| e5870453f9 | |||
| 06251e385e | |||
| 722b5ddf59 | |||
| 47bc9a6655 | |||
| bd2b7b1862 | |||
| c892eacb65 | |||
| ff73104f7e | |||
| 7a858c2365 | |||
| e4540aa619 | |||
| a4264bf4b4 | |||
| 81f41564a2 | |||
| 6b5a2497cc | |||
| 422cc6c978 | |||
| f48e042565 | |||
| d9442850ef | |||
| 2e828157b6 | |||
| 4189146536 | |||
| 59be507d2f | |||
| b45cea4084 | |||
| efe885c6b2 | |||
| 404a4c331d | |||
| 174516cb39 | |||
| b0999112cc | |||
| ed0f7527d5 | |||
| 5064cf833d | |||
| 8745d256bd | |||
| 2e76003fd5 | |||
| 36ce5a5099 | |||
| 465dc1389d | |||
| ae6f1a9865 | |||
| ee99dc9478 | |||
| 00ee5893e9 | |||
| 64d1cffd89 | |||
| f9e8afa2c3 | |||
| e827631d8c | |||
| 115fe8ba58 | |||
| c53250f023 | |||
| 3d99483219 | |||
| 67f788b1e2 | |||
| 764103a2e2 | |||
| a08dd83d1f | |||
| e8922d8ec5 | |||
| 2d44305a8a | |||
| a11c80291e | |||
| 3239567297 | |||
| c239304c2a | |||
| 159b02116f | |||
| e5b7adfb49 | |||
| fa2384e696 | |||
| af0bd3f31a | |||
| 5fff8cd623 | |||
| b2a6c47778 | |||
| a1805fe550 | |||
| 93af155254 | |||
| d783b687a5 |
@@ -1,2 +1,6 @@
|
|||||||
Please review `docs/development.md` for initial orientation in this repository
|
Start with `docs/development.md` for orientation and use its policy reading guide
|
||||||
and follow its task-specific reading guide.
|
to select the policies applicable to the task. Do not read the entire suite by
|
||||||
|
default.
|
||||||
|
|
||||||
|
Read and follow `docs/policy/agents.md` before recommending, planning, or
|
||||||
|
implementing repository changes.
|
||||||
|
|||||||
23
COPYRIGHT
Normal file
23
COPYRIGHT
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
PromptKit
|
||||||
|
|
||||||
|
Copyright (C) 2026 Eric Rakestraw
|
||||||
|
|
||||||
|
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||||
|
|
||||||
|
This library is free software: you can redistribute it and/or modify it
|
||||||
|
under the terms of the GNU Lesser General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
The GNU Lesser General Public License version 3 is provided in LICENSE.
|
||||||
|
|
||||||
|
This library is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Lesser General Public License for more details.
|
||||||
|
|
||||||
|
Policy documents copied or adapted from Standard Repository Policies remain licensed
|
||||||
|
under CC-BY-SA-4.0. See docs/oss/attribution.md for the affected material,
|
||||||
|
upstream credit, and license links.
|
||||||
|
|
||||||
|
Third-party code and assets are subject to their respective license terms.
|
||||||
32
README.md
32
README.md
@@ -33,10 +33,32 @@ boundary and constraints that framework work must preserve.
|
|||||||
|
|
||||||
## Release Guidance
|
## Release Guidance
|
||||||
|
|
||||||
Consumers upgrading from `v0.5.0` to `v0.6.0` should read the
|
The [v0.11.2 release notes](docs/releases/v0.11.2.md) cover policy alignment
|
||||||
[v0.6.0 changelog and migration guide](docs/releases/v0.6.0.md).
|
and copied-policy attribution updates.
|
||||||
|
|
||||||
|
The [v0.11.1 release notes](docs/releases/v0.11.1.md) cover the MIT-licensed
|
||||||
|
catalog updates and licensing documentation improvements.
|
||||||
|
|
||||||
|
Consumers upgrading to `v0.11.0` should read the
|
||||||
|
[v0.11.0 changelog and migration guide](docs/releases/v0.11.0.md), particularly
|
||||||
|
the default retry behavior and license change.
|
||||||
|
|
||||||
|
Consumers upgrading to the `v0.10.0` profile-policy transition
|
||||||
|
should read the [v0.10.0 transition guide](docs/releases/v0.10.0.md).
|
||||||
|
|
||||||
|
Consumers upgrading from `v0.8.0` to `v0.9.0` should read the
|
||||||
|
[v0.9.0 changelog and migration guide](docs/releases/v0.9.0.md).
|
||||||
|
|
||||||
|
Consumers upgrading from `v0.7.0` to `v0.8.0` can consult the
|
||||||
|
[v0.8.0 changelog and migration guide](docs/releases/v0.8.0.md).
|
||||||
|
|
||||||
Earlier adopters can consult the
|
Earlier adopters can consult the
|
||||||
|
[v0.7.0 changelog and migration guide](docs/releases/v0.7.0.md).
|
||||||
|
|
||||||
|
Consumers upgrading from `v0.5.0` to `v0.6.0` can consult the
|
||||||
|
[v0.6.0 changelog and migration guide](docs/releases/v0.6.0.md).
|
||||||
|
|
||||||
|
Consumers upgrading from `v0.4.0` to `v0.5.0` can consult the
|
||||||
[v0.5.0 changelog and migration guide](docs/releases/v0.5.0.md).
|
[v0.5.0 changelog and migration guide](docs/releases/v0.5.0.md).
|
||||||
|
|
||||||
Consumers upgrading from `v0.3.0` to `v0.4.0` should read the
|
Consumers upgrading from `v0.3.0` to `v0.4.0` should read the
|
||||||
@@ -53,4 +75,8 @@ Consumers moving from `v0.1.0` to `v0.2.0` should read the
|
|||||||
[Scriptorium](https://gitea.maximumdirect.net/eric/scriptorium) is the CLI and
|
[Scriptorium](https://gitea.maximumdirect.net/eric/scriptorium) is the CLI and
|
||||||
HTTP application built on Promptkit.
|
HTTP application built on Promptkit.
|
||||||
|
|
||||||
Promptkit is licensed under the [GNU General Public License version 3](LICENSE).
|
## License
|
||||||
|
|
||||||
|
Promptkit is licensed under the [LGPL v3.0 or later](COPYRIGHT).
|
||||||
|
|
||||||
|
[Open Source attribution notices](docs/oss/attribution.md) are available.
|
||||||
|
|||||||
145
appended_messages_contract_test.go
Normal file
145
appended_messages_contract_test.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAppendedMessageRoleConstantsAreStrings(t *testing.T) {
|
||||||
|
var (
|
||||||
|
developer string = promptkit.RoleDeveloper
|
||||||
|
system string = promptkit.RoleSystem
|
||||||
|
user string = promptkit.RoleUser
|
||||||
|
assistant string = promptkit.RoleAssistant
|
||||||
|
)
|
||||||
|
if developer != "developer" || system != "system" || user != "user" || assistant != "assistant" {
|
||||||
|
t.Fatalf("unexpected role constants: %q %q %q %q", developer, system, user, assistant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendedMessagesComposeFrozenEffectivePrompt(t *testing.T) {
|
||||||
|
cacheControl := &promptkit.CacheControl{Type: promptkit.CacheControlEphemeral, TTL: "1h"}
|
||||||
|
appended := []promptkit.RenderedMessage{
|
||||||
|
{Role: " \tAsSiStAnT\n", Content: "previous response"},
|
||||||
|
{Role: promptkit.RoleUser, Content: "corrective request", CacheControl: cacheControl},
|
||||||
|
}
|
||||||
|
client := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "output"}}
|
||||||
|
engine, err := promptkit.NewEngine(
|
||||||
|
promptkit.Config{},
|
||||||
|
promptkit.WithPromptFS(contractPromptFS("prompt", "profile", "configured prompt"), "."),
|
||||||
|
promptkit.WithProfiles(promptkit.Profile{ID: "profile", Endpoint: "http://example.test/v1", Model: "model"}),
|
||||||
|
promptkit.WithLLMClient(client),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct engine: %v", err)
|
||||||
|
}
|
||||||
|
request := promptkit.RunRequest{PromptID: "prompt", AppendedMessages: appended}
|
||||||
|
|
||||||
|
plain, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: "prompt"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepare plain request: %v", err)
|
||||||
|
}
|
||||||
|
prepared, err := engine.Prepare(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepare appended request: %v", err)
|
||||||
|
}
|
||||||
|
expected := []promptkit.RenderedMessage{
|
||||||
|
{Role: promptkit.RoleUser, Content: "configured prompt"},
|
||||||
|
{Role: promptkit.RoleAssistant, Content: "previous response"},
|
||||||
|
{Role: promptkit.RoleUser, Content: "corrective request", CacheControl: &promptkit.CacheControl{Type: promptkit.CacheControlEphemeral, TTL: "1h"}},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(prepared.Messages, expected) || prepared.PromptHash != plain.PromptHash || prepared.RenderedPromptHash == plain.RenderedPromptHash {
|
||||||
|
t.Fatalf("prepared effective prompt = %#v, plain = %#v", prepared, plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
equivalent, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: "prompt", AppendedMessages: []promptkit.RenderedMessage{
|
||||||
|
{Role: promptkit.RoleAssistant, Content: "previous response"},
|
||||||
|
{Role: promptkit.RoleUser, Content: "corrective request", CacheControl: &promptkit.CacheControl{Type: promptkit.CacheControlEphemeral, TTL: "1h"}},
|
||||||
|
}})
|
||||||
|
if err != nil || equivalent.RenderedPromptHash != prepared.RenderedPromptHash {
|
||||||
|
t.Fatalf("normalized-equivalent request = (%#v, %v), want matching rendered hash %q", equivalent, err, prepared.RenderedPromptHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, variant := range []promptkit.RunRequest{
|
||||||
|
{PromptID: "prompt", AppendedMessages: []promptkit.RenderedMessage{{Role: promptkit.RoleAssistant, Content: "changed"}, expected[2]}},
|
||||||
|
{PromptID: "prompt", AppendedMessages: []promptkit.RenderedMessage{expected[2], expected[1]}},
|
||||||
|
{PromptID: "prompt", AppendedMessages: []promptkit.RenderedMessage{{Role: promptkit.RoleAssistant, Content: "previous response"}, {Role: promptkit.RoleUser, Content: "corrective request"}}},
|
||||||
|
} {
|
||||||
|
variantPrepared, prepareErr := engine.Prepare(context.Background(), variant)
|
||||||
|
if prepareErr != nil || variantPrepared.RenderedPromptHash == prepared.RenderedPromptHash || variantPrepared.PromptHash != prepared.PromptHash {
|
||||||
|
t.Fatalf("variant preparation = (%#v, %v)", variantPrepared, prepareErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := engine.Run(context.Background(), request)
|
||||||
|
if err != nil || result.RenderedPromptHash != prepared.RenderedPromptHash || !reflect.DeepEqual(client.requests[0].Prompt.Messages, expected) {
|
||||||
|
t.Fatalf("run result = (%#v, %v), request = %#v", result, err, client.requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
execution, err := engine.PrepareExecution(context.Background(), request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepare execution: %v", err)
|
||||||
|
}
|
||||||
|
appended[0].Content = "changed caller content"
|
||||||
|
cacheControl.TTL = ""
|
||||||
|
firstDetails := execution.Details()
|
||||||
|
firstDetails.Messages[1].Content = "changed details content"
|
||||||
|
firstDetails.Messages[2].CacheControl.Type = "changed details cache"
|
||||||
|
secondDetails := execution.Details()
|
||||||
|
if !reflect.DeepEqual(secondDetails.Messages, expected) || secondDetails.RenderedPromptHash != prepared.RenderedPromptHash {
|
||||||
|
t.Fatalf("prepared execution details = %#v, want frozen %#v", secondDetails, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err = engine.RunPrepared(context.Background(), execution)
|
||||||
|
if err != nil || result.RenderedPromptHash != prepared.RenderedPromptHash || !reflect.DeepEqual(client.requests[1].Prompt.Messages, expected) {
|
||||||
|
t.Fatalf("prepared result = (%#v, %v), requests = %#v", result, err, client.requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidAppendedMessagesFailBeforeSourceOrModelWork(t *testing.T) {
|
||||||
|
promptSource := &inspectionCountingFS{}
|
||||||
|
var modelCalls atomic.Int64
|
||||||
|
engine, err := promptkit.NewEngine(
|
||||||
|
promptkit.Config{},
|
||||||
|
promptkit.WithPromptFS(promptSource, "."),
|
||||||
|
promptkit.WithLLMClient(countingLLMClient{calls: &modelCalls}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct engine: %v", err)
|
||||||
|
}
|
||||||
|
request := promptkit.RunRequest{
|
||||||
|
PromptID: "unreached",
|
||||||
|
AppendedMessages: []promptkit.RenderedMessage{{
|
||||||
|
Role: "unsupported-role",
|
||||||
|
Content: "sensitive appended content",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
operations := []struct {
|
||||||
|
name string
|
||||||
|
run func() error
|
||||||
|
}{
|
||||||
|
{name: "Prepare", run: func() error { _, err := engine.Prepare(context.Background(), request); return err }},
|
||||||
|
{name: "PrepareExecution", run: func() error { _, err := engine.PrepareExecution(context.Background(), request); return err }},
|
||||||
|
{name: "Run", run: func() error { _, err := engine.Run(context.Background(), request); return err }},
|
||||||
|
}
|
||||||
|
for _, operation := range operations {
|
||||||
|
t.Run(operation.name, func(t *testing.T) {
|
||||||
|
err := operation.run()
|
||||||
|
if !errors.Is(err, promptkit.ErrInvalidRequest) {
|
||||||
|
t.Fatalf("error = %v, want ErrInvalidRequest", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if promptSource.opens.Load() != 0 {
|
||||||
|
t.Fatalf("invalid appended message opened prompt sources %d times", promptSource.opens.Load())
|
||||||
|
}
|
||||||
|
if modelCalls.Load() != 0 {
|
||||||
|
t.Fatalf("invalid appended message invoked the model %d times", modelCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
27
backends.go
27
backends.go
@@ -9,6 +9,10 @@ import (
|
|||||||
// backend.
|
// backend.
|
||||||
const BackendOpenRouter = backend.OpenRouterID
|
const BackendOpenRouter = backend.OpenRouterID
|
||||||
|
|
||||||
|
// BackendRakestrawHome is the reserved ID of Promptkit's built-in
|
||||||
|
// Rakestrawhome backend.
|
||||||
|
const BackendRakestrawHome = backend.RakestrawHomeID
|
||||||
|
|
||||||
// BackendLocal is the case-sensitive conventional ID used by [LocalBackend].
|
// BackendLocal is the case-sensitive conventional ID used by [LocalBackend].
|
||||||
// It is not a built-in or reserved backend and must be registered with
|
// It is not a built-in or reserved backend and must be registered with
|
||||||
// [WithBackend].
|
// [WithBackend].
|
||||||
@@ -20,15 +24,18 @@ const BackendLocal = "local"
|
|||||||
// to this configuration value do not break source compatibility.
|
// to this configuration value do not break source compatibility.
|
||||||
type Backend struct {
|
type Backend struct {
|
||||||
// ID is the stable, case-sensitive registry key. NewEngine trims it and
|
// ID is the stable, case-sensitive registry key. NewEngine trims it and
|
||||||
// requires a non-blank value. BackendOpenRouter is reserved.
|
// requires a non-blank value. Built-in backend IDs are reserved.
|
||||||
ID string
|
ID string
|
||||||
// Endpoint is the OpenAI-compatible base endpoint. NewEngine trims it and
|
// Endpoint is the OpenAI-compatible base endpoint. NewEngine trims it and
|
||||||
// requires an absolute HTTP or HTTPS URL with a host and without user
|
// requires an absolute HTTP or HTTPS URL with a host and without user
|
||||||
// information, a query string, or a fragment. Paths are allowed.
|
// information, a query string, or a fragment. Paths are allowed.
|
||||||
Endpoint string
|
Endpoint string
|
||||||
// APIKeyEnv optionally names the environment variable containing the API
|
// APIKeyEnv optionally names an environment lookup source for an API key.
|
||||||
// key. NewEngine trims it and requires the portable form
|
// NewEngine trims it and requires the portable form [A-Za-z_][A-Za-z0-9_]*.
|
||||||
// [A-Za-z_][A-Za-z0-9_]*. Store only the name, never a credential value.
|
// A direct RunRequest.APIKey takes precedence. When no usable credential is
|
||||||
|
// available, the built-in client omits Authorization; injected clients own
|
||||||
|
// their own credential-resolution behavior. Store only the name, never a
|
||||||
|
// credential value.
|
||||||
APIKeyEnv string
|
APIKeyEnv string
|
||||||
// ExtraParams contains backend-wide request defaults. Values must be
|
// ExtraParams contains backend-wide request defaults. Values must be
|
||||||
// JSON-compatible, finite, acyclic, and keyed by non-empty strings. Keys
|
// JSON-compatible, finite, acyclic, and keyed by non-empty strings. Keys
|
||||||
@@ -74,11 +81,11 @@ func LocalBackend(endpoint string, concurrencyLimit int) Backend {
|
|||||||
//
|
//
|
||||||
// Registrations accumulate in option order. Every normalized ID must be unique
|
// Registrations accumulate in option order. Every normalized ID must be unique
|
||||||
// across consumer registrations and built-ins; a duplicate or invalid
|
// across consumer registrations and built-ins; a duplicate or invalid
|
||||||
// definition makes NewEngine fail with ErrInvalidConfig. In particular,
|
// definition makes NewEngine fail with ErrInvalidConfig. Built-in IDs,
|
||||||
// BackendOpenRouter cannot be replaced. The immutable registration is scoped
|
// including [BackendOpenRouter] and [BackendRakestrawHome], cannot be
|
||||||
// to the resulting Engine and cannot be enumerated, replaced, removed, or
|
// replaced. The immutable registration is scoped to the resulting Engine and
|
||||||
// mutated after construction. WithBackend does not install package-global
|
// cannot be enumerated, replaced, removed, or mutated after construction.
|
||||||
// state.
|
// WithBackend does not install package-global state.
|
||||||
func WithBackend(backend Backend) Option {
|
func WithBackend(backend Backend) Option {
|
||||||
queueCapacity := 0
|
queueCapacity := 0
|
||||||
queueCapacitySet := backend.QueueCapacity != nil
|
queueCapacitySet := backend.QueueCapacity != nil
|
||||||
@@ -86,7 +93,7 @@ func WithBackend(backend Backend) Option {
|
|||||||
queueCapacity = *backend.QueueCapacity
|
queueCapacity = *backend.QueueCapacity
|
||||||
}
|
}
|
||||||
return optionFunc(func(options *engineOptions) error {
|
return optionFunc(func(options *engineOptions) error {
|
||||||
options.backends = append(options.backends, domain.Backend{
|
options.internal.AddBackend(domain.Backend{
|
||||||
ID: backend.ID,
|
ID: backend.ID,
|
||||||
Endpoint: backend.Endpoint,
|
Endpoint: backend.Endpoint,
|
||||||
APIKeyEnv: backend.APIKeyEnv,
|
APIKeyEnv: backend.APIKeyEnv,
|
||||||
|
|||||||
88
convert.go
88
convert.go
@@ -1,7 +1,9 @@
|
|||||||
package promptkit
|
package promptkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
||||||
@@ -12,19 +14,59 @@ func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.RunRequest{}, err
|
return domain.RunRequest{}, err
|
||||||
}
|
}
|
||||||
|
appendedMessages, err := toDomainAppendedMessages(req.AppendedMessages)
|
||||||
|
if err != nil {
|
||||||
|
return domain.RunRequest{}, err
|
||||||
|
}
|
||||||
|
var observer domain.ExecutionObserver
|
||||||
|
if req.Observer != nil {
|
||||||
|
callback := req.Observer
|
||||||
|
observer = func(event domain.ExecutionEvent) { callback(fromDomainExecutionEvent(event)) }
|
||||||
|
}
|
||||||
return domain.RunRequest{
|
return domain.RunRequest{
|
||||||
PromptID: req.PromptID,
|
PromptID: req.PromptID,
|
||||||
PromptVersion: req.PromptVersion,
|
PromptVersion: req.PromptVersion,
|
||||||
ProfileID: req.ProfileID,
|
ProfileID: req.ProfileID,
|
||||||
SessionID: req.SessionID,
|
SessionID: req.SessionID,
|
||||||
APIKey: req.APIKey,
|
APIKey: req.APIKey,
|
||||||
Inputs: toDomainArtifactRefMap(req.Inputs),
|
Inputs: toDomainArtifactRefMap(req.Inputs),
|
||||||
Vars: copyStringMap(req.Vars),
|
Vars: copyStringMap(req.Vars),
|
||||||
Execution: execution,
|
Execution: execution,
|
||||||
Validation: toDomainOutputContractPtr(req.Validation),
|
Validation: toDomainOutputContractPtr(req.Validation),
|
||||||
|
AppendedMessages: appendedMessages,
|
||||||
|
TransientRetries: copyIntPtr(req.TransientRetries),
|
||||||
|
Observer: observer,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toDomainAppendedMessages(messages []RenderedMessage) ([]domain.RenderedMessage, error) {
|
||||||
|
if messages == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
converted := make([]domain.RenderedMessage, len(messages))
|
||||||
|
for index, message := range messages {
|
||||||
|
if !utf8.ValidString(message.Content) {
|
||||||
|
return nil, fmt.Errorf("appended message %d content must be valid UTF-8", index)
|
||||||
|
}
|
||||||
|
role, err := domain.NormalizeMessageRole(message.Role)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("appended message %d role: %w", index, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheControl, err := domain.NormalizeCacheControl(toDomainCacheControl(message.CacheControl))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("appended message %d cache_control: %w", index, err)
|
||||||
|
}
|
||||||
|
converted[index] = domain.RenderedMessage{
|
||||||
|
Role: role,
|
||||||
|
Content: message.Content,
|
||||||
|
CacheControl: cacheControl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return converted, nil
|
||||||
|
}
|
||||||
|
|
||||||
func fromDomainPreparedRun(prepared *domain.PreparedRun) *PreparedRun {
|
func fromDomainPreparedRun(prepared *domain.PreparedRun) *PreparedRun {
|
||||||
if prepared == nil {
|
if prepared == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -90,8 +132,22 @@ func toDomainGenerateResponse(resp *GenerateResponse) *domain.GenerateResponse {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &domain.GenerateResponse{
|
return &domain.GenerateResponse{
|
||||||
Content: resp.Content,
|
Content: resp.Content,
|
||||||
Usage: toDomainTokenUsage(resp.Usage),
|
Usage: toDomainTokenUsage(resp.Usage),
|
||||||
|
UsagePresence: toDomainTokenUsagePresence(resp.UsagePresence),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDomainTokenUsagePresence(presence *TokenUsagePresence) *domain.TokenUsagePresence {
|
||||||
|
if presence == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &domain.TokenUsagePresence{
|
||||||
|
PromptTokens: presence.PromptTokens,
|
||||||
|
CompletionTokens: presence.CompletionTokens,
|
||||||
|
TotalTokens: presence.TotalTokens,
|
||||||
|
CachedTokens: presence.CachedTokens,
|
||||||
|
CacheWriteTokens: presence.CacheWriteTokens,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +341,16 @@ func fromDomainRenderedMessages(messages []domain.RenderedMessage) []RenderedMes
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toDomainCacheControl(cacheControl *CacheControl) *domain.CacheControl {
|
||||||
|
if cacheControl == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &domain.CacheControl{
|
||||||
|
Type: domain.CacheControlType(cacheControl.Type),
|
||||||
|
TTL: cacheControl.TTL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func fromDomainCacheControl(cacheControl *domain.CacheControl) *CacheControl {
|
func fromDomainCacheControl(cacheControl *domain.CacheControl) *CacheControl {
|
||||||
if cacheControl == nil {
|
if cacheControl == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
108
convert_appended_messages_test.go
Normal file
108
convert_appended_messages_test.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestToDomainAppendedMessages(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
messages []RenderedMessage
|
||||||
|
want []domain.RenderedMessage
|
||||||
|
wantErr string
|
||||||
|
privateRole string
|
||||||
|
privateText string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "normalizes supported roles",
|
||||||
|
messages: []RenderedMessage{
|
||||||
|
{Role: " DeVeLoPeR ", Content: "developer"},
|
||||||
|
{Role: "SyStEm", Content: "system"},
|
||||||
|
{Role: "\tUsEr\n", Content: "user"},
|
||||||
|
{Role: "assistant", Content: "assistant"},
|
||||||
|
},
|
||||||
|
want: []domain.RenderedMessage{
|
||||||
|
{Role: domain.RoleDeveloper, Content: "developer"},
|
||||||
|
{Role: domain.RoleSystem, Content: "system"},
|
||||||
|
{Role: domain.RoleUser, Content: "user"},
|
||||||
|
{Role: domain.RoleAssistant, Content: "assistant"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid role UTF-8",
|
||||||
|
messages: []RenderedMessage{{Role: string([]byte{0xff}), Content: "private-content"}},
|
||||||
|
wantErr: "appended message 0 role",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid content UTF-8",
|
||||||
|
messages: []RenderedMessage{{Role: "private-role", Content: string([]byte{0xff})}},
|
||||||
|
wantErr: "appended message 0 content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unsupported role",
|
||||||
|
messages: []RenderedMessage{{Role: "private-role", Content: "private-content"}},
|
||||||
|
wantErr: "appended message 0 role",
|
||||||
|
privateRole: "private-role",
|
||||||
|
privateText: "private-content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid cache control",
|
||||||
|
messages: []RenderedMessage{{Role: RoleUser, Content: "private-content", CacheControl: &CacheControl{Type: "private-cache"}}},
|
||||||
|
wantErr: "appended message 0 cache_control",
|
||||||
|
privateText: "private-content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty and whitespace content",
|
||||||
|
messages: []RenderedMessage{
|
||||||
|
{Role: RoleUser, Content: ""},
|
||||||
|
{Role: RoleAssistant, Content: " \t\n "},
|
||||||
|
},
|
||||||
|
want: []domain.RenderedMessage{
|
||||||
|
{Role: domain.RoleUser, Content: ""},
|
||||||
|
{Role: domain.RoleAssistant, Content: " \t\n "},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got, err := toDomainAppendedMessages(test.messages)
|
||||||
|
if test.wantErr != "" {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||||
|
t.Fatalf("error = %v, want %q", err, test.wantErr)
|
||||||
|
}
|
||||||
|
for _, privateValue := range []string{test.privateRole, test.privateText} {
|
||||||
|
if privateValue != "" && strings.Contains(err.Error(), privateValue) {
|
||||||
|
t.Fatalf("error exposed appended message data %q: %v", privateValue, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("toDomainAppendedMessages() error = %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, test.want) {
|
||||||
|
t.Fatalf("toDomainAppendedMessages() = %#v, want %#v", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToDomainAppendedMessagesCopiesCacheControl(t *testing.T) {
|
||||||
|
cacheControl := &CacheControl{Type: CacheControlEphemeral, TTL: "1h"}
|
||||||
|
messages := []RenderedMessage{{Role: RoleUser, Content: "content", CacheControl: cacheControl}}
|
||||||
|
request, err := toDomainRunRequest(RunRequest{AppendedMessages: messages})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("toDomainRunRequest() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages[0].Content = "changed"
|
||||||
|
cacheControl.TTL = ""
|
||||||
|
if request.AppendedMessages[0].Content != "content" || request.AppendedMessages[0].CacheControl.TTL != "1h" {
|
||||||
|
t.Fatalf("domain request did not retain an independent appended-message copy: %#v", request.AppendedMessages)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
doc.go
17
doc.go
@@ -23,8 +23,9 @@
|
|||||||
// InspectProfile return copied inspection values. Returned values and values
|
// InspectProfile return copied inspection values. Returned values and values
|
||||||
// passed to extension interfaces are likewise isolated from engine state.
|
// passed to extension interfaces are likewise isolated from engine state.
|
||||||
// Callers own those copies and may mutate them after the call that supplied or
|
// Callers own those copies and may mutate them after the call that supplied or
|
||||||
// returned them. Returned structured errors are likewise caller-owned and may
|
// returned them. [CapacityError] values are caller-owned and may be mutated
|
||||||
// be mutated without affecting engine state or another error.
|
// without affecting engine state or another error. Immutable [GenerationError]
|
||||||
|
// values are also caller-owned and do not retain shared engine state.
|
||||||
//
|
//
|
||||||
// # Security and sensitive data
|
// # Security and sensitive data
|
||||||
//
|
//
|
||||||
@@ -53,9 +54,15 @@
|
|||||||
// Construction, inspection, handle, and error values, including [Config],
|
// Construction, inspection, handle, and error values, including [Config],
|
||||||
// [Backend], [RunRequest], [ArtifactRef], [ExecutionTargetOverride], [Profile],
|
// [Backend], [RunRequest], [ArtifactRef], [ExecutionTargetOverride], [Profile],
|
||||||
// [OpenAICompatibleProfileConfig], [ProfileInspection],
|
// [OpenAICompatibleProfileConfig], [ProfileInspection],
|
||||||
// [PromptInputDefinition], [PromptInspection], [PreparedExecution], and
|
// [PromptInputDefinition], [PromptInspection], [PreparedExecution],
|
||||||
// [CapacityError], do not have stable JSON representations. Direct API keys
|
// [CapacityError], [GenerationError], [RetryExhaustedError], and
|
||||||
// are nevertheless excluded from JSON for every public value.
|
// [ExecutionEvent], do not have stable JSON
|
||||||
|
// representations. Direct API keys are nevertheless excluded from JSON for
|
||||||
|
// every public value.
|
||||||
|
|
||||||
|
// Provider-derived [GenerationError] accessor values are untrusted and can
|
||||||
|
// contain sensitive request or schema fragments. Applications must apply their
|
||||||
|
// own disclosure policy before logging, displaying, or returning them.
|
||||||
//
|
//
|
||||||
// JSON timestamps use time.Time's RFC 3339 encoding and are omitted when zero.
|
// JSON timestamps use time.Time's RFC 3339 encoding and are omitted when zero.
|
||||||
// PreparedRun and RunResult durations are encoded as integer milliseconds in
|
// PreparedRun and RunResult durations are encoded as integer milliseconds in
|
||||||
|
|||||||
@@ -151,6 +151,14 @@ handling policy. The
|
|||||||
[engine operation GoDoc](../../engine.go) own exact lifecycle, engine-binding,
|
[engine operation GoDoc](../../engine.go) own exact lifecycle, engine-binding,
|
||||||
credential, cancellation, timing, and error semantics.
|
credential, cancellation, timing, and error semantics.
|
||||||
|
|
||||||
|
If the request supplied `Observer`, successful preparation retains that
|
||||||
|
function for `RunPrepared`; preparation itself emits nothing. Observation
|
||||||
|
starts only when the owning engine successfully claims the handle. Reused,
|
||||||
|
discarded, foreign, and invalid handles emit nothing. `Discard` clears the
|
||||||
|
retained callback without invoking it. The function's closure is retained by
|
||||||
|
identity, so synchronize any shared state it uses. Prepared start events can
|
||||||
|
include identities already resolved in the frozen snapshot.
|
||||||
|
|
||||||
## Execute And Validate
|
## Execute And Validate
|
||||||
|
|
||||||
[`Engine.Run`](../../engine.go) performs the same preparation, invokes the
|
[`Engine.Run`](../../engine.go) performs the same preparation, invokes the
|
||||||
@@ -173,6 +181,98 @@ semantics. The
|
|||||||
[OpenAI-compatible integration contract](../integrations/openai-compatible-chat.md)
|
[OpenAI-compatible integration contract](../integrations/openai-compatible-chat.md)
|
||||||
owns the built-in client's outbound HTTP behavior.
|
owns the built-in client's outbound HTTP behavior.
|
||||||
|
|
||||||
|
### Observe An Ordinary Run
|
||||||
|
|
||||||
|
Set `RunRequest.Observer` to receive synchronous metadata events from `Run`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
request.Observer = func(event promptkit.ExecutionEvent) {
|
||||||
|
// Translate selected metadata into an application-owned record.
|
||||||
|
}
|
||||||
|
result, err := engine.Run(ctx, request)
|
||||||
|
```
|
||||||
|
|
||||||
|
An ordinary run emits `started` before request conversion, followed by any
|
||||||
|
attempt and retry-delay completions, then one `finished` event on a normal
|
||||||
|
return. `Prepare` and `PrepareExecution` emit no events. A start event has no
|
||||||
|
resolved prompt, profile, or model; later events gain identities only as
|
||||||
|
selection succeeds. The terminal event has dispatched-attempt, transient-retry,
|
||||||
|
repair-call, duration, and presence-aware usage totals. Its usage is an
|
||||||
|
aggregate, not an additional billable attempt. A known zero differs from an
|
||||||
|
unknown count, and incomplete totals must not be treated as complete cost
|
||||||
|
accounting. A failed operation still returns no partial `RunResult`.
|
||||||
|
An injected client that leaves `UsagePresence` nil can report known nonzero
|
||||||
|
counts, but its zero counts remain unknown to observations; supply explicit
|
||||||
|
presence when measured zeros matter. Existing `RunResult.Usage` sums returned
|
||||||
|
values independently of observation presence.
|
||||||
|
|
||||||
|
Callbacks run synchronously, in order for one operation, and can overlap for
|
||||||
|
different operations. Synchronize shared callback state and avoid blocking on
|
||||||
|
work that needs the same run's admission slot. Callback panics propagate;
|
||||||
|
there is no durable event history or promise of a terminal event after panic.
|
||||||
|
Events omit content and error objects, but selected identifiers may still be
|
||||||
|
sensitive. See the [observation GoDoc](../../observations.go) for exact fields.
|
||||||
|
The [offline proposal and assessment example](../../examples/go-library/observe/main.go)
|
||||||
|
shows application-owned event collection and a separate semantic acceptance
|
||||||
|
decision using the built-in HTTP path without network access.
|
||||||
|
|
||||||
|
### Repair A Structured Result
|
||||||
|
|
||||||
|
Set a small additional-call budget when a structurally invalid result can be
|
||||||
|
corrected automatically:
|
||||||
|
|
||||||
|
```go
|
||||||
|
request.Validation = &promptkit.OutputContract{
|
||||||
|
Format: promptkit.FormatJSON,
|
||||||
|
ValidationMode: promptkit.ValidationJSONSchema,
|
||||||
|
SchemaPath: "events.schema.json",
|
||||||
|
RepairAttempts: 1,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each repair attempt is another model generation, so it can increase latency and
|
||||||
|
usage; `RunResult.Usage` is cumulative and `Validation.RepairAttempts` reports
|
||||||
|
repair generations rather than transient HTTP attempts. Exhaustion still
|
||||||
|
returns the final failed validation result. `basic` validation can also repair
|
||||||
|
an empty candidate, but structural
|
||||||
|
validity is not evidence of factual or domain correctness. See the
|
||||||
|
[output-contract format reference](../formats.md#output-contract) and
|
||||||
|
[`OutputContract` GoDoc](../../types.go) for the exact budget and eligibility
|
||||||
|
rules.
|
||||||
|
|
||||||
|
The built-in client also retries eligible temporary HTTP statuses. By default,
|
||||||
|
each generation can make two additional attempts; a repair generation receives
|
||||||
|
its own budget. Set `Config.TransientRetries` to a pointer to zero for the
|
||||||
|
previous single-attempt behavior, or set `RunRequest.TransientRetries` to
|
||||||
|
override one execution. A custom HTTP transport still uses this policy;
|
||||||
|
injected `LLMClient` implementations retain their own retry policy. Application
|
||||||
|
semantic retries are separate runs and can multiply provider traffic further.
|
||||||
|
The caller context covers capacity and retry waiting as well as attempts;
|
||||||
|
without a deadline a valid provider delay can be long. Promptkit does not
|
||||||
|
infer exactly-once or complete cost accounting from a retry outcome.
|
||||||
|
See the [provider integration contract](../integrations/openai-compatible-chat.md#transient-status-retries)
|
||||||
|
for eligible statuses, waits, timeouts, and provider timing.
|
||||||
|
|
||||||
|
### Append Already-Rendered Messages
|
||||||
|
|
||||||
|
An application can include an earlier assistant response and its own corrective
|
||||||
|
instruction in a fresh request without changing the configured prompt:
|
||||||
|
|
||||||
|
```go
|
||||||
|
request.AppendedMessages = []promptkit.RenderedMessage{
|
||||||
|
{Role: promptkit.RoleAssistant, Content: previousResponse},
|
||||||
|
{Role: promptkit.RoleUser, Content: correction},
|
||||||
|
}
|
||||||
|
result, err := engine.Run(ctx, request)
|
||||||
|
```
|
||||||
|
|
||||||
|
These messages are already rendered: Promptkit does not template or resolve
|
||||||
|
files in them, and they can contain sensitive model output or application
|
||||||
|
feedback. Promptkit remains stateless; every `Run` call re-resolves its current
|
||||||
|
sources and the application owns any semantic retry budget. When a
|
||||||
|
pre-execution equality check is required, use `PrepareExecution` and compare
|
||||||
|
its opaque rendered-prompt hash before invoking `RunPrepared`.
|
||||||
|
|
||||||
## Inputs, Profiles, And Overrides
|
## Inputs, Profiles, And Overrides
|
||||||
|
|
||||||
Use `File`, `Inline`, or `InlineWithURI` to construct request inputs. A request
|
Use `File`, `Inline`, or `InlineWithURI` to construct request inputs. A request
|
||||||
@@ -189,6 +289,78 @@ For programmatic profiles,
|
|||||||
[`OpenAICompatibleProfile`](../../profiles.go) converts ordinary
|
[`OpenAICompatibleProfile`](../../profiles.go) converts ordinary
|
||||||
OpenAI-compatible settings into a value accepted by `WithProfiles`.
|
OpenAI-compatible settings into a value accepted by `WithProfiles`.
|
||||||
|
|
||||||
|
### Choose A Profile Ownership Model
|
||||||
|
|
||||||
|
Choose the profile source that matches the application's continuity needs:
|
||||||
|
|
||||||
|
- Select `promptkit-openrouter-standard` or `promptkit-openrouter-auto` when
|
||||||
|
the maintained literal name is the desired consumer contract. Their current
|
||||||
|
selections are in the [format reference](../formats.md#built-in-profile-catalog).
|
||||||
|
- Select an external catalog profile directly when the application accepts that
|
||||||
|
profile's catalog volatility. The selected external inventories are linked
|
||||||
|
from the [format reference](../formats.md#external-profile-inventories).
|
||||||
|
- Define a complete application-owned profile with an explicit backend or
|
||||||
|
endpoint, model, and settings when the application owns those choices.
|
||||||
|
|
||||||
|
An application profile that inherits an external base follows later changes to
|
||||||
|
that base and fails if the base disappears; it is not a frozen copy. Promptkit
|
||||||
|
tests the catalog versions selected in its module graph. Independently advancing
|
||||||
|
a catalog dependency can remove an inherited base and produce an untested
|
||||||
|
combination. Pinning the resolved module graph preserves the embedded data for
|
||||||
|
a build, but cannot preserve provider availability. Running binaries neither
|
||||||
|
download catalog updates nor automatically replace withdrawn models. See the
|
||||||
|
[release policy](../policy/release.md#catalog-profile-compatibility) for the exact
|
||||||
|
cross-version guarantees.
|
||||||
|
|
||||||
|
### Alias A Built-In Profile
|
||||||
|
|
||||||
|
Give an application-owned profile ID a built-in base when prompts should select
|
||||||
|
the application ID while inheriting the built-in target. The child can override
|
||||||
|
only the setting it owns:
|
||||||
|
|
||||||
|
```go
|
||||||
|
promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: "weather-light",
|
||||||
|
BaseProfileID: "promptkit-openrouter-standard",
|
||||||
|
ReasoningEffort: "high",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Select `weather-light` in a prompt or `RunRequest.ProfileID`; it remains the
|
||||||
|
reported selected profile. See the [profile inheritance format
|
||||||
|
reference](../formats.md#profile-inheritance) and the
|
||||||
|
[`Profile` GoDoc](../../types.go) for exact lookup, merging, and validation
|
||||||
|
behavior. If the base is an external profile, apply the volatility guidance
|
||||||
|
above when deciding whether to use an application-owned complete definition
|
||||||
|
instead.
|
||||||
|
|
||||||
|
### Use The Rakestrawhome Built-In Profile
|
||||||
|
|
||||||
|
Set `RAKESTRAWHOME_INFERENCE_API_KEY` in the application environment, then
|
||||||
|
select `rakestrawhome-gemma-4-31b` as an ordinary profile ID. For example, a
|
||||||
|
prepared result identifies the selected built-in through
|
||||||
|
`BackendRakestrawHome`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
prepared, err := engine.Prepare(ctx, promptkit.RunRequest{
|
||||||
|
PromptID: "meeting.summary",
|
||||||
|
ProfileID: "rakestrawhome-gemma-4-31b",
|
||||||
|
Inputs: inputs,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if prepared.SelectedBackendID != promptkit.BackendRakestrawHome {
|
||||||
|
return fmt.Errorf("unexpected backend %q", prepared.SelectedBackendID)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not register `rakestrawhome` manually. When adopting this built-in, remove
|
||||||
|
an existing `WithBackend` registration with that exact ID; retaining it causes
|
||||||
|
the intentional duplicate-ID configuration error. Direct request credentials
|
||||||
|
and runtime endpoint overrides remain supported under their ordinary GoDoc and
|
||||||
|
format contracts.
|
||||||
|
|
||||||
### Inspect A Profile Before Prompt Work
|
### Inspect A Profile Before Prompt Work
|
||||||
|
|
||||||
Use [`Engine.InspectProfile`](../../engine.go) to validate one configured
|
Use [`Engine.InspectProfile`](../../engine.go) to validate one configured
|
||||||
@@ -204,7 +376,7 @@ if err != nil {
|
|||||||
|
|
||||||
target := inspection.EffectiveModelParams
|
target := inspection.EffectiveModelParams
|
||||||
if target.APIKeyEnv != "" {
|
if target.APIKeyEnv != "" {
|
||||||
// Apply application policy for the named environment variable.
|
// This is a configured optional environment lookup source.
|
||||||
} else if inspection.APIKeyRequired {
|
} else if inspection.APIKeyRequired {
|
||||||
// Arrange a direct credential before later execution.
|
// Arrange a direct credential before later execution.
|
||||||
}
|
}
|
||||||
@@ -213,9 +385,11 @@ if target.APIKeyEnv != "" {
|
|||||||
Use this configuration-time boundary when only the profile and its target need
|
Use this configuration-time boundary when only the profile and its target need
|
||||||
checking. Use `Prepare` when the application also needs prompt, input, schema,
|
checking. Use `Prepare` when the application also needs prompt, input, schema,
|
||||||
or rendering work; use prepared execution when that work must remain tied to a
|
or rendering work; use prepared execution when that work must remain tied to a
|
||||||
later execution. Inspection reports credential requirements but leaves the
|
later execution. A reported `APIKeyEnv` is a configured optional source, while
|
||||||
timing of credential enforcement to the application. The method's
|
`APIKeyRequired` is the explicit local requirement. The
|
||||||
[GoDoc](../../engine.go) owns its exact result and error contract.
|
[credential format reference](../formats.md#credentials) and the method's
|
||||||
|
[GoDoc](../../engine.go) own the exact precedence, timing, result, and error
|
||||||
|
contracts.
|
||||||
|
|
||||||
### Set A Per-Run Session And Reasoning
|
### Set A Per-Run Session And Reasoning
|
||||||
|
|
||||||
@@ -423,6 +597,38 @@ status; those choices remain with the consuming application. The
|
|||||||
contract, while the [`Engine.Run` and error GoDoc](../../engine.go) owns broad
|
contract, while the [`Engine.Run` and error GoDoc](../../engine.go) owns broad
|
||||||
error and cancellation identities.
|
error and cancellation identities.
|
||||||
|
|
||||||
|
For a non-2xx response from the built-in OpenAI-compatible client, inspect the
|
||||||
|
status and deliberately selected provider diagnostic when useful:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var generationErr *promptkit.GenerationError
|
||||||
|
if errors.As(err, &generationErr) {
|
||||||
|
status := generationErr.StatusCode()
|
||||||
|
message := generationErr.ProviderMessage()
|
||||||
|
_, _ = status, message // Apply application presentation policy.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All provider fields are untrusted and can contain sensitive request or schema
|
||||||
|
fragments. Do not log, display, or return them without an application-specific
|
||||||
|
disclosure policy. Promptkit does not assign presentation behavior.
|
||||||
|
The [`GenerationError` GoDoc](../../generation_error.go) owns the exact typed
|
||||||
|
error contract.
|
||||||
|
|
||||||
|
Generation failures also match `ErrTransport` when the built-in HTTP request
|
||||||
|
fails, or `ErrMalformedResponse` when a successful provider response cannot be
|
||||||
|
decoded or an injected client returns a nil successful response. Both retain
|
||||||
|
`ErrLLMGenerate`; transport failures retain their original causes for
|
||||||
|
`errors.Is` and `errors.As`. Caller cancellation and deadlines retain their
|
||||||
|
standard context identities. Arbitrary injected-client errors retain their own
|
||||||
|
identities without Promptkit guessing a failure category from their text.
|
||||||
|
An eligible built-in status that consumes a positive retry budget additionally
|
||||||
|
matches `ErrRetriesExhausted`; use `errors.As` to read
|
||||||
|
`RetryExhaustedError.Attempts()` and the final `GenerationError` status. With
|
||||||
|
explicit zero retries, the first eligible status is an ordinary generation
|
||||||
|
error, not exhaustion. Completed structural rejection remains a normal result
|
||||||
|
with failed validation, while an operational error returns no partial result.
|
||||||
|
|
||||||
## Application Boundary
|
## Application Boundary
|
||||||
|
|
||||||
Promptkit is an importable library. It does not own a command, inbound HTTP
|
Promptkit is an importable library. It does not own a command, inbound HTTP
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
# Development
|
# Development
|
||||||
|
|
||||||
This is the contributor entry point for Promptkit, a reusable Go library. All
|
This is the contributor entry point for Promptkit, a reusable Go library.
|
||||||
contributors must read the
|
Use the Policy Reading Guide below to select policies applicable to the task.
|
||||||
[architecture policy](policy/architecture.md) before making changes.
|
|
||||||
|
|
||||||
## Initial Orientation
|
## Initial Orientation
|
||||||
|
|
||||||
@@ -15,17 +14,27 @@ Before starting work:
|
|||||||
4. keep documentation limited to implemented behavior unless an accepted
|
4. keep documentation limited to implemented behavior unless an accepted
|
||||||
decision or temporary roadmap explicitly owns future work.
|
decision or temporary roadmap explicitly owns future work.
|
||||||
|
|
||||||
Start with:
|
## Policy Reading Guide
|
||||||
|
|
||||||
- the [architecture policy](policy/architecture.md) for library boundaries,
|
| Policy | Read before… |
|
||||||
dependency direction, invariants, and non-goals;
|
| --- | --- |
|
||||||
- the [internal component overview](internal/overview.md) for the current
|
| [Policy set provenance](policy/meta.md) | Adopting policies, checking upstream revisions, or updating the adopted policy set. |
|
||||||
package and component inventory;
|
| [Agents](policy/agents.md) | Recommending, planning, or implementing repository changes. |
|
||||||
- the [documentation policy](policy/documentation.md) before changing
|
| [Architecture](policy/architecture.md) | Planning or changing library code or its structure. |
|
||||||
documentation;
|
| [Testing](policy/testing.md) | Selecting validation for code changes, or adding, changing, removing, or evaluating tests. |
|
||||||
- the [testing policy](policy/testing.md) before adding, rewriting, or deleting
|
| [Documentation](policy/documentation.md) | Writing, revising, or reorganizing documentation. |
|
||||||
tests; and
|
| [OSS](policy/oss.md) | Adding or updating dependencies or incorporated third-party material, or changing licensing or attribution. |
|
||||||
- the [release procedure](release.md) for version and publication work.
|
| [Release](policy/release.md) | Preparing or publishing a release, changing release procedures, or making versioning and compatibility decisions. |
|
||||||
|
|
||||||
|
- Read applicable policies before making decisions they govern; multiple policies
|
||||||
|
may apply.
|
||||||
|
- Select policies using the task triggers above. Follow cross-references to
|
||||||
|
relevant sections when needed to resolve a concrete question.
|
||||||
|
- Reuse policies already available in context; reread when changed or no longer
|
||||||
|
available.
|
||||||
|
- Expand reading when the task's scope expands.
|
||||||
|
- When revising a policy itself, read that policy and any provisions elsewhere
|
||||||
|
affected by the revision.
|
||||||
|
|
||||||
## Task-Specific Reading Guide
|
## Task-Specific Reading Guide
|
||||||
|
|
||||||
@@ -34,12 +43,14 @@ Start with:
|
|||||||
| Root public API | The [architecture policy](policy/architecture.md), [consumer guide](consumers/pkg-promptkit.md), [testing policy](policy/testing.md), and existing GoDoc. |
|
| Root public API | The [architecture policy](policy/architecture.md), [consumer guide](consumers/pkg-promptkit.md), [testing policy](policy/testing.md), and existing GoDoc. |
|
||||||
| Prompt, profile, or schema formats | The [framework format reference](formats.md), owning parser or validator package, and [documentation policy](policy/documentation.md). |
|
| Prompt, profile, or schema formats | The [framework format reference](formats.md), owning parser or validator package, and [documentation policy](policy/documentation.md). |
|
||||||
| Source loading or validation | The [framework format reference](formats.md), [internal source document](internal/sources.md), and owning package tests. |
|
| Source loading or validation | The [framework format reference](formats.md), [internal source document](internal/sources.md), and owning package tests. |
|
||||||
|
| Maintained external catalogs or built-ins | The [internal source document](internal/sources.md), [framework format reference](formats.md), [testing policy](policy/testing.md), both catalog module repositories, each catalog's release procedure, and the selected module versions in `go.mod`. |
|
||||||
| Model-client behavior | The [OpenAI-compatible integration contract](integrations/openai-compatible-chat.md), [internal model-client document](internal/llm.md), and owning package tests. |
|
| Model-client behavior | The [OpenAI-compatible integration contract](integrations/openai-compatible-chat.md), [internal model-client document](internal/llm.md), and owning package tests. |
|
||||||
| Internal package implementation | The [architecture policy](policy/architecture.md), [internal component overview](internal/overview.md), and focused internal document listed for that package. |
|
| 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. |
|
| 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). |
|
| Maintained example | The [preparation](../examples/go-library/prepare/main.go), [execution](../examples/go-library/run/main.go), and [observation](../examples/go-library/observe/main.go) examples, [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. |
|
| Documentation | The [documentation policy](policy/documentation.md) and canonical owner of every affected contract. |
|
||||||
| Release preparation or publication | The [release procedure](release.md). |
|
| Licensing, dependencies/catalog updates, copied material, or attribution | The [OSS policy](policy/oss.md), [project grant](../COPYRIGHT), [license texts](../LICENSE), and [attribution entries](oss/attribution.md); inspect the selected upstream source's notices. |
|
||||||
|
| Release preparation or publication, including release notes | The [release policy](policy/release.md), [release procedure](release.md), and [OSS discoverability rules](policy/oss.md#discoverability). |
|
||||||
|
|
||||||
For cross-cutting changes, follow every applicable row. Do not create
|
For cross-cutting changes, follow every applicable row. Do not create
|
||||||
placeholder documents for packages, APIs, or integrations that do not yet
|
placeholder documents for packages, APIs, or integrations that do not yet
|
||||||
@@ -47,187 +58,40 @@ exist.
|
|||||||
|
|
||||||
## Maintainer Validation
|
## Maintainer Validation
|
||||||
|
|
||||||
This section is the canonical local validation workflow for Promptkit. Run
|
For ordinary work, select validation proportionate to the change. Do not repeat
|
||||||
every command from the repository root before accepting a change. The test
|
successful checks for unchanged content merely to create a commit. The full
|
||||||
suite and maintained examples are deterministic, offline, and require no real
|
release-candidate checks have one executable entry point from the repository root:
|
||||||
provider credentials.
|
|
||||||
|
|
||||||
### Tests, Analysis, Build, And Examples
|
|
||||||
|
|
||||||
Run the ordinary and race-enabled suites, static analysis, the build, and both
|
|
||||||
maintained consumer examples:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./...
|
./scripts/release-check.sh
|
||||||
go test -race ./...
|
|
||||||
go vet ./...
|
|
||||||
go build ./...
|
|
||||||
go run ./examples/go-library/prepare
|
|
||||||
go run ./examples/go-library/run
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Both examples must exit successfully. Review their JSON output: preparation
|
The [script](../scripts/release-check.sh) owns the executable checks:
|
||||||
must report the selected offline prompt, profile, model, and message count;
|
race-enabled tests, vet, the three offline consumer examples with semantic output
|
||||||
execution must report the deterministic generated output, successful
|
assertions, tracked Go formatting, local Markdown links, module/workspace
|
||||||
validation, selected offline model, and usage. Neither command may contact a
|
hygiene, whitespace, and the credential-pattern scan. It does not publish,
|
||||||
provider or require credentials.
|
change files, validate release-note structure, or retire roadmaps. It requires Go,
|
||||||
|
Git, a POSIX shell, and Python 3. Tests and examples require no provider
|
||||||
|
credentials or live inference calls.
|
||||||
|
|
||||||
### Go Formatting
|
The script can run on intended uncommitted changes during development.
|
||||||
|
Formatting and documentation checks use tracked files; stage intended new
|
||||||
|
files before final validation so those checks include them. The release
|
||||||
|
procedure separately enforces a clean, committed candidate.
|
||||||
|
|
||||||
Check every tracked Go file. The final command must succeed and the captured
|
### Change Review
|
||||||
list must be empty:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
unformatted=$(
|
|
||||||
git ls-files '*.go' |
|
|
||||||
while IFS= read -r go_file
|
|
||||||
do
|
|
||||||
gofmt -l "$go_file"
|
|
||||||
done
|
|
||||||
)
|
|
||||||
test -z "$unformatted"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Local Markdown Links
|
|
||||||
|
|
||||||
Use the Python standard library to verify every repository-relative Markdown
|
|
||||||
target and local heading fragment. The check is offline and prints nothing on
|
|
||||||
success:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
python3 - <<'PY'
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
root = Path.cwd().resolve()
|
|
||||||
markdown_files = [
|
|
||||||
root / name
|
|
||||||
for name in subprocess.check_output(
|
|
||||||
["git", "ls-files", "*.md"], text=True
|
|
||||||
).splitlines()
|
|
||||||
]
|
|
||||||
link_pattern = re.compile(r"!?\[[^]]*\]\(([^)]+)\)")
|
|
||||||
heading_pattern = re.compile(r"^#{1,6}\s+(.+?)\s*#*\s*$")
|
|
||||||
scheme_pattern = re.compile(r"^[a-z][a-z0-9+.-]*:", re.IGNORECASE)
|
|
||||||
|
|
||||||
|
|
||||||
def markdown_lines(path):
|
|
||||||
in_fence = False
|
|
||||||
fence = ""
|
|
||||||
for line in path.read_text(encoding="utf-8").splitlines():
|
|
||||||
stripped = line.lstrip()
|
|
||||||
marker = stripped[:3]
|
|
||||||
if marker in {"```", "~~~"}:
|
|
||||||
if not in_fence:
|
|
||||||
in_fence = True
|
|
||||||
fence = marker
|
|
||||||
elif marker == fence:
|
|
||||||
in_fence = False
|
|
||||||
fence = ""
|
|
||||||
continue
|
|
||||||
if not in_fence:
|
|
||||||
yield line
|
|
||||||
|
|
||||||
|
|
||||||
anchor_cache = {}
|
|
||||||
|
|
||||||
|
|
||||||
def anchors(path):
|
|
||||||
if path in anchor_cache:
|
|
||||||
return anchor_cache[path]
|
|
||||||
found = set()
|
|
||||||
counts = {}
|
|
||||||
for line in markdown_lines(path):
|
|
||||||
match = heading_pattern.match(line)
|
|
||||||
if not match:
|
|
||||||
continue
|
|
||||||
heading = re.sub(r"<[^>]+>", "", match.group(1)).replace("`", "")
|
|
||||||
base = re.sub(r"[^\w\- ]", "", heading.lower()).replace(" ", "-")
|
|
||||||
count = counts.get(base, 0)
|
|
||||||
counts[base] = count + 1
|
|
||||||
found.add(base if count == 0 else f"{base}-{count}")
|
|
||||||
anchor_cache[path] = found
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
failures = []
|
|
||||||
for source in markdown_files:
|
|
||||||
text = "\n".join(markdown_lines(source))
|
|
||||||
for match in link_pattern.finditer(text):
|
|
||||||
target = match.group(1).strip()
|
|
||||||
if target.startswith("<") and target.endswith(">"):
|
|
||||||
target = target[1:-1]
|
|
||||||
if scheme_pattern.match(target) or target.startswith("//"):
|
|
||||||
continue
|
|
||||||
path_text, separator, fragment = target.partition("#")
|
|
||||||
destination = source if not path_text else source.parent / unquote(path_text)
|
|
||||||
try:
|
|
||||||
destination = destination.resolve()
|
|
||||||
destination.relative_to(root)
|
|
||||||
except ValueError:
|
|
||||||
failures.append(f"{source.relative_to(root)}: escapes repository: {target}")
|
|
||||||
continue
|
|
||||||
if not destination.exists():
|
|
||||||
failures.append(f"{source.relative_to(root)}: missing target: {target}")
|
|
||||||
continue
|
|
||||||
if separator and destination.suffix.lower() == ".md":
|
|
||||||
fragment = unquote(fragment).lower()
|
|
||||||
if fragment not in anchors(destination):
|
|
||||||
failures.append(f"{source.relative_to(root)}: missing anchor: {target}")
|
|
||||||
|
|
||||||
if failures:
|
|
||||||
print("\n".join(failures), file=sys.stderr)
|
|
||||||
raise SystemExit(1)
|
|
||||||
PY
|
|
||||||
```
|
|
||||||
|
|
||||||
### Repository Hygiene And Review
|
|
||||||
|
|
||||||
Reject an active Go workspace, tracked workspace files, a vendor tree, or a
|
|
||||||
module replacement:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
case "$(go env GOWORK)" in
|
|
||||||
''|off) ;;
|
|
||||||
*) printf '%s\n' 'an active Go workspace is not allowed' >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
test -z "$(git ls-files go.work go.work.sum)"
|
|
||||||
test ! -e vendor
|
|
||||||
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod
|
|
||||||
then
|
|
||||||
printf '%s\n' 'go.mod contains a replacement' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
Check whitespace in both unstaged and staged changes. List ignored files and
|
|
||||||
scan tracked content for common credential forms:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git diff --check
|
|
||||||
git diff --cached --check
|
|
||||||
test -z "$(git ls-files --others --ignored --exclude-standard)"
|
|
||||||
credential_pattern='-----BEGIN ([A-Z0-9]+ )?PRIV''ATE KEY-----|AKI''A[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{36,}|sk-[A-Za-z0-9]{32,}'
|
|
||||||
if git grep -nEI -e "$credential_pattern" -- .
|
|
||||||
then
|
|
||||||
printf '%s\n' 'possible credential found' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
Inspect `git status --short --untracked-files=all` and the complete diff before
|
Inspect `git status --short --untracked-files=all` and the complete diff before
|
||||||
accepting a change. The status may contain only the intended source changes
|
accepting a change. Reject unintended files, credentials, private data,
|
||||||
during development. Reject credentials, private keys, environment files,
|
generated binaries, test output, downloaded assets, and template residue.
|
||||||
generated binaries, test or coverage output, downloaded assets, template
|
The automated credential scan does not replace review of the actual change.
|
||||||
residue, and any other artifact that does not belong in source control. The
|
|
||||||
credential scan catches common forms but does not replace inspection of the
|
|
||||||
actual change.
|
|
||||||
|
|
||||||
After committing the accepted change, require a clean candidate:
|
For releases, follow the [release procedure](release.md). Its publication
|
||||||
|
command runs the full local checks once on the final committed candidate and
|
||||||
|
automates release-note structure and Git checks. Do not repeat ordinary change
|
||||||
|
review as a release sign-off. Roadmap retirement remains preparation housekeeping.
|
||||||
|
|
||||||
```sh
|
Follow the [agent commit policy](policy/agents.md#git-commit-policy) for local
|
||||||
test -z "$(git status --porcelain)"
|
commits. Ordinary commits may leave unrelated work in the workspace; preserve
|
||||||
```
|
and report it. A clean worktree and index are required for release candidates,
|
||||||
|
as enforced by the publication command, not for every ordinary commit.
|
||||||
|
|||||||
162
docs/formats.md
162
docs/formats.md
@@ -82,7 +82,13 @@ allowed.
|
|||||||
|
|
||||||
### Messages And Templates
|
### Messages And Templates
|
||||||
|
|
||||||
Each message has a non-empty `role` and exactly one of:
|
Each message has a `role` that Promptkit trims and lowercases. It must then be
|
||||||
|
exactly one of `developer`, `system`, `user`, or `assistant`; blank, custom,
|
||||||
|
`tool`, and `function` roles are invalid. This intentionally tightens the
|
||||||
|
previous nonblank-string rule. Consumers migrating to the next minor release
|
||||||
|
must update any nonstandard prompt-definition roles before upgrading.
|
||||||
|
|
||||||
|
Each message also has exactly one of:
|
||||||
|
|
||||||
- `content`, containing an inline Go template; or
|
- `content`, containing an inline Go template; or
|
||||||
- `content_file`, naming a file whose contents are the Go template.
|
- `content_file`, naming a file whose contents are the Go template.
|
||||||
@@ -126,7 +132,7 @@ outbound integration determines its wire representation.
|
|||||||
| `format` | yes | `text`, `markdown`, or `json`. |
|
| `format` | yes | `text`, `markdown`, or `json`. |
|
||||||
| `validation_mode` | yes | `none`, `basic`, `json`, or `json_schema`. |
|
| `validation_mode` | yes | `none`, `basic`, `json`, or `json_schema`. |
|
||||||
| `schema_path` | for `json_schema` | Path to a schema in the configured schema source. |
|
| `schema_path` | for `json_schema` | Path to a schema in the configured schema source. |
|
||||||
| `repair_attempts` | no | Integer zero or greater; omitted means zero. |
|
| `repair_attempts` | no | Integer from zero through three; omitted means zero. A positive value requires `basic`, `json`, or `json_schema` validation. |
|
||||||
|
|
||||||
The validation modes behave as follows:
|
The validation modes behave as follows:
|
||||||
|
|
||||||
@@ -136,14 +142,31 @@ The validation modes behave as follows:
|
|||||||
- `json_schema` requires valid JSON that satisfies the selected schema.
|
- `json_schema` requires valid JSON that satisfies the selected schema.
|
||||||
|
|
||||||
`format` controls output artifact metadata. JSON Schema mode also supplies the
|
`format` controls output artifact metadata. JSON Schema mode also supplies the
|
||||||
schema to compatible model clients as structured-output metadata. The public
|
schema to compatible model clients as structured-output metadata. Plain `json`
|
||||||
engine does not install an output repairer, so its validation is single-pass
|
validation accepts every valid JSON value and does not request a provider-native
|
||||||
even when a positive `repair_attempts` value is present.
|
JSON-object constraint.
|
||||||
|
|
||||||
|
`repair_attempts` counts additional generation calls after a failed validation.
|
||||||
|
Zero is single-pass. With a positive eligible budget, Promptkit stops at the
|
||||||
|
first valid candidate. If the budget is exhausted, it returns the final
|
||||||
|
candidate and its complete failed validation result; generation and operational
|
||||||
|
validation failures remain errors. `none` never permits repair.
|
||||||
|
|
||||||
A request-level `OutputContract` replaces the complete prompt output contract.
|
A request-level `OutputContract` replaces the complete prompt output contract.
|
||||||
It does not merge individual fields. If its format is empty, Promptkit uses
|
It does not merge individual fields. If its format is empty, Promptkit uses
|
||||||
`text`.
|
`text`.
|
||||||
|
|
||||||
|
## Built-In Backends
|
||||||
|
|
||||||
|
Every engine provides these reserved OpenAI-compatible backend IDs. Consumers
|
||||||
|
must not register either ID with `WithBackend`; exact registration and
|
||||||
|
reservation behavior belongs to the [`Backend` GoDoc](../backends.go).
|
||||||
|
|
||||||
|
| ID | Base endpoint | API-key environment variable | Active generation limit | Default queue capacity |
|
||||||
|
| --- | --- | --- | ---: | ---: |
|
||||||
|
| `openrouter` | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` | 16 | 1024 |
|
||||||
|
| `rakestrawhome` | `https://inference.ai.rakestrawhome.com/v1` | `RAKESTRAWHOME_INFERENCE_API_KEY` | 4 | 1024 |
|
||||||
|
|
||||||
## Profile Definitions
|
## Profile Definitions
|
||||||
|
|
||||||
A profile supplies model execution settings:
|
A profile supplies model execution settings:
|
||||||
@@ -162,9 +185,19 @@ extra_params:
|
|||||||
provider_option: enabled
|
provider_option: enabled
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A derived profile can use a named base and override only the settings it owns:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: local-summary-fast
|
||||||
|
base_profile: local-summary
|
||||||
|
timeout_seconds: 30
|
||||||
|
reasoning_effort: low
|
||||||
|
```
|
||||||
|
|
||||||
| Field | Required | Meaning |
|
| Field | Required | Meaning |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `id` | yes | Profile identifier, trimmed before selection and publication. It must be non-empty after trimming and unique within one source after normalization. |
|
| `id` | yes | Profile identifier, trimmed before selection and publication. It must be non-empty after trimming and unique within one source after normalization. |
|
||||||
|
| `base_profile` | no | One optional parent profile ID. A derived profile may inherit target fields from it. |
|
||||||
| `backend` | unless `endpoint` is present | Backend registry ID. It is trimmed and registry membership is checked when the profile is prepared or inspected. |
|
| `backend` | unless `endpoint` is present | Backend registry ID. It is trimmed and registry membership is checked when the profile is prepared or inspected. |
|
||||||
| `endpoint` | unless `backend` is present | OpenAI-compatible base URL, including an API version path when required. A nonempty value is trimmed and must be absolute HTTP or HTTPS with a host and without user information, a query, or a fragment. When both connection fields are present, this overrides the backend endpoint without changing backend identity. |
|
| `endpoint` | unless `backend` is present | OpenAI-compatible base URL, including an API version path when required. A nonempty value is trimmed and must be absolute HTTP or HTTPS with a host and without user information, a query, or a fragment. When both connection fields are present, this overrides the backend endpoint without changing backend identity. |
|
||||||
| `model` | yes | Non-empty provider model name. |
|
| `model` | yes | Non-empty provider model name. |
|
||||||
@@ -174,16 +207,21 @@ extra_params:
|
|||||||
| `timeout_seconds` | no | Per-generation deadline in whole seconds; integer zero or greater. |
|
| `timeout_seconds` | no | Per-generation deadline in whole seconds; integer zero or greater. |
|
||||||
| `service_tier` | no | Provider-specific request tier. |
|
| `service_tier` | no | Provider-specific request tier. |
|
||||||
| `reasoning_effort` | no | Provider-specific reasoning setting. |
|
| `reasoning_effort` | no | Provider-specific reasoning setting. |
|
||||||
| `api_key_env` | no | Name of an environment variable containing the API key. |
|
| `api_key_env` | no | Optional environment-variable lookup source for an API key. |
|
||||||
| `extra_params` | no | JSON-compatible provider-specific outbound fields. |
|
| `extra_params` | no | JSON-compatible provider-specific outbound fields. |
|
||||||
|
|
||||||
Raw `api_key` is prohibited in profile YAML. Store only an environment
|
Raw `api_key` is prohibited in profile YAML. Store only an environment
|
||||||
variable name in `api_key_env`.
|
variable name in `api_key_env`.
|
||||||
|
|
||||||
|
A standalone profile must provide a model and at least one of `backend` or
|
||||||
|
`endpoint`. A derived profile may omit those target fields because its selected
|
||||||
|
base chain can provide them. Local parsing still validates a derived profile's
|
||||||
|
own ID, supplied endpoint, execution-setting bounds, and `extra_params`.
|
||||||
|
|
||||||
Promptkit does not infer a backend from a model or endpoint. Endpoint-only
|
Promptkit does not infer a backend from a model or endpoint. Endpoint-only
|
||||||
profiles remain supported and have no effective backend ID.
|
profiles remain supported and have no effective backend ID.
|
||||||
The engine always provides the built-in `openrouter` ID. Consumers can add
|
The engine always provides the built-in `openrouter` and `rakestrawhome` IDs.
|
||||||
engine-scoped IDs with
|
Consumers can add engine-scoped IDs with
|
||||||
[`WithBackend`](../backends.go); exact registration validation belongs to its
|
[`WithBackend`](../backends.go); exact registration validation belongs to its
|
||||||
GoDoc.
|
GoDoc.
|
||||||
|
|
||||||
@@ -245,7 +283,8 @@ Profile sources resolve matching IDs in this order:
|
|||||||
2. the ordinary configured source selected by a profile file, `fs.FS`, or
|
2. the ordinary configured source selected by a profile file, `fs.FS`, or
|
||||||
configured profile directory;
|
configured profile directory;
|
||||||
3. application fallback profiles supplied with `WithFallbackProfileFS`; and
|
3. application fallback profiles supplied with `WithFallbackProfileFS`; and
|
||||||
4. embedded built-in profiles.
|
4. Promptkit-owned profiles; and
|
||||||
|
5. maintained external catalog profiles.
|
||||||
|
|
||||||
A profile source supplies a complete definition; definitions and their fields
|
A profile source supplies a complete definition; definitions and their fields
|
||||||
are not merged across sources. A higher-precedence source falls back only when
|
are not merged across sources. A higher-precedence source falls back only when
|
||||||
@@ -255,41 +294,65 @@ profiles. They use `APIKeyRequired` for request-scoped credentials instead of
|
|||||||
`api_key_env`. Preparation and exact profile inspection use this same source
|
`api_key_env`. Preparation and exact profile inspection use this same source
|
||||||
precedence.
|
precedence.
|
||||||
|
|
||||||
|
When a selected definition names `base_profile`, every profile ID in that
|
||||||
|
chain is looked up through this same precedence order. A higher-precedence
|
||||||
|
definition therefore shadows a lower-precedence definition of the same base
|
||||||
|
ID, including a built-in. References are not source-qualified.
|
||||||
|
|
||||||
|
### Profile Inheritance
|
||||||
|
|
||||||
|
Promptkit resolves one linear base chain of at most 32 profiles, including the
|
||||||
|
selected profile. It merges settings from the root base to the selected leaf.
|
||||||
|
The leaf's `id` remains the selected profile identity. Nonblank string fields
|
||||||
|
(`backend`, `endpoint`, `model`, `service_tier`, `reasoning_effort`, and
|
||||||
|
`api_key_env`) and nonzero numeric fields replace inherited values. A nonempty
|
||||||
|
`extra_params` map replaces the complete inherited map rather than merging
|
||||||
|
keys, and `APIKeyRequired: true` remains true through the chain. Backend and
|
||||||
|
endpoint are independent: replacing one does not clear the other.
|
||||||
|
|
||||||
|
There is no profile-level clearing syntax. Blank strings, zero numbers, false,
|
||||||
|
and empty maps remain unspecified and inherit from a base. Use existing
|
||||||
|
presence-aware request overrides where an execution needs an explicit zero or
|
||||||
|
empty reasoning setting.
|
||||||
|
|
||||||
|
An absent directly selected profile reports the ordinary not-found error. Once
|
||||||
|
the selected profile exists, a missing base, cycle, overlong chain, or
|
||||||
|
incomplete resolved target is a profile-load failure. Ordinary operations
|
||||||
|
resolve chains afresh; prepared execution retains the fully resolved target.
|
||||||
|
|
||||||
## Built-In Profile Catalog
|
## Built-In Profile Catalog
|
||||||
|
|
||||||
Every built-in selects the `openrouter` backend. The engine's built-in backend
|
Every built-in profile selects one maintained built-in backend and inherits
|
||||||
registry supplies `https://openrouter.ai/api/v1` and the environment-variable
|
that backend's connection and credential metadata. Profile files do not repeat
|
||||||
name `OPENROUTER_API_KEY`, so individual profiles contain only model and
|
those values. A configured, application fallback, or in-memory profile with
|
||||||
generation settings. Built-in profile files do not repeat those connection
|
the same profile ID takes precedence.
|
||||||
values. A configured, application fallback, or in-memory profile with the same
|
|
||||||
profile ID takes precedence.
|
|
||||||
|
|
||||||
| Provider | ID | Model |
|
Promptkit owns two opt-in profiles. The following definitions are current
|
||||||
| --- | --- | --- |
|
selections, not automatic defaults:
|
||||||
| aion-labs | `aion-2` | `aion-labs/aion-2.0` |
|
|
||||||
| anthropic | `claude-fable-latest` | `~anthropic/claude-fable-latest` |
|
| ID | Current definition |
|
||||||
| anthropic | `claude-haiku-latest` | `~anthropic/claude-haiku-latest` |
|
| --- | --- |
|
||||||
| anthropic | `claude-opus-latest` | `~anthropic/claude-opus-latest` |
|
| `promptkit-openrouter-standard` | Inherits `gemini-flash-latest`. |
|
||||||
| anthropic | `claude-sonnet-latest` | `~anthropic/claude-sonnet-latest` |
|
| `promptkit-openrouter-auto` | Selects backend `openrouter` and model `openrouter/auto`. |
|
||||||
| deepseek | `deepseek-3-2` | `deepseek/deepseek-v3.2` |
|
|
||||||
| deepseek | `deepseek-4-flash` | `deepseek/deepseek-v4-flash` |
|
Select either name through an existing request `profile_id` or prompt
|
||||||
| deepseek | `deepseek-4-pro` | `deepseek/deepseek-v4-pro` |
|
`default_profile`. Consumers can replace either definition or its inherited
|
||||||
| google | `gemini-2-flash` | `google/gemini-2.5-flash` |
|
base through the ordinary profile-source precedence above. The
|
||||||
| google | `gemini-2-flash-lite` | `google/gemini-2.5-flash-lite` |
|
[release policy](policy/release.md#catalog-profile-compatibility) defines the
|
||||||
| google | `gemini-2-pro` | `google/gemini-2.5-pro` |
|
cross-version guarantee for these names.
|
||||||
| google | `gemini-3-flash-lite` | `google/gemini-3.1-flash-lite` |
|
|
||||||
| google | `gemini-flash-latest` | `~google/gemini-flash-latest` |
|
## External Profile Inventories
|
||||||
| google | `gemini-pro-latest` | `~google/gemini-pro-latest` |
|
|
||||||
| google | `gemma-4-31b` | `google/gemma-4-31b-it:exacto` |
|
The external profile inventory is defined by the selected catalog modules, not
|
||||||
| minimax | `minimax-m2` | `minimax/minimax-m2.5` |
|
duplicated here. Browse the exact data selected by this Promptkit version:
|
||||||
| minimax | `minimax-m3` | `minimax/minimax-m3` |
|
|
||||||
| mistral | `mistral-large-2512` | `mistralai/mistral-large-2512` |
|
- [OpenRouter catalog v2.20260926.0 profiles](https://gitea.maximumdirect.net/eric/promptkit-backend-openrouter/src/tag/v2.20260926.0/catalog/profiles)
|
||||||
| mistral | `mistral-medium-3-5` | `mistralai/mistral-medium-3-5` |
|
- [Rakestrawhome catalog v2.20260926.0 profiles](https://gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/src/tag/v2.20260926.0/catalog/profiles)
|
||||||
| mistral | `mistral-small-3` | `mistralai/mistral-small-3.2-24b-instruct` |
|
|
||||||
| mistral | `mistral-small-4` | `mistralai/mistral-small-2603` |
|
External profile entries are volatile across catalog releases. The
|
||||||
| nvidia | `nemotron-3-ultra` | `nvidia/nemotron-3-ultra-550b-a55b` |
|
[release policy](policy/release.md#catalog-profile-compatibility) owns the precise
|
||||||
| openai | `gpt-5-mini` | `openai/gpt-5.4-mini` |
|
compatibility boundary; a selected module version identifies tested local data,
|
||||||
| openai | `gpt-5-nano` | `openai/gpt-5.4-nano` |
|
not live provider availability.
|
||||||
|
|
||||||
## Schemas
|
## Schemas
|
||||||
|
|
||||||
@@ -308,16 +371,25 @@ schema produces a failed validation result.
|
|||||||
Credential values belong at the request or environment boundary, never in
|
Credential values belong at the request or environment boundary, never in
|
||||||
prompt, profile, schema, or example files:
|
prompt, profile, schema, or example files:
|
||||||
|
|
||||||
- a file profile names an environment variable with `api_key_env`;
|
- a backend or file profile can name an optional environment lookup source
|
||||||
- an in-memory profile may set `APIKeyRequired`;
|
with `APIKeyEnv` or `api_key_env`;
|
||||||
- a request can provide a direct `APIKey` or override `APIKeyEnv`; and
|
- an in-memory profile may set `APIKeyRequired` as an explicit local
|
||||||
|
requirement;
|
||||||
|
- a request can provide a direct `APIKey` or override the optional `APIKeyEnv`
|
||||||
|
source; and
|
||||||
- a direct request key takes precedence over environment lookup.
|
- a direct request key takes precedence over environment lookup.
|
||||||
|
|
||||||
After a direct request key, the credential-source precedence is request
|
After a direct request key, the credential-source precedence is request
|
||||||
`APIKeyEnv`, profile `api_key_env`, then the backend default. An in-memory
|
`APIKeyEnv`, profile `api_key_env`, then the backend default. An in-memory
|
||||||
profile with `APIKeyRequired` clears an inherited backend environment name and
|
profile with `APIKeyRequired` clears an inherited backend environment name and
|
||||||
requires a direct key unless the request explicitly supplies `APIKeyEnv`.
|
requires a direct key unless the request explicitly supplies `APIKeyEnv`.
|
||||||
Promptkit validates required credential availability during preparation.
|
Named environment sources are optional: when the selected source is absent,
|
||||||
|
empty, or whitespace-only, the built-in client omits the `Authorization`
|
||||||
|
header and handles the provider response normally. `APIKeyRequired` is the
|
||||||
|
only explicit local availability requirement. Promptkit validates required
|
||||||
|
credential availability during preparation and rechecks it when a prepared
|
||||||
|
execution runs. Injected clients receive resolved source metadata but define
|
||||||
|
their own credential-resolution behavior.
|
||||||
Direct keys are excluded from JSON results and redacted by public string
|
Direct keys are excluded from JSON results and redacted by public string
|
||||||
formatters. Environment-variable names may appear in prepared metadata, but
|
formatters. Environment-variable names may appear in prepared metadata, but
|
||||||
their values do not.
|
their values do not.
|
||||||
|
|||||||
@@ -31,11 +31,13 @@ does not serialize it in the provider request.
|
|||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
A non-empty API key supplied directly on the execution target takes
|
A usable API key supplied directly on the execution target takes precedence.
|
||||||
precedence. Otherwise, when an API-key environment-variable name is supplied,
|
Otherwise, when an API-key environment-variable name is supplied, the client
|
||||||
the client reads that variable and requires a non-empty value. The selected
|
reads and trims that variable. A bearer header is sent only when the resolved
|
||||||
key is sent as `Authorization: Bearer <key>`. No authorization header is sent
|
direct or environment credential is non-empty. When neither source is usable,
|
||||||
when neither mechanism is configured.
|
the client omits `Authorization` and handles the provider response normally.
|
||||||
|
An explicitly required target with no usable source is rejected before
|
||||||
|
transport.
|
||||||
|
|
||||||
The target contains the already resolved environment-variable name: an
|
The target contains the already resolved environment-variable name: an
|
||||||
explicit request override takes precedence over profile metadata, which takes
|
explicit request override takes precedence over profile metadata, which takes
|
||||||
@@ -53,6 +55,12 @@ Each ordinary message contains its `role` and string `content`. A
|
|||||||
cache-controlled message instead uses a text content block containing `type`,
|
cache-controlled message instead uses a text content block containing `type`,
|
||||||
`text`, and `cache_control`; an empty cache-control TTL is omitted.
|
`text`, and `cache_control`; an empty cache-control TTL is omitted.
|
||||||
|
|
||||||
|
Promptkit sends only `developer`, `system`, `user`, and `assistant` roles and
|
||||||
|
does so without provider-specific translation. Tool and deprecated function
|
||||||
|
payloads are outside this text-message contract. A backend or model that
|
||||||
|
rejects an otherwise supported role or context returns its ordinary provider
|
||||||
|
error, which follows the normal generation-error path.
|
||||||
|
|
||||||
The effective direct or prompt-rendered session ID is trimmed, limited to 256
|
The effective direct or prompt-rendered session ID is trimmed, limited to 256
|
||||||
Unicode code points, and sent when nonempty as top-level `session_id`. It is
|
Unicode code points, and sent when nonempty as top-level `session_id`. It is
|
||||||
never also sent as a session header.
|
never also sent as a session header.
|
||||||
@@ -65,7 +73,8 @@ The client conditionally includes:
|
|||||||
- non-empty `service_tier` and effective `reasoning_effort`; an explicitly
|
- non-empty `service_tier` and effective `reasoning_effort`; an explicitly
|
||||||
disabled reasoning setting is empty and therefore omitted; and
|
disabled reasoning setting is empty and therefore omitted; and
|
||||||
- `response_format` for JSON Schema structured output, including its name,
|
- `response_format` for JSON Schema structured output, including its name,
|
||||||
strict flag, and schema document.
|
strict flag, and schema document. Plain JSON validation does not add an
|
||||||
|
object-only response constraint.
|
||||||
|
|
||||||
The engine resolves backend, profile, and request extra-parameter maps by
|
The engine resolves backend, profile, and request extra-parameter maps by
|
||||||
whole-map replacement rather than key merging. The resulting effective map is
|
whole-map replacement rather than key merging. The resulting effective map is
|
||||||
@@ -97,21 +106,74 @@ drained.
|
|||||||
|
|
||||||
The bounded body must contain exactly one OpenAI-compatible JSON response
|
The bounded body must contain exactly one OpenAI-compatible JSON response
|
||||||
object followed only by JSON whitespace and EOF. The client returns the first
|
object followed only by JSON whitespace and EOF. The client returns the first
|
||||||
choice's non-empty message content and maps prompt, completion, total, cached,
|
choice's explicitly present string message content, including an empty or
|
||||||
and cache-write token counts. Invalid or truncated JSON, trailing non-whitespace
|
whitespace-only string, and maps prompt, completion, total, cached, and
|
||||||
data, a second JSON value, absent choices, empty first-choice content, and size
|
cache-write token counts. Invalid or truncated JSON, trailing non-whitespace
|
||||||
overflow are malformed responses and return no partial result.
|
data, a second JSON value, absent choices, missing content, `null` content,
|
||||||
|
non-string content, and size overflow are malformed responses and return no
|
||||||
|
partial result.
|
||||||
|
|
||||||
For a non-2xx status, the error includes the status code but never the provider
|
Each of the five token fields retains whether the provider reported a
|
||||||
response body. Promptkit does not yet parse provider error envelopes; bounded
|
non-null integer, including zero. Omitted and `null` fields are unknown for
|
||||||
non-success parsing belongs to the
|
observations. A strictly decoded successful envelope can yield observed usage
|
||||||
[structured-generation-error roadmap](../roadmap/structured-generation-errors.md).
|
even when a later missing choice or content makes the attempt malformed.
|
||||||
|
Invalid framing and non-2xx error bodies supply no observed usage; provider
|
||||||
|
work may still have occurred. Reported counts are not inferred, reconciled,
|
||||||
|
or proof of complete cost accounting.
|
||||||
|
|
||||||
|
For a non-2xx status, Promptkit recognizes one JSON document with a top-level
|
||||||
|
object-valued `error` member. Its optional `message` and `type` fields must be
|
||||||
|
strings, and `code` may be a string or JSON number. Valid supported fields are
|
||||||
|
handled independently, numeric codes retain their JSON number text, and
|
||||||
|
unknown fields are ignored. Missing, invalid, malformed, or multiply framed
|
||||||
|
envelopes contribute no provider detail.
|
||||||
|
|
||||||
|
Non-success bodies have a 65,536-byte limit. A larger declared
|
||||||
|
`Content-Length` is not read; otherwise the client reads at most one additional
|
||||||
|
byte to detect streamed or underreported overflow. Empty, unreadable,
|
||||||
|
oversized, malformed, and unrecognized bodies retain only the received status.
|
||||||
|
The body is always closed and no oversized stream is drained beyond that probe.
|
||||||
|
|
||||||
|
Extracted strings are made valid UTF-8, trimmed, and converted to one line by
|
||||||
|
collapsing Unicode whitespace, control, and format-character runs. Blank
|
||||||
|
values are omitted. Codes and types longer than 256 Unicode code points are
|
||||||
|
omitted; messages longer than 4,096 code points are truncated at a code-point
|
||||||
|
boundary with an ellipsis inside the limit. Promptkit never exposes raw bodies,
|
||||||
|
headers, endpoints, credentials, request data, schemas, generated content, or
|
||||||
|
unsupported provider metadata through this handling.
|
||||||
|
|
||||||
An outbound `http.Client.Do` failure retains both Promptkit's request-failure
|
An outbound `http.Client.Do` failure retains both Promptkit's request-failure
|
||||||
identity and the exact transport error for `errors.Is` and `errors.As` checks.
|
identity and the exact transport error for `errors.Is` and `errors.As` checks.
|
||||||
The rendered error does not include the selected endpoint, request headers,
|
The rendered error does not include the selected endpoint, request headers,
|
||||||
request content, credentials, or provider body.
|
request content, credentials, or provider body.
|
||||||
|
|
||||||
|
## Transient Status Retries
|
||||||
|
|
||||||
|
The built-in path retries only HTTP 429, 500, 502, 503, and 504. It makes two
|
||||||
|
additional attempts by default; engine configuration or an individual run can
|
||||||
|
choose a different nonnegative count, including zero for single-attempt
|
||||||
|
behavior. The same budget applies separately to initial generation and each
|
||||||
|
structural repair generation. An injected `LLMClient` is dispatched once by
|
||||||
|
Promptkit regardless of this setting. A custom `http.Client` on the built-in
|
||||||
|
path remains subject to these retries.
|
||||||
|
|
||||||
|
Each retry uses equal jitter between half and all of an exponential local
|
||||||
|
delay, starting at 500 milliseconds and capped at 8 seconds. The first
|
||||||
|
`Retry-After` header value sets a minimum not-before time measured from
|
||||||
|
response receipt if it is valid unsigned decimal seconds or an HTTP date.
|
||||||
|
Invalid values use local delay; past dates impose no extra delay. The provider
|
||||||
|
minimum is not capped at 8 seconds. Retry waiting is cancellable and holds no
|
||||||
|
active backend permit. A caller without a deadline can wait a long time for
|
||||||
|
provider timing.
|
||||||
|
|
||||||
|
The request body, selected URL, and resolved authorization are fixed across
|
||||||
|
attempts of one generation. Each attempt creates a fresh HTTP request and
|
||||||
|
receives its own configured generation and HTTP-client timeout after capacity
|
||||||
|
admission. The caller context covers every attempt and wait without a reset.
|
||||||
|
Transport failures, malformed responses, noneligible statuses, and invalid
|
||||||
|
generated content are not transient retries. A positive budget ending in
|
||||||
|
eligible statuses reports retry exhaustion with the final provider cause.
|
||||||
|
|
||||||
## Timeout And Cancellation
|
## Timeout And Cancellation
|
||||||
|
|
||||||
Timeouts are layered:
|
Timeouts are layered:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ stable JSON values.
|
|||||||
|
|
||||||
## Construction And Pool Lifecycle
|
## Construction And Pool Lifecycle
|
||||||
|
|
||||||
Each root `NewEngine` call obtains a normalized capacity-policy snapshot from
|
Each internal engine construction obtains a normalized capacity-policy snapshot from
|
||||||
its immutable backend registry and constructs a new `Manager`. The manager
|
its immutable backend registry and constructs a new `Manager`. The manager
|
||||||
creates one pool for each limited backend ID. It has no package-global mutable
|
creates one pool for each limited backend ID. It has no package-global mutable
|
||||||
state, background workers, shutdown protocol, or persistence, so engines with
|
state, background workers, shutdown protocol, or persistence, so engines with
|
||||||
@@ -54,38 +54,48 @@ reserve another bounded slot.
|
|||||||
|
|
||||||
## FIFO Generation Permits
|
## FIFO Generation Permits
|
||||||
|
|
||||||
`NewClient` wraps the engine's selected internal model client after public
|
The engine supplies one [generation coordinator](generation.md) to initial
|
||||||
client adaptation or built-in client construction. Initial generation and the
|
generation and the default repairer. Their requests retain the same effective
|
||||||
default repairer receive the same wrapper. Their requests retain the same
|
backend, credential, numeric-presence metadata, structured-output settings,
|
||||||
effective backend, credential, numeric-presence metadata, and structured-output
|
and retry budget. Scheduling does not change provider omission semantics
|
||||||
settings, so scheduling does not change provider omission semantics between
|
between calls.
|
||||||
calls.
|
|
||||||
|
|
||||||
For each `Generate` call, the wrapper selects a pool from the request's
|
For each model attempt, the coordinator selects a pool from the request's
|
||||||
effective backend ID. An unlimited call passes directly to the next client. A
|
effective backend ID. An unlimited call uses the unrestricted path. A limited
|
||||||
limited call acquires an active permit, invokes the next client, and defers
|
call acquires an active permit and defers release around that attempt, so
|
||||||
permit release so ordinary returns and panic unwinding both restore capacity.
|
ordinary returns and panic unwinding both restore capacity.
|
||||||
Preparation and validation never hold an active permit.
|
Preparation and validation never hold an active permit.
|
||||||
|
|
||||||
|
The manager's `Acquire` operation owns that active-permit acquisition and
|
||||||
|
returns an idempotent release function. The coordinator uses it around
|
||||||
|
individual attempts. The earlier one-shot wrapper still delegates to the same
|
||||||
|
operation for focused internal callers and tests, but engine assembly no
|
||||||
|
longer uses it. There is one queue and one active limit per limited backend pool.
|
||||||
|
|
||||||
When all active permits are occupied, calls join a mutex-protected FIFO waiter
|
When all active permits are occupied, calls join a mutex-protected FIFO waiter
|
||||||
list. Releasing a permit transfers it directly to the oldest remaining waiter
|
list. Releasing a permit transfers it directly to the oldest remaining waiter
|
||||||
before making it generally available. Pools do not order work relative to
|
before making it generally available. Pools do not order work relative to
|
||||||
other backend IDs.
|
other backend IDs.
|
||||||
|
|
||||||
The wrapper passes generation requests, responses, and collaborator errors
|
The coordinator does not retry injected clients; it normalizes a nil successful
|
||||||
through unchanged. It owns scheduling only; the concrete model client remains
|
response as malformed generation. The built-in HTTP client remains responsible
|
||||||
responsible for provider transport behavior. The runner, rather than the
|
for provider transport behavior. The runner, rather than the
|
||||||
capacity layer, sums all five usage fields from the initial response and every
|
capacity layer, sums all five usage fields from the initial response and every
|
||||||
completed repair response into the successful run result.
|
completed repair response into the successful run result.
|
||||||
|
|
||||||
|
An invocation-local observer receives attempt events after active permit
|
||||||
|
release, including canceled acquisition without dispatch. A retry wait holds
|
||||||
|
the logical admission lease but no active permit. Observer callbacks must not
|
||||||
|
block on work that requires the same run's admission slot.
|
||||||
|
|
||||||
## Cancellation And Release
|
## Cancellation And Release
|
||||||
|
|
||||||
Admission checks the caller context before reserving a slot. A call canceled
|
Admission checks the caller context before reserving a slot. A call canceled
|
||||||
while waiting for an active permit removes its waiter under the same pool lock
|
while waiting for an active permit removes its waiter under the same pool lock
|
||||||
used to grant permits. If cancellation removes the waiter first, the wrapped
|
used to grant permits. If cancellation removes the waiter first, no model
|
||||||
client is not invoked. If a concurrent grant wins first, the call owns the
|
attempt is dispatched. If a concurrent grant wins first, the call owns the
|
||||||
permit and invokes the client with the original context, allowing the client
|
permit; the coordinator checks the context before dispatch and releases the
|
||||||
to observe cancellation normally.
|
permit if cancellation has already occurred.
|
||||||
|
|
||||||
This grant-or-cancel decision prevents lost and double-released permits.
|
This grant-or-cancel decision prevents lost and double-released permits.
|
||||||
Admission leases and active permits are released after success, collaborator
|
Admission leases and active permits are released after success, collaborator
|
||||||
@@ -111,6 +121,8 @@ release. The
|
|||||||
[external package capacity tests](../../capacity_contract_test.go) own the
|
[external package capacity tests](../../capacity_contract_test.go) own the
|
||||||
assembled public-engine behavior for configured limits, capacity errors,
|
assembled public-engine behavior for configured limits, capacity errors,
|
||||||
endpoint identity, engine independence, and injected clients. The
|
endpoint identity, engine independence, and injected clients. The
|
||||||
|
[reliable execution contract tests](../../reliable_execution_contract_test.go)
|
||||||
|
cover shared and independent backend pools during retry and repair. The
|
||||||
[prepared-execution contract tests](../../prepared_execution_contract_test.go)
|
[prepared-execution contract tests](../../prepared_execution_contract_test.go)
|
||||||
own the public prepared-capacity boundary. The
|
own the public prepared-capacity boundary. The
|
||||||
[root error-boundary tests](../../errors_internal_test.go) own preservation of
|
[root error-boundary tests](../../errors_internal_test.go) own preservation of
|
||||||
|
|||||||
50
docs/internal/generation.md
Normal file
50
docs/internal/generation.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Internal Generation Coordination
|
||||||
|
|
||||||
|
`internal/generation` coordinates one model-generation request through the
|
||||||
|
existing engine-local [capacity manager](capacity.md) and the
|
||||||
|
[built-in HTTP client](llm.md). The engine supplies the same coordinator to
|
||||||
|
initial generation and the default output repairer.
|
||||||
|
|
||||||
|
The coordinator has explicit built-in and injected-client constructors. The
|
||||||
|
built-in path prepares the provider request once, then acquires and releases an
|
||||||
|
active backend permit around each actual HTTP attempt. Only HTTP 429, 500, 502,
|
||||||
|
503, and 504 can lead to another attempt. The injected-client path acquires a
|
||||||
|
permit and invokes its client once, regardless of the internal retry budget.
|
||||||
|
The runner continues to own the logical execution's admission lease. A request
|
||||||
|
can override the engine's retry budget; the runner freezes that choice for
|
||||||
|
prepared execution and passes it to each repair generation.
|
||||||
|
|
||||||
|
Retry waits hold no active permit. The local delay uses equal jitter between
|
||||||
|
half and all of a capped exponential base, beginning at 500 milliseconds and
|
||||||
|
capping at 8 seconds. A valid provider not-before time can extend that delay;
|
||||||
|
the caller context interrupts it. The next attempt reacquires the ordinary
|
||||||
|
FIFO permit. Preparation failures dispatch nothing. When a positive retry
|
||||||
|
budget ends with eligible failures, the internal exhaustion error retains the
|
||||||
|
final cause and actual dispatched-attempt count.
|
||||||
|
|
||||||
|
When an invocation-local [observation recorder](observation.md) is present in
|
||||||
|
the context, each acquisition emits an attempt event, including canceled
|
||||||
|
capacity waits without dispatch. Each actual retry wait emits a delay event,
|
||||||
|
including interrupted waits. Active permits are released before callbacks;
|
||||||
|
generation duration ends before callback delivery. Built-in successful-envelope
|
||||||
|
usage retains reported-field presence, while injected clients use explicit
|
||||||
|
presence when supplied and otherwise treat zero fields as unknown.
|
||||||
|
Malformed successful envelopes can retain strictly decoded usage; error
|
||||||
|
response bodies are never mined for it. Arbitrary injected failures carry
|
||||||
|
unknown usage. Transport, timeout, malformed, and noneligible status failures
|
||||||
|
stop the sequence without retry exhaustion. Context is checked before each
|
||||||
|
wait, acquisition, and dispatch.
|
||||||
|
|
||||||
|
The public client adapter translates explicitly supplied public transport and
|
||||||
|
malformed-response identities into internal categories while retaining the
|
||||||
|
original error chain. This keeps injected attempt classifications consistent
|
||||||
|
with terminal observations without parsing diagnostic text or retrying injected
|
||||||
|
clients.
|
||||||
|
|
||||||
|
Focused coordinator tests use controlled transport and wait functions. They
|
||||||
|
cover eligibility, exhaustion, cancellation, backoff, request freezing, permit
|
||||||
|
release, and the injected-client path. The capacity manager tests own permit
|
||||||
|
acquisition and idempotent release. Public
|
||||||
|
[integration tests](../../reliable_execution_contract_test.go) cover retry,
|
||||||
|
repair, observation, frozen prepared execution, and shared-backend behavior
|
||||||
|
through the assembled facade.
|
||||||
@@ -10,7 +10,7 @@ owns the observable outbound HTTP contract. The
|
|||||||
[framework format reference](../formats.md) owns the profile and prompt
|
[framework format reference](../formats.md) owns the profile and prompt
|
||||||
settings consumed by the client.
|
settings consumed by the client.
|
||||||
|
|
||||||
The concrete client remains under `internal/llm`. The root engine assembles it
|
The concrete client remains under `internal/llm`. The internal engine assembles it
|
||||||
as the default implementation behind Promptkit's public client boundary.
|
as the default implementation behind Promptkit's public client boundary.
|
||||||
|
|
||||||
## Components And Flow
|
## Components And Flow
|
||||||
@@ -21,31 +21,39 @@ uses internal domain values for rendered prompts, execution targets,
|
|||||||
structured output, responses, and token usage.
|
structured output, responses, and token usage.
|
||||||
|
|
||||||
The runner supplies a fully resolved target after applying backend, profile,
|
The runner supplies a fully resolved target after applying backend, profile,
|
||||||
and request precedence. The client uses its endpoint, credential metadata,
|
and request precedence, plus canonical provider-bound text messages. The
|
||||||
generation fields, and extra parameters. `BackendID` remains routing metadata
|
client uses its endpoint, credential metadata, generation fields, and extra
|
||||||
|
parameters. `BackendID` remains routing metadata
|
||||||
for the generation boundary and is not mapped into the provider payload.
|
for the generation boundary and is not mapped into the provider payload.
|
||||||
|
|
||||||
Construction trims and validates a nonempty configured base URL and clones any
|
Construction trims and validates a nonempty configured base URL and clones any
|
||||||
supplied `http.Client` so Promptkit can apply its timeout default without
|
supplied `http.Client` so Promptkit can apply its timeout default without
|
||||||
mutating the caller's client. An empty configured base remains valid because a
|
mutating the caller's client. An empty configured base remains valid because a
|
||||||
resolved request target may supply the endpoint. Generation then:
|
resolved request target may supply the endpoint. Generation preparation then:
|
||||||
|
|
||||||
1. validates shared execution-setting invariants and the final selected base
|
1. validates shared execution-setting invariants and the final selected base
|
||||||
endpoint;
|
endpoint;
|
||||||
2. maps the internal request into the OpenAI-compatible chat payload;
|
2. maps the internal request into the OpenAI-compatible chat payload;
|
||||||
3. validates and merges extra parameters;
|
3. validates and merges extra parameters;
|
||||||
4. composes `/chat/completions` through parsed URL path operations;
|
4. composes `/chat/completions` through parsed URL path operations;
|
||||||
5. resolves authentication;
|
5. resolves authentication and freezes the URL, body, and optional credential.
|
||||||
6. performs the outbound request under the applicable deadlines; and
|
|
||||||
7. decodes one strictly framed, size-bounded response object and maps its first
|
Each execution of that prepared request creates a fresh HTTP request, body
|
||||||
choice and token usage.
|
reader, and attempt timeout context. It closes the response body and returns
|
||||||
|
the HTTP status, decoded response or error, reported token values and presence,
|
||||||
|
and any valid provider `Retry-After` not-before time. A strictly decoded
|
||||||
|
successful envelope can retain usage even if its first choice or content is
|
||||||
|
missing. Invalid framing and non-success error bodies supply no usage. The
|
||||||
|
ordinary `Generate` method prepares and executes once and returns no partial
|
||||||
|
response on failure.
|
||||||
|
|
||||||
`internal/llm` owns the set of reserved OpenAI-compatible request fields used
|
`internal/llm` owns the set of reserved OpenAI-compatible request fields used
|
||||||
when validating extra parameters. Backend registration consumes the same rule
|
when validating extra parameters. Backend registration consumes the same rule
|
||||||
without making the model client depend on registry configuration.
|
without making the model client depend on registry configuration.
|
||||||
|
|
||||||
The implementation has no retry loop, tool-call support, provider catalog,
|
This client does not schedule retries; the [generation coordinator](generation.md)
|
||||||
inbound HTTP behavior, or durable session store.
|
does so around its prepared attempts. The client has no tool-call support,
|
||||||
|
provider catalog, inbound HTTP behavior, or durable session store.
|
||||||
|
|
||||||
## Prepared Generation
|
## Prepared Generation
|
||||||
|
|
||||||
@@ -54,12 +62,14 @@ the target, rendered messages, and structured-output constraint retained by
|
|||||||
executable preparation. Execution does not reopen or rerender consumer
|
executable preparation. Execution does not reopen or rerender consumer
|
||||||
sources.
|
sources.
|
||||||
|
|
||||||
Before backend admission, the runner rechecks that the frozen credential
|
Before backend admission, the runner rechecks a frozen credential
|
||||||
environment-variable name is available. The handle does not retain the
|
environment-variable name only when the target explicitly requires a
|
||||||
environment value; the model client resolves the value visible when generation
|
credential. The handle does not retain the environment value; the model client
|
||||||
begins. A direct request key remains in private execution state only until the
|
resolves the value visible when generation begins. For optional sources with no
|
||||||
claimed execution finishes or an unclaimed handle is discarded. Exact public
|
usable value, the built-in client omits `Authorization` and continues to the
|
||||||
ownership and redaction semantics belong to the
|
provider. A direct request key remains in private execution state only until
|
||||||
|
the claimed execution finishes or an unclaimed handle is discarded. Exact
|
||||||
|
public ownership and redaction semantics belong to the
|
||||||
[`PreparedExecution` GoDoc](../../prepared_execution.go).
|
[`PreparedExecution` GoDoc](../../prepared_execution.go).
|
||||||
|
|
||||||
## Failure Categories
|
## Failure Categories
|
||||||
@@ -67,21 +77,44 @@ ownership and redaction semantics belong to the
|
|||||||
The package preserves distinct error identities for invalid client
|
The package preserves distinct error identities for invalid client
|
||||||
configuration, invalid generation requests, request execution failures,
|
configuration, invalid generation requests, request execution failures,
|
||||||
non-success provider statuses, and malformed successful responses. Provider
|
non-success provider statuses, and malformed successful responses. Provider
|
||||||
response bodies are not included in non-success errors.
|
response bodies are never exposed in raw form through non-success errors.
|
||||||
|
|
||||||
Invalid nonempty configured endpoints are configuration failures. A missing or
|
Invalid nonempty configured endpoints are configuration failures. A missing or
|
||||||
invalid final selected endpoint is an invalid generation request and is
|
invalid final selected endpoint is an invalid generation request and is
|
||||||
rejected before transport.
|
rejected before transport.
|
||||||
|
|
||||||
|
Authentication resolves a trimmed direct key before a trimmed configured
|
||||||
|
environment value. Optional missing, empty, or whitespace-only sources do not
|
||||||
|
block transport and produce no `Authorization` header. An explicitly required
|
||||||
|
target with no usable source is rejected before transport with the existing
|
||||||
|
invalid-request diagnostics.
|
||||||
|
|
||||||
Successful response bodies have a fixed 16 MiB limit enforced by declared
|
Successful response bodies have a fixed 16 MiB limit enforced by declared
|
||||||
length and by reading at most one byte beyond the boundary. The decoder accepts
|
length and by reading at most one byte beyond the boundary. The decoder accepts
|
||||||
exactly one JSON object plus trailing whitespace and EOF. Size overflow,
|
exactly one JSON object plus trailing whitespace and EOF. Size overflow,
|
||||||
truncation, malformed JSON, trailing data, and a second value are malformed
|
truncation, malformed JSON, trailing data, and a second value are malformed
|
||||||
responses with no partial result or provider content in the error. Every body
|
responses with no partial result or provider content in the error. Every body
|
||||||
is closed, and an unbounded oversized stream is not drained. Non-success
|
is closed, and an unbounded oversized stream is not drained.
|
||||||
responses remain status-only; bounded provider error-envelope parsing belongs
|
|
||||||
to the
|
After framing succeeds, the first choice must contain an explicitly present
|
||||||
[structured-generation-error roadmap](../roadmap/structured-generation-errors.md).
|
string `message.content`. The string is returned exactly, including empty or
|
||||||
|
whitespace-only content. Missing choices, missing or `null` content, and
|
||||||
|
non-string content are malformed responses. Output validation and correction
|
||||||
|
eligibility remain outside this package.
|
||||||
|
|
||||||
|
Successful-envelope usage is decoded with independent presence bits for each
|
||||||
|
token field: omission and `null` are unknown, while a reported integer zero is
|
||||||
|
known. Strictly decoded usage can still be observed if later choice/content
|
||||||
|
checks fail; invalid framing or a non-success error body contributes none.
|
||||||
|
Existing successful-result accounting remains value-based.
|
||||||
|
|
||||||
|
For a non-success response, `ProviderHTTPError` retains the HTTP status and
|
||||||
|
only normalized detail from the bounded recognized envelope. It retains
|
||||||
|
`ErrUnexpectedStatus` through unwrapping. The client owns response closure;
|
||||||
|
its bounded reader and parser never close or drain a body themselves. The root
|
||||||
|
facade converts this concrete internal error into the public
|
||||||
|
[`GenerationError`](../../generation_error.go), while arbitrary injected-client
|
||||||
|
errors continue through the ordinary generation-error mapping unchanged.
|
||||||
|
|
||||||
An `http.Client.Do` failure is represented by a redacting multi-cause error:
|
An `http.Client.Do` failure is represented by a redacting multi-cause error:
|
||||||
the package request-failure sentinel and the exact returned transport error are
|
the package request-failure sentinel and the exact returned transport error are
|
||||||
@@ -99,10 +132,14 @@ The
|
|||||||
own configuration, client cloning, deterministic deadline precedence,
|
own configuration, client cloning, deterministic deadline precedence,
|
||||||
authentication, request and response mapping, malformed data, error identity,
|
authentication, request and response mapping, malformed data, error identity,
|
||||||
cancellation, endpoint selection and composition, pre-transport rejection, and
|
cancellation, endpoint selection and composition, pre-transport rejection, and
|
||||||
bounded single-document response framing, closure, and response-body
|
bounded single-document successful-response framing, closure, and
|
||||||
suppression. The root
|
response-body suppression. The focused
|
||||||
transport contract tests also verify that resolved backend settings reach this
|
[provider HTTP error tests](../../internal/llm/provider_http_error_test.go)
|
||||||
client without serializing backend identity and that ordinary-run cancellation
|
own envelope parsing, normalization, and bounded-reader cases; their
|
||||||
retains its public generation and context identities. All use local test
|
[transport tests](../../internal/llm/provider_http_error_transport_test.go)
|
||||||
servers or controlled test transports; the default suite makes no live or paid
|
own non-success response closure and integration. Root transport contract tests
|
||||||
provider requests.
|
own public `GenerationError` conversion, while also verifying that resolved
|
||||||
|
backend settings reach this client without serializing backend identity and
|
||||||
|
that ordinary-run cancellation retains its public generation and context
|
||||||
|
identities. All use local test servers or controlled test transports; the
|
||||||
|
default suite makes no live or paid provider requests.
|
||||||
|
|||||||
48
docs/internal/observation.md
Normal file
48
docs/internal/observation.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Internal Execution Observation
|
||||||
|
|
||||||
|
`internal/observation` owns one invocation's run ID, presence-aware token
|
||||||
|
aggregation, measured phase totals, and synchronous metadata event delivery.
|
||||||
|
Its event values and callback type live in `internal/domain` so internal
|
||||||
|
requests can carry them without depending on the recorder package. The
|
||||||
|
recorder supplies run IDs to the runner and receives attempt and
|
||||||
|
retry-delay measurements from the generation coordinator. Ordinary `Run`
|
||||||
|
lifecycle instrumentation spans the facade and runner boundaries.
|
||||||
|
Prepared execution starts only after its private payload is atomically claimed;
|
||||||
|
the facade receives that invocation's recorder privately and emits the terminal
|
||||||
|
event after public error mapping. Failed claims and preparation emit nothing.
|
||||||
|
|
||||||
|
The facade masks inherited recorder and prepared-claim state when entering
|
||||||
|
preparation or execution. Delegated calls from an injected collaborator retain
|
||||||
|
the caller's cancellation, deadline, and unrelated context values, but do not
|
||||||
|
reuse the enclosing invocation's accounting. Only the new request's observer
|
||||||
|
(or the claimed handle's retained observer) enables its observation lifecycle.
|
||||||
|
|
||||||
|
A recorder keeps only counters, resolved identity fields, and the selected
|
||||||
|
callback. It has no global history, queue, goroutine, or mutex held across a
|
||||||
|
callback. The caller supplies normalized failure metadata; the recorder never
|
||||||
|
parses an error string or receives provider bodies, credentials, headers, or
|
||||||
|
other content. Events are values with no mutable containers. Callback panics
|
||||||
|
propagate to the caller.
|
||||||
|
Per-operation callbacks are ordered and synchronous; different operations may
|
||||||
|
invoke the same callback concurrently. The recorder marks each measured phase's
|
||||||
|
end before delivery, so callback work does not inflate provider duration.
|
||||||
|
Terminal phase durations sum measured phases, while wall duration may also
|
||||||
|
include preparation, validation, and callbacks. Panics do not promise a
|
||||||
|
terminal event.
|
||||||
|
|
||||||
|
Attempt usage is known when its explicit presence bit is true. For legacy
|
||||||
|
injected clients without presence metadata, nonzero counts are known and
|
||||||
|
zeros are unknown. Aggregate values sum known contributions; `Known` means
|
||||||
|
at least one reported value, while `Complete` means every dispatched attempt
|
||||||
|
reported the field. Zero dispatched attempts produce known, complete zero
|
||||||
|
totals. Non-dispatched acquisitions contribute capacity wait but no usage or
|
||||||
|
generation duration. Terminal totals count dispatched attempts, transient
|
||||||
|
attempts above the first in a generation, and repair generations with at least
|
||||||
|
one dispatch. A terminal event is an aggregate and must not be added to its
|
||||||
|
attempt events.
|
||||||
|
|
||||||
|
The [recorder tests](../../internal/observation/recorder_test.go) own usage
|
||||||
|
presence, phase sums, event ordering, empty-run accounting, identity snapshots,
|
||||||
|
and callback panic behavior. The [generation coordinator](generation.md) owns
|
||||||
|
attempt and retry-delay measurements and emits their completion events after
|
||||||
|
releasing active permits.
|
||||||
@@ -11,25 +11,29 @@ contributor workflow and validation.
|
|||||||
|
|
||||||
| Component | Implemented responsibility | References |
|
| Component | Implemented responsibility | References |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Root `promptkit` package | Provides the supported engine facade, source, backend-registration, and injection options, public request, result, prompt-inspection, and profile-inspection values, opaque prepared-execution handles, profile construction, extension interfaces, value conversion, redacted formatting, typed capacity errors, public error mapping, and engine-local profile-source assembly including application fallbacks. | [Package GoDoc](../../doc.go), [prepared execution](../../prepared_execution.go), [backend API](../../backends.go), [engine assembly](../../engine.go) |
|
| Root `promptkit` package | Provides the supported engine facade, source, backend-registration, and injection options, public request, result, prompt-inspection, profile-inspection, and ordinary-run observation values, opaque prepared-execution handles, profile construction, extension interfaces, value conversion, redacted formatting, and typed capacity and generation error mapping. | [Package GoDoc](../../doc.go), [observations](../../observations.go), [prepared execution](../../prepared_execution.go), [backend API](../../backends.go), [engine facade](../../engine.go) |
|
||||||
| `examples/go-library/prepare` | Demonstrates an offline downstream consumer using a prompt file, in-memory profile, inline input, and `Prepare`. It is not a public library package. | [Example program](../../examples/go-library/prepare/main.go) |
|
| `examples/go-library/prepare` | Demonstrates an offline downstream consumer using a prompt file, in-memory profile, inline input, and `Prepare`. It is not a public library package. | [Example program](../../examples/go-library/prepare/main.go) |
|
||||||
| `examples/go-library/run` | Demonstrates an offline downstream consumer using a prompt file, in-memory profile, inline input, an injected deterministic model client, and `Run`. It is not a public library package. | [Example program](../../examples/go-library/run/main.go) |
|
| `examples/go-library/run` | Demonstrates an offline downstream consumer using a prompt file, in-memory profile, inline input, an injected deterministic model client, and `Run`. It is not a public library package. | [Example program](../../examples/go-library/run/main.go) |
|
||||||
| `internal/backend` | Constructs each engine's immutable registry from the built-in OpenRouter definition and consumer additions, validates and defensively copies definitions through the shared JSON-value package, and consumes the LLM-owned OpenAI-compatible reserved request-field rule. | [Backend registry](../../internal/backend/registry.go) |
|
| `examples/go-library/observe` | Demonstrates offline built-in HTTP retry and synchronous metadata observation across structured proposal and assessment runs using embedded sources, two profiles on one finite backend, and an application decision. It is not a public library package. | [Example program](../../examples/go-library/observe/main.go) |
|
||||||
|
| `internal/backend` | Constructs each engine's immutable registry from maintained definitions and consumer additions, validates and defensively copies definitions through the shared JSON-value package, and consumes the LLM-owned OpenAI-compatible reserved request-field rule. | [Backend registry](../../internal/backend/registry.go) |
|
||||||
|
| `internal/catalog` | Strictly validates imported immutable maintained backend and profile catalog assets before engine assembly uses them. | [Catalog adapter](../../internal/catalog/catalog.go), [internal sources](sources.md#profiles-and-built-ins) |
|
||||||
| `internal/capacity` | Owns engine-local bounded execution admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) |
|
| `internal/capacity` | Owns engine-local bounded execution admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) |
|
||||||
| `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, profiles, execution targets, rendering, generation, and validation, and owns source-neutral invariants for shared execution settings, OpenAI-compatible base endpoints, session identifiers, and output contracts. Source parsing, required fields, other source-specific normalization, defaulting, and boundary-specific error classification remain with their callers. | [Domain declarations](../../internal/domain/domain.go), [endpoint invariant](../../internal/domain/endpoint.go) |
|
| `internal/domain` | Defines internal framework values for requests, artifacts, prompt definitions, profiles, execution targets, rendering, generation, and validation, and owns source-neutral invariants for shared execution settings, OpenAI-compatible base endpoints, session identifiers, and output contracts. Source parsing, required fields, other source-specific normalization, defaulting, and boundary-specific error classification remain with their callers. | [Domain declarations](../../internal/domain/domain.go), [endpoint invariant](../../internal/domain/endpoint.go) |
|
||||||
|
| `internal/engine` | Assembles external catalogs, Promptkit-owned profiles, repositories, registry, capacity, collaborators, repairer, and the one use-case runner for each engine instance. | [Engine assembly](../../internal/engine/engine.go), [internal sources](sources.md#profiles-and-built-ins), [internal runner](runner.md) |
|
||||||
|
| `internal/generation` | Coordinates frozen built-in HTTP attempts and one-shot injected calls through active backend permits, including bounded status retries, cancellable backoff, and attempt observations. | [Internal generation coordination](generation.md) |
|
||||||
| `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) |
|
| `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) |
|
||||||
| `internal/filecatalog` | Provides deterministic YAML discovery and path helpers for operating-system filesystems and `fs.FS` sources. | [File catalog](../../internal/filecatalog/catalog.go) |
|
| `internal/filecatalog` | Provides deterministic YAML discovery and path helpers for operating-system filesystems and `fs.FS` sources. | [File catalog](../../internal/filecatalog/catalog.go) |
|
||||||
| `internal/jsonvalue` | Validates and deeply copies bounded JSON-compatible extra-parameter and prepared-schema trees while preserving supported concrete value types and rejecting cycles or excessive depth and work. | [JSON values](../../internal/jsonvalue/jsonvalue.go) |
|
| `internal/jsonvalue` | Validates and deeply copies bounded JSON-compatible extra-parameter and prepared-schema trees while preserving supported concrete value types and rejecting cycles or excessive depth and work. | [JSON values](../../internal/jsonvalue/jsonvalue.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/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, including backend selection, 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` | Owns immutable in-memory and filesystem profile repositories, local profile validation, raw-source overlays with error-preserving fallback, and inherited-profile resolution. | [Framework formats](../formats.md), [profile repositories](../../internal/profile/filesystem_repository.go), [internal sources](sources.md#profiles-and-built-ins) |
|
||||||
| `internal/profile/builtin` | Embeds the built-in profile catalog, whose entries select OpenRouter. | [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/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/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 and creates operation-local validation plans with canonical contained schema resources. | [Framework formats](../formats.md#schemas), [internal sources and validation](sources.md) |
|
| `internal/validate` | Validates basic, JSON, and JSON Schema output using operating-system filesystem or `fs.FS` schema sources and creates operation-local validation plans with canonical contained schema resources. | [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 from resolved execution targets, including response decoding, authentication, deadline handling, and ownership of the OpenAI-compatible reserved request-field policy. | [Internal model client](llm.md) |
|
| `internal/llm` | Defines the internal generation boundary and implements outbound OpenAI-compatible chat requests from resolved execution targets, including bounded structured non-success response decoding, successful-response decoding, authentication, deadline handling, and ownership of the OpenAI-compatible reserved request-field policy. | [Internal model client](llm.md) |
|
||||||
| `internal/usecase` | Resolves prompt definitions and hashes, profiles, backends, and targets for exact inspection and request settings for preparation, and coordinates ordinary execution and one-attempt prepared execution across internal sources, rendering, artifact loading, operation-local validation plans, generation, capacity, and optional repair. | [Internal runner](runner.md), [prepared-execution implementation](../../internal/usecase/prepared_execution.go) |
|
| `internal/observation` | Owns invocation-local run IDs, presence-aware usage and phase accounting, and synchronous metadata event delivery for ordinary and claimed prepared runs and generation attempts. | [Internal execution observation](observation.md) |
|
||||||
|
| `internal/usecase` | Resolves prompt definitions and hashes, profiles, backends, and targets for exact inspection and request settings for preparation, and coordinates ordinary and single-claim prepared execution across internal sources, rendering, artifact loading, operation-local validation plans, generation, capacity, and bounded repair. | [Internal runner](runner.md), [prepared-execution implementation](../../internal/usecase/prepared_execution.go) |
|
||||||
|
|
||||||
The root package assembles these internal components without exposing their
|
The internal engine assembles these internal components without exposing their
|
||||||
representations. Consumers depend only on the root facade.
|
representations. Consumers depend only on the root facade.
|
||||||
|
|
||||||
## Maintenance
|
## Maintenance
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ artifact, rendering, and validation behavior, while the
|
|||||||
[model-client document](llm.md) owns generation behavior and failure
|
[model-client document](llm.md) owns generation behavior and failure
|
||||||
categories.
|
categories.
|
||||||
|
|
||||||
The runner remains under `internal/usecase` and is assembled by the root
|
The runner remains under `internal/usecase` and is assembled by
|
||||||
Promptkit engine. Its concrete type is not part of the public API.
|
`internal/engine`. Its concrete type is not part of the public API.
|
||||||
The [framework format reference](../formats.md) owns prompt, profile, schema,
|
The [framework format reference](../formats.md) owns prompt, profile, schema,
|
||||||
and override semantics consumed by the runner.
|
and override semantics consumed by the runner.
|
||||||
|
|
||||||
@@ -18,13 +18,15 @@ and override semantics consumed by the runner.
|
|||||||
|
|
||||||
`Runner` coordinates narrow internal interfaces for prompt definitions,
|
`Runner` coordinates narrow internal interfaces for prompt definitions,
|
||||||
profiles, backend resolution, artifacts, rendering, model generation, and
|
profiles, backend resolution, artifacts, rendering, model generation, and
|
||||||
validation. The root engine supplies one immutable registry containing the
|
validation. The internal engine supplies one immutable registry containing the
|
||||||
built-in backend and validated consumer additions, one engine-local run
|
built-in backend and validated consumer additions, one engine-local run
|
||||||
admitter, and a model client wrapped by the same capacity manager. Validation
|
admitter, and a generation coordinator using the same capacity manager. Validation
|
||||||
plans and provider-facing schema metadata come from the validator's preparation
|
plans and provider-facing schema metadata come from the validator's preparation
|
||||||
interface.
|
interface.
|
||||||
An output repairer can be injected internally, but the ordinary runner
|
The internal engine supplies one default output repairer through the explicit
|
||||||
constructor does not enable one.
|
runner constructor, using the same coordinator as initial
|
||||||
|
generation. The no-repair runner constructor remains available for focused
|
||||||
|
internal callers and tests.
|
||||||
|
|
||||||
Each invocation carries its state in request, prepared-run, and result values.
|
Each invocation carries its state in request, prepared-run, and result values.
|
||||||
The runner has no durable run or session store.
|
The runner has no durable run or session store.
|
||||||
@@ -82,7 +84,8 @@ profile, or backend:
|
|||||||
schema metadata from it when required;
|
schema metadata from it when required;
|
||||||
2. load and hash input artifacts;
|
2. load and hash input artifacts;
|
||||||
3. render messages and the prompt-defined session;
|
3. render messages and the prompt-defined session;
|
||||||
4. apply any direct session ID;
|
4. apply any direct session ID, then append already-normalized request messages
|
||||||
|
after the rendered definition messages;
|
||||||
5. hash the effective rendered prompt; and
|
5. hash the effective rendered prompt; and
|
||||||
6. construct the prepared value and preparation timing.
|
6. construct the prepared value and preparation timing.
|
||||||
|
|
||||||
@@ -111,7 +114,10 @@ the prompt session template, and is applied after ordinary message rendering.
|
|||||||
A blank direct value retains prompt-template behavior. The runner clears the
|
A blank direct value retains prompt-template behavior. The runner clears the
|
||||||
template only on a value copy of the definition, so the definition hash always
|
template only on a value copy of the definition, so the definition hash always
|
||||||
describes the original source while the rendered-prompt hash includes the
|
describes the original source while the rendered-prompt hash includes the
|
||||||
effective direct or rendered session.
|
effective direct or rendered session and the complete effective message
|
||||||
|
sequence. The rendered-prompt hash uses a versioned, length-framed SHA-256
|
||||||
|
encoding of that session, every role and content value, and cache-control
|
||||||
|
presence and values; its hexadecimal value is opaque.
|
||||||
|
|
||||||
The registry is read-only after engine construction. Concurrent `Prepare` and
|
The registry is read-only after engine construction. Concurrent `Prepare` and
|
||||||
`Run` calls resolve independent defensive backend values and keep all
|
`Run` calls resolve independent defensive backend values and keep all
|
||||||
@@ -125,8 +131,17 @@ admitter is an internal unlimited fallback. After successful admission, `Run`
|
|||||||
immediately defers the returned release function, performs the completion
|
immediately defers the returned release function, performs the completion
|
||||||
phase, makes one initial generation call, builds the named output artifact,
|
phase, makes one initial generation call, builds the named output artifact,
|
||||||
and validates that artifact with the plan compiled during completion. Invalid
|
and validates that artifact with the plan compiled during completion. Invalid
|
||||||
generated content remains a validation result; an inability to perform
|
generated content remains a validation result; an inability to generate or
|
||||||
validation is an operational error.
|
perform validation is an operational error.
|
||||||
|
|
||||||
|
When the public facade installs an invocation-local observation recorder,
|
||||||
|
ordinary `Run` reuses its run ID and carries it to the generation coordinator
|
||||||
|
through context. Prompt and profile identities are enriched only after their
|
||||||
|
respective resolutions succeed, before admission. The facade starts observation
|
||||||
|
before request conversion and emits its terminal event after public error
|
||||||
|
mapping and after the runner's deferred admission release. Preparation and
|
||||||
|
prepared execution follows the same terminal mapping after a successful claim;
|
||||||
|
invalid or discarded handles do not acquire an observer.
|
||||||
|
|
||||||
Validation preparation and execution honor cancellation at every
|
Validation preparation and execution honor cancellation at every
|
||||||
Promptkit-controlled boundary and do not publish a partial plan or result.
|
Promptkit-controlled boundary and do not publish a partial plan or result.
|
||||||
@@ -139,20 +154,30 @@ method must return before cancellation can take precedence over its outcome.
|
|||||||
The admission lease covers completion-phase preparation, initial generation,
|
The admission lease covers completion-phase preparation, initial generation,
|
||||||
validation, every repair, and every exit. It bounds accepted work without
|
validation, every repair, and every exit. It bounds accepted work without
|
||||||
serializing preparation or validation behind the active-generation limit.
|
serializing preparation or validation behind the active-generation limit.
|
||||||
The wrapped model client separately acquires a FIFO active permit only around
|
The coordinator separately acquires a FIFO active permit only around each
|
||||||
each actual generation call.
|
actual model attempt. It releases the permit before a transient retry wait.
|
||||||
|
|
||||||
When an internal repairer is present, a JSON or JSON Schema content failure can
|
After a failed `basic`, JSON, or JSON Schema validation with a positive frozen
|
||||||
trigger bounded repair attempts. Repair receives the effective execution
|
budget, the installed repairer can make a bounded corrective call. Each request
|
||||||
target, explicit numeric-presence bits, credential, backend identity, session
|
starts with a fresh copy of the complete effective message sequence (the
|
||||||
ID, validation errors, prior output, and structured-output specification. One
|
configured prefix followed by the request suffix), includes only the latest
|
||||||
request constructor supplies those common fields to initial and repair
|
nonempty candidate as an assistant message, and appends one corrective user
|
||||||
generation while their rendered prompts remain intentionally distinct. The
|
message. Empty candidates omit that assistant message. The
|
||||||
default repairer uses the same wrapped client as initial generation, so each
|
correction carries validation diagnostics as JSON data bounded to 64 KiB; the
|
||||||
repair reacquires the selected backend's active permit while remaining inside
|
full diagnostics remain in the validation result.
|
||||||
its original admission lease. Repair never performs a second bounded
|
|
||||||
admission, and repaired outputs use the operation's existing validation plan.
|
Repair receives the effective execution target, explicit numeric-presence bits,
|
||||||
This capability remains internal and is not a public option.
|
credential, backend identity, session ID, structured-output specification, and
|
||||||
|
frozen transient retry budget.
|
||||||
|
The same request constructor supplies those common fields to initial and repair
|
||||||
|
generation. The default repairer uses the same coordinator as initial
|
||||||
|
generation, so each repair reacquires the selected backend's active permit
|
||||||
|
while remaining inside its original admission lease. Repair never performs a
|
||||||
|
second bounded admission, and repaired outputs use the operation's existing
|
||||||
|
validation plan. The runner stops at the first valid candidate, sums completed
|
||||||
|
generation usage, reports calls actually started, and returns the final failed
|
||||||
|
validation result on exhaustion. A repair generation failure follows the
|
||||||
|
ordinary generation-error category rather than becoming a validation error.
|
||||||
|
|
||||||
A successful result includes the output artifact and raw output, validation
|
A successful result includes the output artifact and raw output, validation
|
||||||
state, effective session ID, prompt and rendered-prompt hashes, selected
|
state, effective session ID, prompt and rendered-prompt hashes, selected
|
||||||
@@ -164,7 +189,8 @@ credential during execution, reaches generation and every repair attempt.
|
|||||||
Result usage is the field-wise sum of all five usage values from the initial
|
Result usage is the field-wise sum of all five usage values from the initial
|
||||||
response and every completed repair response. Final raw output, artifact, and
|
response and every completed repair response. Final raw output, artifact, and
|
||||||
validation state still come from the last candidate. A repair error returns no
|
validation state still come from the last candidate. A repair error returns no
|
||||||
partial run result or partial usage.
|
partial run result or partial result usage, although the invocation-local
|
||||||
|
observer retains completed-attempt usage and reports it in the terminal event.
|
||||||
|
|
||||||
## Failure Categories
|
## Failure Categories
|
||||||
|
|
||||||
@@ -203,8 +229,9 @@ behavior, hashing, generation and validation outcomes, backend propagation,
|
|||||||
bounded repair progression, initial/repair request parity, cumulative usage,
|
bounded repair progression, initial/repair request parity, cumulative usage,
|
||||||
shared initial/repair capacity, credentials and redaction, error categories,
|
shared initial/repair capacity, credentials and redaction, error categories,
|
||||||
artifact metadata, and timing. The
|
artifact metadata, and timing. The
|
||||||
[capacity subsystem document](capacity.md) identifies the focused pool,
|
[capacity subsystem document](capacity.md) identifies the focused pool and
|
||||||
waiter, and wrapped-client tests.
|
waiter tests. The [generation document](generation.md) owns attempt and retry
|
||||||
|
coordination tests.
|
||||||
|
|
||||||
Changes to orchestration should continue to use the existing package
|
Changes to orchestration should continue to use the existing package
|
||||||
interfaces, keep request state local to an invocation, and preserve the shared
|
interfaces, keep request state local to an invocation, and preserve the shared
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This document describes Promptkit's implemented internal source, artifact,
|
|||||||
rendering, and output-validation behavior. The
|
rendering, and output-validation behavior. The
|
||||||
[architecture policy](../policy/architecture.md) owns the library boundary and
|
[architecture policy](../policy/architecture.md) owns the library boundary and
|
||||||
dependency rules. None of these internal packages is a supported consumer API,
|
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.
|
and the internal engine assembles them behind the public facade's source options and values.
|
||||||
The [framework format reference](../formats.md) owns the exact file fields,
|
The [framework format reference](../formats.md) owns the exact file fields,
|
||||||
validation modes, built-in catalog, and source precedence.
|
validation modes, built-in catalog, and source precedence.
|
||||||
|
|
||||||
@@ -21,6 +21,12 @@ paths, content opening, and root containment. Each lookup remains a
|
|||||||
point-in-time scan: definitions and catalogs are not cached, and file-backed
|
point-in-time scan: definitions and catalogs are not cached, and file-backed
|
||||||
message content is opened only for the exact selected candidate.
|
message content is opened only for the exact selected candidate.
|
||||||
|
|
||||||
|
Message roles are normalized through the shared domain owner by trimming
|
||||||
|
Unicode whitespace and lowercasing. Only `developer`, `system`, `user`, and
|
||||||
|
`assistant` are published; invalid roles remain selected prompt-definition
|
||||||
|
failures rather than becoming request errors. Cache-control metadata uses the
|
||||||
|
same shared domain normalization and defensive-copy rule.
|
||||||
|
|
||||||
Operating-system sources enforce containment against canonical roots and
|
Operating-system sources enforce containment against canonical roots and
|
||||||
targets so symlinks cannot escape. Injected `fs.FS` sources enforce containment
|
targets so symlinks cannot escape. Injected `fs.FS` sources enforce containment
|
||||||
in their clean relative path namespace. A single-file source uses the selected
|
in their clean relative path namespace. A single-file source uses the selected
|
||||||
@@ -39,17 +45,36 @@ duplicate detection, and source containment:
|
|||||||
|
|
||||||
## Profiles And Built-Ins
|
## Profiles And Built-Ins
|
||||||
|
|
||||||
`internal/profile` loads and validates execution profiles from an
|
`internal/profile` owns immutable in-memory and filesystem repositories, local
|
||||||
operating-system filesystem or an `fs.FS`. A file contains exactly one YAML
|
validation, overlays, and resolution for execution profiles. In-memory
|
||||||
document and its trimmed YAML `id` is its only selection identity; filenames do
|
definitions are normalized and validated once at that repository boundary and
|
||||||
not confer authority. Each point lookup reads discovered files once for their
|
are defensively copied on storage and lookup. A file contains
|
||||||
metadata and reuses the selected file's bytes for strict decoding; unrelated
|
exactly one YAML document and its trimmed YAML `id` is its only selection
|
||||||
profiles are not fully decoded. Strict selected decoding recognizes the
|
identity; filenames do not confer authority. Each point lookup reads discovered
|
||||||
optional `backend` field, trims its value, and requires a model plus at least
|
files once for their metadata and reuses the selected file's bytes for strict
|
||||||
one non-blank backend or endpoint. File-backed `extra_params` values are
|
decoding; unrelated profiles are not fully decoded. Strict selected decoding
|
||||||
validated and defensively copied through the shared bounded JSON-value owner
|
recognizes `base_profile` and the optional `backend` field, trims their values,
|
||||||
before a profile is published. OpenAI-compatible reserved-field policy remains
|
and permits inherited target fields only when a base is named. File-backed
|
||||||
with the model-client and backend-registry owners.
|
`extra_params` values are validated and defensively copied through the shared
|
||||||
|
bounded JSON-value owner before a profile is published. OpenAI-compatible
|
||||||
|
reserved-field policy remains with the model-client and backend-registry owners.
|
||||||
|
|
||||||
|
`LoadFSRepository` is the eager immutable loading boundary for internal
|
||||||
|
catalog consumers. It discovers and strictly validates every raw profile once,
|
||||||
|
preserves safe source metadata including explicitly present YAML fields, and
|
||||||
|
publishes independently copied values from memory. It does not resolve profile
|
||||||
|
inheritance. Configured consumer sources continue to use the lazy point lookup
|
||||||
|
repositories described above.
|
||||||
|
|
||||||
|
`internal/catalog` validates the imported immutable OpenRouter and
|
||||||
|
Rakestrawhome asset modules as one private adapter boundary. It enforces their
|
||||||
|
manifest, layout, profile ownership, inheritance, and secret-safety rules
|
||||||
|
before returning raw catalog sources. Internal engine assembly uses those validated
|
||||||
|
catalogs as the lowest-precedence profile source.
|
||||||
|
|
||||||
|
Normalized external catalog profile IDs with the exact `promptkit-` prefix are
|
||||||
|
rejected at this adapter boundary. That reservation applies only to imported
|
||||||
|
catalogs; configured consumer sources retain the ordinary profile-ID rules.
|
||||||
|
|
||||||
The overlay repository consults the next repository only when the
|
The overlay repository consults the next repository only when the
|
||||||
higher-precedence repository reports that a profile is absent. A reliably
|
higher-precedence repository reports that a profile is absent. A reliably
|
||||||
@@ -59,23 +84,35 @@ backend registry membership because the available registry belongs to the
|
|||||||
assembled engine; the runner checks membership during preparation and exact
|
assembled engine; the runner checks membership during preparation and exact
|
||||||
profile inspection.
|
profile inspection.
|
||||||
|
|
||||||
The root engine assembles profile repositories in precedence order: in-memory
|
The internal engine assembles one raw composite catalog in precedence order:
|
||||||
profiles, one ordinary configured source, an application fallback source, then
|
in-memory profiles, one ordinary configured source, an application fallback
|
||||||
the embedded built-in catalog. An explicit file or `fs.FS` profile source
|
source, Promptkit-owned profiles, then the maintained external catalog. It
|
||||||
replaces `Config.ProfileDir` within the ordinary configured-source category.
|
constructs the owned definitions through the in-memory repository and validates
|
||||||
|
their maintained-only resolution and backend membership before consumer sources
|
||||||
|
are overlaid. An explicit file or `fs.FS` profile source replaces
|
||||||
|
`Config.ProfileDir` within the ordinary configured-source category. One outer
|
||||||
|
resolving repository wraps that complete raw catalog, so each base lookup
|
||||||
|
observes the same precedence and shadowing rules.
|
||||||
|
|
||||||
Exact profile inspection performs one point-in-time lookup through those
|
The resolving repository traverses every selected chain afresh, retains no
|
||||||
profile sources and checks the resolved target without reading prompt, input,
|
cache, detects cycles, limits a chain to 32 profiles, merges root-to-leaf into a
|
||||||
|
new caller-owned value, and validates the final target before publishing it. It
|
||||||
|
does not check backend registry membership. Exact `base_profile` syntax, merge
|
||||||
|
rules, and consumer-visible failure behavior belong to the [framework format
|
||||||
|
reference](../formats.md#profile-inheritance).
|
||||||
|
|
||||||
|
Exact profile inspection performs one point-in-time resolved lookup through
|
||||||
|
those profile sources and checks the final target without reading prompt, input,
|
||||||
or schema sources. It does not retain that lookup for a later execution.
|
or schema sources. It does not retain that lookup for a later execution.
|
||||||
|
Prepared execution instead freezes the fully resolved target; a later ordinary
|
||||||
|
operation performs a fresh traversal.
|
||||||
|
|
||||||
`internal/profile/builtin` embeds the maintained built-in profile catalog.
|
The imported external catalog provides external profiles and matching backend
|
||||||
Every embedded profile selects `openrouter` and inherits its endpoint and
|
definitions. The internal engine owns the Promptkit profiles documented in the
|
||||||
credential environment-variable name from the built-in backend registry rather
|
[framework format reference](../formats.md#built-in-profile-catalog). Profile
|
||||||
than repeating those values. Profile loading and overlay behavior are owned by
|
loading and overlay behavior are owned by the [profile repository tests](../../internal/profile/repository_test.go), while
|
||||||
the [profile repository tests](../../internal/profile/repository_test.go),
|
catalog completeness, backend selection, and duplicate IDs are owned by the
|
||||||
while catalog completeness, the backend-selection invariant, and duplicate IDs
|
[catalog adapter tests](../../internal/catalog/catalog_test.go).
|
||||||
are owned by the
|
|
||||||
[built-in repository tests](../../internal/profile/builtin/repository_test.go).
|
|
||||||
|
|
||||||
## Ordinary Artifacts
|
## Ordinary Artifacts
|
||||||
|
|
||||||
@@ -110,8 +147,9 @@ Session and message parsing and execution remain synchronous. The renderer
|
|||||||
checks cancellation before and after each parse and execution boundary,
|
checks cancellation before and after each parse and execution boundary,
|
||||||
between artifact conversion chunks, around each message, and before publishing
|
between artifact conversion chunks, around each message, and before publishing
|
||||||
the complete prompt. It cannot interrupt template work already in progress and
|
the complete prompt. It cannot interrupt template work already in progress and
|
||||||
never publishes a partial prompt after observing cancellation. It carries
|
never publishes a partial prompt after observing cancellation. It validates and
|
||||||
message roles, session IDs, and cache control into the rendered prompt. The
|
canonicalizes message roles before carrying roles, session IDs, and cache
|
||||||
|
control into the rendered prompt. The
|
||||||
[renderer tests](../../internal/prompt/renderer_test.go) own rendering behavior.
|
[renderer tests](../../internal/prompt/renderer_test.go) own rendering behavior.
|
||||||
|
|
||||||
## Schemas And Output Validation
|
## Schemas And Output Validation
|
||||||
|
|||||||
93
docs/oss/attribution.md
Normal file
93
docs/oss/attribution.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Open Source Attribution Notices
|
||||||
|
|
||||||
|
## Standard Repository Policies
|
||||||
|
|
||||||
|
Material: policy documents under `docs/policy/` and the Policy Reading Guide in
|
||||||
|
`docs/development.md`, adapted for this repository; root `AGENTS.md`, copied
|
||||||
|
without modification from upstream `agents/AGENTS.md`.
|
||||||
|
|
||||||
|
Copyright (C) 2026 Eric Rakestraw
|
||||||
|
|
||||||
|
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||||
|
|
||||||
|
License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
|
||||||
|
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE).
|
||||||
|
|
||||||
|
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
|
||||||
|
|
||||||
|
## Promptkit OpenRouter Catalog
|
||||||
|
|
||||||
|
Selected module: `gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2`
|
||||||
|
at `v2.20260926.0`.
|
||||||
|
|
||||||
|
Copyright (C) 2026 Eric Rakestraw
|
||||||
|
|
||||||
|
SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
License and notices: [MIT](licenses/MIT.txt) |
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/promptkit-backend-openrouter/src/commit/4f731d217e0bc39151c010f50fb5912706e7b6b3/COPYRIGHT) |
|
||||||
|
[LICENSE](https://gitea.maximumdirect.net/eric/promptkit-backend-openrouter/src/commit/4f731d217e0bc39151c010f50fb5912706e7b6b3/LICENSE).
|
||||||
|
|
||||||
|
[Source](https://gitea.maximumdirect.net/eric/promptkit-backend-openrouter/src/commit/4f731d217e0bc39151c010f50fb5912706e7b6b3).
|
||||||
|
|
||||||
|
## Promptkit Rakestrawhome Catalog
|
||||||
|
|
||||||
|
Selected module: `gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2`
|
||||||
|
at `v2.20260926.0`.
|
||||||
|
|
||||||
|
Copyright (C) 2026 Eric Rakestraw
|
||||||
|
|
||||||
|
SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
License and notices: [MIT](licenses/MIT.txt) |
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/src/commit/48918a3ad39fd99843cda94a86133b1b3881d596/COPYRIGHT) |
|
||||||
|
[LICENSE](https://gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/src/commit/48918a3ad39fd99843cda94a86133b1b3881d596/LICENSE).
|
||||||
|
|
||||||
|
[Source](https://gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/src/commit/48918a3ad39fd99843cda94a86133b1b3881d596).
|
||||||
|
|
||||||
|
## jsonschema
|
||||||
|
|
||||||
|
Selected module: `github.com/santhosh-tekuri/jsonschema/v6` at `v6.0.2`.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
License and notices: [Apache 2.0](licenses/Apache-2.0.txt) |
|
||||||
|
[LICENSE](https://github.com/santhosh-tekuri/jsonschema/blob/29cbed948d24a04700eb94436416b18a07953b71/LICENSE).
|
||||||
|
|
||||||
|
[Source](https://github.com/santhosh-tekuri/jsonschema/tree/29cbed948d24a04700eb94436416b18a07953b71).
|
||||||
|
|
||||||
|
## Go Text
|
||||||
|
|
||||||
|
Selected module: `golang.org/x/text` at `v0.14.0` (transitive dependency).
|
||||||
|
|
||||||
|
Copyright (c) 2009 The Go Authors. All rights reserved.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
License and notices: [BSD 3-Clause](licenses/BSD-3-Clause.txt) |
|
||||||
|
[LICENSE](https://github.com/golang/text/blob/6c97a165dd661335ff7bce6104a008558123c353/LICENSE) |
|
||||||
|
[PATENTS](https://github.com/golang/text/blob/6c97a165dd661335ff7bce6104a008558123c353/PATENTS).
|
||||||
|
|
||||||
|
[Source](https://github.com/golang/text/tree/6c97a165dd661335ff7bce6104a008558123c353).
|
||||||
|
|
||||||
|
## Go YAML v3
|
||||||
|
|
||||||
|
Selected module: `gopkg.in/yaml.v3` at `v3.0.1`.
|
||||||
|
|
||||||
|
Copyright (c) 2006-2010 Kirill Simonov
|
||||||
|
|
||||||
|
Copyright (c) 2006-2011 Kirill Simonov
|
||||||
|
|
||||||
|
Copyright (c) 2011-2019 Canonical Ltd
|
||||||
|
|
||||||
|
Copyright 2011-2016 Canonical Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: MIT AND Apache-2.0
|
||||||
|
|
||||||
|
License and notices: [MIT](licenses/MIT.txt) |
|
||||||
|
[Apache 2.0](licenses/Apache-2.0.txt) |
|
||||||
|
[LICENSE](https://github.com/go-yaml/yaml/blob/f6f7691b1fdeb513f56608cd2c32c51f8194bf51/LICENSE) |
|
||||||
|
[NOTICE](https://github.com/go-yaml/yaml/blob/f6f7691b1fdeb513f56608cd2c32c51f8194bf51/NOTICE).
|
||||||
|
|
||||||
|
[Source](https://github.com/go-yaml/yaml/tree/f6f7691b1fdeb513f56608cd2c32c51f8194bf51).
|
||||||
202
docs/oss/licenses/Apache-2.0.txt
Normal file
202
docs/oss/licenses/Apache-2.0.txt
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
11
docs/oss/licenses/BSD-3-Clause.txt
Normal file
11
docs/oss/licenses/BSD-3-Clause.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Copyright (c) <year> <owner>.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
170
docs/oss/licenses/CC-BY-SA-4.0.txt
Normal file
170
docs/oss/licenses/CC-BY-SA-4.0.txt
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
Creative Commons Attribution-ShareAlike 4.0 International
|
||||||
|
|
||||||
|
Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible.
|
||||||
|
|
||||||
|
Using Creative Commons Public Licenses
|
||||||
|
|
||||||
|
Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses.
|
||||||
|
|
||||||
|
Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. More considerations for licensors.
|
||||||
|
|
||||||
|
Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described.
|
||||||
|
|
||||||
|
Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public.
|
||||||
|
|
||||||
|
Creative Commons Attribution-ShareAlike 4.0 International Public License
|
||||||
|
|
||||||
|
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
|
||||||
|
|
||||||
|
Section 1 – Definitions.
|
||||||
|
|
||||||
|
a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
|
||||||
|
|
||||||
|
b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.
|
||||||
|
|
||||||
|
c. BY-SA Compatible License means a license listed at creativecommons.org/compatiblelicenses, approved by Creative Commons as essentially the equivalent of this Public License.
|
||||||
|
|
||||||
|
d. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
|
||||||
|
|
||||||
|
e. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
|
||||||
|
|
||||||
|
f. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
|
||||||
|
|
||||||
|
g. License Elements means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution and ShareAlike.
|
||||||
|
|
||||||
|
h. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
|
||||||
|
|
||||||
|
i. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
|
||||||
|
|
||||||
|
j. Licensor means the individual(s) or entity(ies) granting rights under this Public License.
|
||||||
|
|
||||||
|
k. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
|
||||||
|
|
||||||
|
l. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
|
||||||
|
|
||||||
|
m. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.
|
||||||
|
|
||||||
|
Section 2 – Scope.
|
||||||
|
|
||||||
|
a. License grant.
|
||||||
|
|
||||||
|
1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
|
||||||
|
|
||||||
|
A. reproduce and Share the Licensed Material, in whole or in part; and
|
||||||
|
|
||||||
|
B. produce, reproduce, and Share Adapted Material.
|
||||||
|
|
||||||
|
2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
|
||||||
|
|
||||||
|
3. Term. The term of this Public License is specified in Section 6(a).
|
||||||
|
|
||||||
|
4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.
|
||||||
|
|
||||||
|
5. Downstream recipients.
|
||||||
|
|
||||||
|
A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
|
||||||
|
|
||||||
|
B. Additional offer from the Licensor – Adapted Material. Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter’s License You apply.
|
||||||
|
|
||||||
|
C. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
|
||||||
|
|
||||||
|
6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
|
||||||
|
|
||||||
|
b. Other rights.
|
||||||
|
|
||||||
|
1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
|
||||||
|
|
||||||
|
2. Patent and trademark rights are not licensed under this Public License.
|
||||||
|
|
||||||
|
3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties.
|
||||||
|
|
||||||
|
Section 3 – License Conditions.
|
||||||
|
|
||||||
|
Your exercise of the Licensed Rights is expressly made subject to the following conditions.
|
||||||
|
|
||||||
|
a. Attribution.
|
||||||
|
|
||||||
|
1. If You Share the Licensed Material (including in modified form), You must:
|
||||||
|
|
||||||
|
A. retain the following if it is supplied by the Licensor with the Licensed Material:
|
||||||
|
|
||||||
|
i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);
|
||||||
|
|
||||||
|
ii. a copyright notice;
|
||||||
|
|
||||||
|
iii. a notice that refers to this Public License;
|
||||||
|
|
||||||
|
iv. a notice that refers to the disclaimer of warranties;
|
||||||
|
|
||||||
|
v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
|
||||||
|
|
||||||
|
B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
|
||||||
|
|
||||||
|
C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
|
||||||
|
|
||||||
|
2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
|
||||||
|
|
||||||
|
3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
|
||||||
|
|
||||||
|
b. ShareAlike.In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply.
|
||||||
|
|
||||||
|
1. The Adapter’s License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-SA Compatible License.
|
||||||
|
|
||||||
|
2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material.
|
||||||
|
|
||||||
|
3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply.
|
||||||
|
|
||||||
|
Section 4 – Sui Generis Database Rights.
|
||||||
|
|
||||||
|
Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
|
||||||
|
|
||||||
|
a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database;
|
||||||
|
|
||||||
|
b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b); and
|
||||||
|
|
||||||
|
c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
|
||||||
|
For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights.
|
||||||
|
|
||||||
|
Section 5 – Disclaimer of Warranties and Limitation of Liability.
|
||||||
|
|
||||||
|
a. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.
|
||||||
|
|
||||||
|
b. To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.
|
||||||
|
|
||||||
|
c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.
|
||||||
|
|
||||||
|
Section 6 – Term and Termination.
|
||||||
|
|
||||||
|
a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.
|
||||||
|
|
||||||
|
b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
|
||||||
|
|
||||||
|
1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
|
||||||
|
|
||||||
|
2. upon express reinstatement by the Licensor.
|
||||||
|
|
||||||
|
c. For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
|
||||||
|
|
||||||
|
d. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
|
||||||
|
|
||||||
|
e. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
|
||||||
|
|
||||||
|
Section 7 – Other Terms and Conditions.
|
||||||
|
|
||||||
|
a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.
|
||||||
|
|
||||||
|
b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.
|
||||||
|
|
||||||
|
Section 8 – Interpretation.
|
||||||
|
|
||||||
|
a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.
|
||||||
|
|
||||||
|
b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.
|
||||||
|
|
||||||
|
c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.
|
||||||
|
|
||||||
|
d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.
|
||||||
|
|
||||||
|
Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses.
|
||||||
|
|
||||||
|
Creative Commons may be contacted at creativecommons.org.
|
||||||
18
docs/oss/licenses/MIT.txt
Normal file
18
docs/oss/licenses/MIT.txt
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) <year> <copyright holders>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||||
|
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||||
|
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||||
|
following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||||
|
portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||||
|
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||||
|
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||||
|
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
96
docs/policy/agents.md
Normal file
96
docs/policy/agents.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Agent Policy
|
||||||
|
|
||||||
|
**Base Policy Version:** 1.3
|
||||||
|
|
||||||
|
**Last Updated**: 2026-09-27
|
||||||
|
|
||||||
|
**Upstream Source:** [agents.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/agents.md)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Agents must read and follow these rules.
|
||||||
|
|
||||||
|
## Implementation Discipline
|
||||||
|
|
||||||
|
**KEY PRINCIPLES:** Write the simplest idiomatic code that satisfies the user's
|
||||||
|
request. Do not implicitly strengthen requirements, restrictions, or guarantees
|
||||||
|
beyond what the user actually requested.
|
||||||
|
|
||||||
|
**Keep implementation machinery proportionate.** Use straightforward,
|
||||||
|
idiomatic code and introduce only the machinery needed to support the requested
|
||||||
|
behavior and flexibility. Optimize for conceptual simplicity and
|
||||||
|
maintainability, not for the fewest lines, fewest options, or narrowest product
|
||||||
|
surface.
|
||||||
|
|
||||||
|
**Do not invent restrictions.** Do not reject, prohibit, constrain, cap,
|
||||||
|
harden, or otherwise narrow behavior unless required by the user, an explicit
|
||||||
|
repository contract, or an unavoidable correctness constraint. Preserve
|
||||||
|
operator and caller choice where policy has not been established. If an
|
||||||
|
additional restriction appears advisable, present it as a decision rather than
|
||||||
|
silently implementing it.
|
||||||
|
|
||||||
|
**Do not invent guarantees.** Do not strengthen public contracts, guarantees,
|
||||||
|
or invariants beyond those expressly requested by the user.
|
||||||
|
|
||||||
|
**Use the standard library.** For example: in almost every case, the best
|
||||||
|
approach to copying files is to use the standard library. Writing custom code to
|
||||||
|
implement power-loss protection at the application level, or to guard against
|
||||||
|
bad actors with privileged and concurrent access to the host's filesystem,
|
||||||
|
is wasteful, unnecessary, and not idiomatic. Stick to the simple, standard, and
|
||||||
|
idiomatic approach unless another path is expressly requested by the user.
|
||||||
|
|
||||||
|
**An implementation detail is not repository policy.** Tests and documentation
|
||||||
|
must not elevate incidental behavior into a durable guarantee or restriction
|
||||||
|
unless that behavior was expressly selected as part of the contract.
|
||||||
|
|
||||||
|
## Promptkit Contract Authority
|
||||||
|
|
||||||
|
The following clarification preserves Promptkit's established distinction
|
||||||
|
between implementation choices and authorized contracts.
|
||||||
|
|
||||||
|
A correctness constraint must follow from the requested behavior or an
|
||||||
|
established invariant, not from the convenience of the chosen implementation.
|
||||||
|
Explain the specific failure it prevents and why a less restrictive
|
||||||
|
implementation cannot satisfy the contract.
|
||||||
|
|
||||||
|
Existing explicit contracts remain authoritative; changing or weakening one
|
||||||
|
requires a deliberate decision. Agent-authored plans, tests, documentation,
|
||||||
|
and prior implementations are not evidence that an additional restriction was
|
||||||
|
deliberately approved. Identify the authorizing requirement or decision; when
|
||||||
|
that authority is unclear, surface the ambiguity rather than treating repetition
|
||||||
|
as approval.
|
||||||
|
|
||||||
|
## Git Commit Policy
|
||||||
|
|
||||||
|
By default, commit each completed, nonempty unit of work using a
|
||||||
|
concise, plain-English message describing what changed. This policy is standing
|
||||||
|
authorization to create those local commits; a separate user request is not
|
||||||
|
required. An explicit user instruction to leave work uncommitted takes precedence.
|
||||||
|
|
||||||
|
A unit of work is a focused, reviewable outcome, including its relevant tests and
|
||||||
|
documentation.
|
||||||
|
Complete the appropriate validation before committing. If work is incomplete or
|
||||||
|
validation is blocked, report that state accurately.
|
||||||
|
|
||||||
|
Before committing:
|
||||||
|
|
||||||
|
1. Run `git status --short` and review the relevant diff, including any staged
|
||||||
|
changes. Identify changes that predate the current unit of work.
|
||||||
|
2. Exclude secrets, local environment files, generated junk, and unrelated changes.
|
||||||
|
3. Stage only the files or hunks belonging to the completed unit. Use `git add .`
|
||||||
|
only when every included change belongs to that unit. Preserve unrelated
|
||||||
|
staged and unstaged work.
|
||||||
|
4. Review `git diff --staged` and run appropriate checks for the change. Reuse
|
||||||
|
successful checks for unchanged content.
|
||||||
|
5. Review `git log --oneline -n 10` and use
|
||||||
|
`git commit -m "<concise plain-English commit message>"`.
|
||||||
|
6. Report the commit identifier and any remaining uncommitted work.
|
||||||
|
|
||||||
|
If the unit cannot be safely separated from pre-existing work, leave it uncommitted
|
||||||
|
and explain the overlap rather than silently including that work. If a commit
|
||||||
|
fails, preserve the changes and report the failure; do not bypass hooks.
|
||||||
|
|
||||||
|
Default commit authorization does not authorize pushing, publishing, amending,
|
||||||
|
resetting, reverting, or rewriting history. Those actions require a separate user
|
||||||
|
instruction. Do not use `git commit -a` or `git commit --no-verify` to bypass the
|
||||||
|
staging and validation discipline above.
|
||||||
@@ -1,11 +1,41 @@
|
|||||||
# Architecture Policy
|
# Architecture Policy
|
||||||
|
|
||||||
## Purpose
|
**Base Policy Version:** 1.3
|
||||||
|
|
||||||
This document defines Promptkit's current high-level architecture and the
|
**Last Updated**: 2026-09-27
|
||||||
durable boundaries that implementation changes must preserve. The
|
|
||||||
[internal component overview](../internal/overview.md) inventories concrete
|
**Upstream Source:** [architecture.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
|
||||||
implemented packages without redefining these rules.
|
|
||||||
|
## Purpose And Ownership
|
||||||
|
|
||||||
|
The adopted policy defines Promptkit's purpose and the architectural
|
||||||
|
constraints contributors must follow. It owns system boundaries, responsibility
|
||||||
|
and dependency rules, established invariants, and explicit non-goals.
|
||||||
|
|
||||||
|
Describe the concrete package inventory and implementation mechanics in internal
|
||||||
|
documentation. Link to canonical public contracts for their exact behavior and
|
||||||
|
to ADRs for decision rationale.
|
||||||
|
|
||||||
|
## Architectural Approach
|
||||||
|
|
||||||
|
Hexagonal architecture is strongly recommended, including encapsulating external
|
||||||
|
integrations and APIs within internal adapters. Keep domain and application
|
||||||
|
logic independent of external integration details, and make dependency direction
|
||||||
|
clear in the adopted policy.
|
||||||
|
|
||||||
|
Implement that separation with proportionate, idiomatic code. Choose interfaces
|
||||||
|
and package layout to serve the application's actual boundaries. Use adapters
|
||||||
|
for collaborators with external integration responsibilities.
|
||||||
|
|
||||||
|
## Adopted Constraints And Implemented Structure
|
||||||
|
|
||||||
|
Architectural constraints govern current and future development. Record
|
||||||
|
implemented structure accurately, proposals and implementation status in the
|
||||||
|
roadmap, and significant decisions and rationale in ADRs. Describe a target
|
||||||
|
structure as implemented when its changes land.
|
||||||
|
|
||||||
|
Constraints must reflect deliberately selected requirements and decisions.
|
||||||
|
Do not promote incidental implementation choices into architectural invariants.
|
||||||
|
|
||||||
## System Shape
|
## System Shape
|
||||||
|
|
||||||
@@ -17,53 +47,25 @@ The module root contains package `promptkit`, which is the public facade. It
|
|||||||
provides the supported engine, configuration and source options, requests,
|
provides the supported engine, configuration and source options, requests,
|
||||||
results, public values, extension interfaces, profiles, and error sentinels.
|
results, public values, extension interfaces, profiles, and error sentinels.
|
||||||
|
|
||||||
The implemented internal components consist of:
|
The [internal component overview](../internal/overview.md) owns the inventory
|
||||||
|
of implemented packages and their concrete responsibilities. This policy owns
|
||||||
|
the boundaries and dependency rules those components must preserve.
|
||||||
|
|
||||||
- `internal/domain`, which owns framework data values and source-neutral
|
The internal engine assembles one immutable backend registry, one capacity manager,
|
||||||
invariants shared by later internal components;
|
|
||||||
- `internal/backend`, which owns validated immutable OpenAI-compatible backend
|
|
||||||
definitions and the built-in OpenRouter definition;
|
|
||||||
- `internal/capacity`, which owns engine-local bounded run admission and
|
|
||||||
model-generation scheduling for limited backends;
|
|
||||||
- `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/jsonvalue`, which validates and defensively copies JSON-compatible
|
|
||||||
extra-parameter trees;
|
|
||||||
- `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` and `examples/go-library/run` packages are
|
|
||||||
maintained downstream consumers of the root facade. They do not expose library
|
|
||||||
packages or participate in internal assembly.
|
|
||||||
|
|
||||||
The root facade assembles one immutable backend registry, one capacity manager,
|
|
||||||
the internal repositories, renderer, validator, outbound client, and use-case
|
the internal repositories, renderer, validator, outbound client, and use-case
|
||||||
runner while translating public values and errors at the library boundary. The
|
runner. The registry contains validated maintained definitions plus engine-scoped
|
||||||
registry contains built-ins plus validated engine-scoped consumer additions.
|
consumer additions. The root facade translates public values and errors at the
|
||||||
The facade constructs the capacity manager from the registry's immutable
|
library boundary.
|
||||||
|
The internal engine constructs the capacity manager from the registry's immutable
|
||||||
policy snapshot, wraps the selected built-in or injected model client, and
|
policy snapshot, wraps the selected built-in or injected model client, and
|
||||||
supplies bounded admission to the runner. The defaults and renderer depend on
|
supplies bounded admission to the runner. The defaults and renderer depend on
|
||||||
the domain model. Prompt-definition and profile repositories use the domain
|
the domain model. Prompt-definition and profile repositories use the domain
|
||||||
model, file catalog, and YAML decoder. The built-in profile repository supplies
|
model, file catalog, and YAML decoder. The catalog adapter validates imported
|
||||||
an embedded `fs.FS` to the profile package. Artifact reading uses the domain
|
immutable maintained data, including its reserved namespace, before internal
|
||||||
model and application-neutral defaults. Validation uses the domain model, file
|
engine assembly supplies it to the profile package. Artifact reading uses the
|
||||||
catalog, and JSON Schema implementation. The model client uses the domain
|
domain model and application-neutral defaults. Validation uses the domain model,
|
||||||
model, application-neutral defaults, and an injected or standard-library HTTP
|
file catalog, and JSON Schema implementation. The model client uses the domain
|
||||||
|
model and application-neutral defaults, and an injected or standard-library HTTP
|
||||||
client. The use-case runner depends on the narrow interfaces owned by each
|
client. The use-case runner depends on the narrow interfaces owned by each
|
||||||
internal component, including backend lookup and run admission.
|
internal component, including backend lookup and run admission.
|
||||||
|
|
||||||
@@ -125,8 +127,10 @@ consumer contract requires a public package.
|
|||||||
Each package must have one cohesive responsibility and a clear dependency
|
Each package must have one cohesive responsibility and a clear dependency
|
||||||
direction. Internal packages must not expose their types merely to simplify
|
direction. Internal packages must not expose their types merely to simplify
|
||||||
wiring, and the public facade must not leak internal representations through
|
wiring, and the public facade must not leak internal representations through
|
||||||
exported signatures. New public packages require a durable consumer need that
|
exported signatures. Production packages under `internal/` must not import the
|
||||||
cannot be served cleanly by the root facade.
|
module root; the root facade may depend on internal implementation packages.
|
||||||
|
New public packages require a durable consumer need that cannot be served
|
||||||
|
cleanly by the root facade.
|
||||||
|
|
||||||
The [internal component overview](../internal/overview.md) must be updated as
|
The [internal component overview](../internal/overview.md) must be updated as
|
||||||
packages are implemented or their responsibilities change.
|
packages are implemented or their responsibilities change.
|
||||||
@@ -177,3 +181,21 @@ state.
|
|||||||
Do not list planned packages as implemented components. When implementation
|
Do not list planned packages as implemented components. When implementation
|
||||||
introduces a package, update the internal inventory and the owning contract or
|
introduces a package, update the internal inventory and the owning contract or
|
||||||
subsystem document in the same change.
|
subsystem document in the same change.
|
||||||
|
|
||||||
|
## Trust Boundaries And Threat Scope
|
||||||
|
|
||||||
|
Where useful, state the trust boundaries and threat scenarios relevant to the
|
||||||
|
application's adopted requirements, along with scenarios it explicitly excludes.
|
||||||
|
For example, distinguish handling untrusted application input from defending
|
||||||
|
against a privileged actor concurrently modifying the host filesystem.
|
||||||
|
|
||||||
|
Threat exclusions define the scope of the adopted requirements. Base defenses
|
||||||
|
and guarantees on those requirements and raise unsettled scope for a deliberate
|
||||||
|
decision under the [agent policy](agents.md).
|
||||||
|
|
||||||
|
Keep this discussion proportionate to the application. A separate threat
|
||||||
|
assessment or security document is optional when adopting this template.
|
||||||
|
For this library, the framework format and integration documents own credential
|
||||||
|
supply and external contracts; internal documents own implementation mechanisms.
|
||||||
|
Consumer-specific filesystem and security policy remains outside Promptkit's
|
||||||
|
boundary as described above.
|
||||||
|
|||||||
@@ -1,138 +1,139 @@
|
|||||||
# Documentation Policy
|
# Documentation Policy
|
||||||
|
|
||||||
|
**Base Policy Version:** 1.11
|
||||||
|
|
||||||
|
**Last Updated**: 2026-09-27
|
||||||
|
|
||||||
|
**Upstream Source:** [documentation.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/documentation.md)
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
This policy assigns each Promptkit documentation topic to one canonical owner.
|
This policy assigns each documentation topic to one canonical owner. Its goal is
|
||||||
Its goal is to keep documentation for this reusable Go library accurate,
|
to keep this library's documentation accurate, concise, discoverable, and resistant
|
||||||
concise, discoverable, and resistant to drift for consumers, contributors,
|
to drift for users, operators, developers, integrators, and LLM coding agents.
|
||||||
maintainers, integrators, and coding agents.
|
|
||||||
|
|
||||||
## Core Rules
|
## Core Rules
|
||||||
|
|
||||||
### One Canonical Owner
|
### One Canonical Owner
|
||||||
|
|
||||||
Each authoritative fact belongs in one document or source form. A non-owning
|
Each authoritative fact belongs in one document. A non-owning document may give
|
||||||
document may give a short, stable summary for orientation, but it must link to
|
a short, stable summary for orientation, but it must link to the canonical owner
|
||||||
the canonical owner instead of repeating exact contracts.
|
for volatile details.
|
||||||
|
|
||||||
Volatile details include exported declarations, accepted inputs, defaults,
|
Volatile details include commands, flags, configuration fields and defaults,
|
||||||
schemas, file names, paths, error identities, retry behavior, and runtime
|
module keys, schemas, file names, paths, status codes, retry behavior, and
|
||||||
guarantees. If readers could reasonably treat a statement as a contract,
|
runtime guarantees. If readers could reasonably treat a statement as a
|
||||||
maintain its exact definition only in the owning source.
|
contract, maintain it only in the owning document.
|
||||||
|
|
||||||
### Current State, Decisions, And Future Work
|
### Current And Future Behavior
|
||||||
|
|
||||||
Outside `docs/roadmap/`, documentation describes implemented behavior only.
|
Outside `docs/roadmap/`, documentation describes implemented behavior only.
|
||||||
Partial features may be described only to their implemented boundary.
|
Partial features may be described only to their implemented boundary.
|
||||||
|
|
||||||
An accepted architecture decision may describe an approved direction before it
|
Architectural constraints may govern future changes. Describe current structure
|
||||||
is implemented, but acceptance is not evidence that the behavior exists.
|
from implemented behavior.
|
||||||
Current-state documents change when the implementation lands. Temporary
|
|
||||||
roadmaps own future work, sequencing, and implementation status; they do not
|
ADRs may record an accepted architectural decision before implementation. The
|
||||||
replace durable policies or current contracts.
|
roadmap owns implementation status and sequencing until the decision is
|
||||||
|
implemented. Current architecture, user, operator, integration, and internal
|
||||||
|
documentation are updated when the behavior lands.
|
||||||
|
|
||||||
### Audience And Detail
|
### Audience And Detail
|
||||||
|
|
||||||
Write for the document's stated audience and include only the detail needed for
|
Write for the document's stated audience and include only the detail needed for
|
||||||
its owned topic. Consumer guidance should not expose incidental implementation
|
its owned topic. Keep user and operator docs focused on using and operating the
|
||||||
detail. Contributor documentation should link to public contracts and durable
|
application. Developer docs should link to user-facing and external contracts
|
||||||
policies instead of restating them.
|
for their definitions.
|
||||||
|
|
||||||
### Links
|
### Examples
|
||||||
|
|
||||||
Use descriptive link text and repository-relative links for repository
|
Complete copyable files belong in `examples/`. Documentation may use the
|
||||||
documents. Link to the canonical owner rather than to a duplicate summary.
|
smallest illustrative snippet needed to explain its owned topic, but should link
|
||||||
Check every added or changed link and repair or remove links when their target
|
to maintained examples for complete copyable files.
|
||||||
moves or is retired.
|
|
||||||
|
|
||||||
### Examples And Code Fences
|
Examples must be valid, secret-free, and tested where practical. Commands and
|
||||||
|
configuration used in documentation should match the application.
|
||||||
Complete copyable files belong in `examples/` when maintained examples exist.
|
|
||||||
Documentation may use the smallest illustrative snippet needed for its owned
|
|
||||||
topic, but should link to a maintained example instead of embedding a second
|
|
||||||
complete copy.
|
|
||||||
|
|
||||||
Examples must be valid, secret-free, and tested where practical. Commands,
|
|
||||||
imports, and Go snippets must match the implemented library. Use a language tag
|
|
||||||
on fenced code blocks and make clear when a fragment is illustrative rather
|
|
||||||
than directly runnable.
|
|
||||||
|
|
||||||
### Security And Privacy
|
### Security And Privacy
|
||||||
|
|
||||||
Documentation and examples must not contain real credentials, private keys,
|
Documentation and examples must not contain real credentials, private keys,
|
||||||
private environment dumps, sensitive source material, or private
|
private environment dumps, sensitive source material, or private infrastructure
|
||||||
infrastructure details unless intentionally public. Document secret-handling
|
details unless intentionally public. Document secret-handling mechanisms, not
|
||||||
mechanisms, not secret values.
|
secret values.
|
||||||
|
|
||||||
## Canonical Ownership
|
## Canonical Ownership
|
||||||
|
|
||||||
| Topic | Canonical owner | Owned content | Content owned elsewhere |
|
| Topic | Canonical owner | Owned content | Content owned elsewhere |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Project orientation | `README.md` | What Promptkit is, its current usability, module identity, license summary, and links onward. | Exact API contracts, contributor procedures, architecture detail, and release steps. |
|
| Product orientation and minimal end-to-end quickstart | `README.md` | Purpose, value, shortest successful invocation, and navigation, including licensing and attribution. | Complete command reference, configuration reference, operational procedures, implementation detail. |
|
||||||
| Contributor workflow | `docs/development.md` | Task-oriented reading guide, local workflow, validation commands, and repository hygiene. | Architecture rules, API semantics, subsystem behavior, and release procedure. |
|
| Contributor entry point | `docs/development.md` | Task-oriented reading guide, contributor orientation, baseline validation commands, and canonical links. | Package inventory, architecture rules, subsystem behavior, detailed change recipes. |
|
||||||
| Current architecture | `docs/policy/architecture.md` | System shape, normative ownership, dependency direction, package boundaries, invariants, and non-goals. | Concrete component inventory, implementation mechanics, contributor procedures, decision history, and future work. |
|
| Application architecture policy | `docs/policy/architecture.md` | System shape, normative ownership, dependency direction, boundaries, invariants, safety properties, and non-goals. | Concrete package inventory, implementation mechanics, contributor procedures, decision history, future work. |
|
||||||
| Documentation organization | `docs/policy/documentation.md` | Documentation ownership, audience boundaries, maintenance rules, and document lifecycle. | Library architecture or runtime behavior. |
|
| Policy-set provenance and upstream navigation | `docs/policy/meta.md` | Policy-set origin, local authority, adoption baselines, and upstream adoption and update links. | Task-specific reading triggers, detailed adoption procedures, and formal attribution notices. |
|
||||||
| 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. |
|
| Agent policy | `docs/policy/agents.md` | Proportionate, idiomatic implementation; rules against inventing requirements, restrictions, guarantees, or policy. | Application contracts, architecture, testing policy, and contributor procedures. |
|
||||||
| 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. |
|
| Documentation policy | `docs/policy/documentation.md` | Documentation ownership, audience boundaries, maintenance rules, and ADR/document lifecycle. | Application architecture or product behavior. |
|
||||||
|
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk sufficiency, boundaries, doubles, coverage, regressions, and test maintenance. | Subsystem behavior, application contracts, subsystem-specific test inventories, and implementation plans. |
|
||||||
|
| Release policy | `docs/policy/release.md` | Versioning, compatibility, release integrity and immutability, release documentation, proportionate gates, publication controls, completion, and required versus optional checks. | Repository-specific commands, product installation guidance, CLI version semantics, historical release summaries, and CI implementation detail. |
|
||||||
|
| Open source licensing and attribution policy | `docs/policy/oss.md` | License defaults and author choices, COPYRIGHT and attribution formats, license/notice links, discoverability, maintenance, and concrete exceptions. | Component entries, license texts, release commands, and dependency security review. |
|
||||||
|
| Project copyright and license grant | `COPYRIGHT` | Project identity, copyright holders and years, SPDX expression, grant, warranty disclaimer, and third-party statement. | Complete license texts and upstream component attribution. |
|
||||||
|
| Project license texts | `LICENSE` | Complete project and incorporated companion license texts, with required project-specific fields. | General licensing policy and upstream component attribution. |
|
||||||
|
| Upstream component attribution | `docs/oss/attribution.md` | House Format component entries: identity, material scope and modifications where applicable, copyright, license, required notices, and license, notice, and source links. | General attribution policy, exception reasoning, research narratives, compliance commentary, and complete standard license texts. |
|
||||||
|
| Standard dependency license texts | `docs/oss/licenses/` | Complete, verbatim standard license texts shared by applicable entries. | Project-specific upstream notices, attribution prose, and policy interpretation. |
|
||||||
|
| Repository-specific release procedure | `docs/release.md` | Maintainer prerequisites, candidate preparation, validation and publication commands and automated guards, asynchronous deliverables, delivery status, optional consumer verification, and recovery. | General release requirements, product installation guidance, historical release summaries, and current compatibility contracts. |
|
||||||
|
| Consumer guidance for the public Go API (if applicable) | `docs/consumers/` | Task-oriented public Go API usage, minimal examples, and consumer responsibilities. | HTTP wire semantics, external protocol contracts, internal implementation detail. |
|
||||||
|
| Integration contracts for external components (if applicable) | `docs/integrations/` | External formats and protocols, upstream and downstream contracts, logical output paths and schemas, media types, and compatibility. | Physical runtime placement and lifecycle, internal transformations, CLI syntax, configuration defaults. |
|
||||||
|
| Inventory of implemented internal packages | `docs/internal/overview.md` | Implemented packages and components, responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. |
|
||||||
|
| Behavior and architecture of internal packages | Other files under `docs/internal/` | Implementation flow, collaborators, state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. |
|
||||||
|
| Architectural decision records | `docs/adr/` | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, task sequencing. |
|
||||||
|
| Future work and implementation status | `docs/roadmap/` | Proposed, accepted, deferred, or rejected work; implementation status, sequencing, and task breakdowns. | Implemented behavior reference and architectural decision rationale. |
|
||||||
|
| Release-note history | `docs/releases/` | One checked-in summary per source release under the procedure; the note at its immutable tag is the release record. | Current commands, behavior, contracts, and compatibility definitions. |
|
||||||
|
| Complete copyable artifacts | `examples/` | Maintained configuration, inputs, and other copyable or runnable files. | Field-by-field reference, command reference, prose explanation. |
|
||||||
| 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. |
|
| 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. |
|
|
||||||
| 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. |
|
|
||||||
| Supplemental release guidance | None. `docs/releases/` may be used when a release benefits from a changelog or migration guide. | No canonical content. These files may briefly summarize release-specific changes, compatibility, and consumer migration paths, and may be corrected, consolidated, archived, or removed when no longer useful. | Public API and behavior contracts, formats, integrations, architecture, release procedure, and the authoritative annotated-tag release record. |
|
|
||||||
| 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. |
|
|
||||||
| 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. |
|
|
||||||
| 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
|
Create supporting documents for existing interfaces and responsibilities when
|
||||||
consumer, integration, release, subsystem, ADR, roadmap, or example document
|
substantive content is available.
|
||||||
only when the corresponding implemented interface, release, decision, planned
|
|
||||||
effort, or maintained artifact exists.
|
|
||||||
|
|
||||||
## Boundary Rules
|
## Boundary Rules
|
||||||
|
|
||||||
### Orientation
|
### Orientation
|
||||||
|
|
||||||
The README owns project orientation. The development guide routes
|
Link among orientation, architecture, and internal documents; maintain each
|
||||||
contributors. Architecture owns normative structure. The internal overview
|
package or behavior description in its canonical owner.
|
||||||
owns the current concrete component map. These documents may link to one
|
|
||||||
another but must not maintain parallel package or behavior descriptions.
|
|
||||||
|
|
||||||
### Public Contracts And Implementation
|
### Public Contracts And Implementation
|
||||||
|
|
||||||
Go declarations and GoDoc own exact exported API contracts once those APIs
|
Go declarations and GoDoc own exact exported Go API contracts. The
|
||||||
exist. Consumer and integration documents explain how to use those contracts
|
[framework format reference](../formats.md) owns prompt, profile, and schema
|
||||||
for a task. Internal documents explain how Promptkit implements them. Internal
|
formats; integration documents own external wire behavior. Consumer and
|
||||||
documentation may identify a public type or external format as a dependency,
|
internal documents link to those owners for exact definitions.
|
||||||
but must link to its canonical definition rather than restate it.
|
|
||||||
|
|
||||||
The [framework format reference](../formats.md) owns exact prompt, profile, and
|
### Releases
|
||||||
schema-file contracts. Integration documents own external wire formats.
|
|
||||||
|
|
||||||
### Supplemental Release Guidance
|
The release policy defines requirements and invariants that every release must
|
||||||
|
satisfy. The repository-specific release procedure defines the commands and
|
||||||
|
workflow maintainers use to satisfy that policy. Release notes record history
|
||||||
|
at immutable tags. Maintain current behavior in its canonical documentation,
|
||||||
|
update it with the corresponding behavior changes, and keep release procedures
|
||||||
|
consistent with adopted policy.
|
||||||
|
|
||||||
Files under `docs/releases/` may provide changelog-style summaries and
|
### Project Licensing And Upstream Attribution
|
||||||
migration guidance for a particular release. They are navigation and
|
|
||||||
orientation aids, not canonical owners of public APIs, behavior, formats,
|
|
||||||
integrations, architecture, release procedure, or other durable facts. When a
|
|
||||||
reader needs detail beyond a short release-specific note, the release document
|
|
||||||
must link to the applicable canonical documentation rather than reproduce its
|
|
||||||
contract.
|
|
||||||
|
|
||||||
The annotated tag message required by the
|
README and release documents link to the canonical licensing and attribution
|
||||||
[release procedure](../release.md#write-the-release-note) remains the
|
materials under the [OSS policy](oss.md). The release procedure owns URL
|
||||||
authoritative release record. Supplemental release documents may be corrected,
|
construction and publication mechanics.
|
||||||
consolidated, archived, or removed at any time when they are no longer useful,
|
|
||||||
provided maintained documentation does not depend on them and the annotated
|
### Contracts And Implementation
|
||||||
tag record remains intact.
|
|
||||||
|
Integration and API documents define externally observable shapes and
|
||||||
|
semantics. Internal documents explain how this application implements or consumes those
|
||||||
|
contracts. Internal docs may name a field, file, or protocol to identify a
|
||||||
|
dependency, but must link to its canonical contract for the definition.
|
||||||
|
|
||||||
### 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
|
||||||
library security boundaries and invariants. Public declarations and integration
|
library security boundaries. Public declarations and integration documents own
|
||||||
documents own consumer-visible security contracts. Internal documents own
|
consumer-visible contracts; internal documents own implementation mechanisms.
|
||||||
implementation mechanisms only.
|
|
||||||
|
|
||||||
## Architecture Decision Records
|
## Architecture Decision Records
|
||||||
|
|
||||||
@@ -147,48 +148,47 @@ Use sequentially numbered ADR filenames such as
|
|||||||
6. alternatives considered;
|
6. alternatives considered;
|
||||||
7. consequences.
|
7. consequences.
|
||||||
|
|
||||||
Use one of these statuses:
|
Treat the decision content of an accepted ADR as immutable. When a decision
|
||||||
|
changes, create a new ADR and update the earlier ADR's status to superseded.
|
||||||
|
Rejected architectural alternatives belong in the ADR; rejected product ideas
|
||||||
|
belong in the roadmap.
|
||||||
|
|
||||||
- **Proposed:** the decision is under consideration and may change;
|
## Maintenance
|
||||||
- **Accepted:** the decision is approved, whether or not implementation is
|
|
||||||
complete;
|
|
||||||
- **Rejected:** the proposed decision was considered and not adopted;
|
|
||||||
- **Superseded:** a later accepted ADR replaces the accepted decision.
|
|
||||||
|
|
||||||
A proposed ADR transitions to Accepted or Rejected. An Accepted ADR transitions
|
When behavior changes, update its canonical owner in the same change. If
|
||||||
to Superseded only when a later Accepted ADR replaces it. An ADR may be created
|
ownership moves, remove the old definition and replace it with a link where
|
||||||
as Accepted when the decision has already been made.
|
navigation remains useful.
|
||||||
|
|
||||||
Treat the decision content of an Accepted ADR as immutable. A changed decision
|
Before completing documentation work:
|
||||||
requires a later ADR rather than a rewrite of the accepted record. A Superseded
|
|
||||||
ADR must link to its replacement, and the replacement must link back. Rejected
|
- verify affected behavior and examples;
|
||||||
architectural alternatives belong in the ADR; rejected feature ideas belong in
|
- check commands, flags, fields, defaults, schemas, and paths against their
|
||||||
a roadmap when they need to be retained.
|
implementation;
|
||||||
|
- keep unimplemented behavior in the roadmap, subject to the ADR exception;
|
||||||
|
- remove stale references and validate links;
|
||||||
|
- confirm that non-owning documents summarize and link rather than redefine;
|
||||||
|
- confirm that no secrets or sensitive private data were added.
|
||||||
|
|
||||||
|
## Promptkit Documentation Scope
|
||||||
|
|
||||||
|
Promptkit is a Go library, so exported GoDoc and framework formats replace
|
||||||
|
application CLI, configuration, operations, and HTTP-service owners. Reading
|
||||||
|
triggers belong in the development guide, not this topic map.
|
||||||
|
|
||||||
## Document Lifecycle
|
## Document Lifecycle
|
||||||
|
|
||||||
Create durable current-state documentation with the implementation it
|
|
||||||
describes. Update its canonical owner in the same change when behavior changes.
|
|
||||||
If ownership moves, remove the old definition and leave a link where navigation
|
|
||||||
remains useful.
|
|
||||||
|
|
||||||
Roadmaps are temporary coordination documents. When their work is complete,
|
Roadmaps are temporary coordination documents. When their work is complete,
|
||||||
record completion, move any still-useful decisions or contracts to their
|
record completion, move any still-useful decisions or contracts to their
|
||||||
durable owners, update incoming links, and archive or remove the roadmap
|
durable owners, update incoming links, and archive or remove the roadmap
|
||||||
according to repository practice. Do not preserve completed roadmaps as a
|
according to repository practice. Do not preserve completed roadmaps as a
|
||||||
second current-state reference.
|
second current-state reference.
|
||||||
|
|
||||||
Supplemental release documents may likewise be removed without preserving a
|
Retain release notes as historical records under the release policy. Do not
|
||||||
replacement. Before removal, update maintained incoming links so current
|
apply temporary-roadmap retirement rules to published release notes.
|
||||||
documentation does not depend on an optional historical guide.
|
|
||||||
|
|
||||||
Before completing documentation work:
|
For Promptkit ADRs, retain the existing Proposed, Accepted, Rejected, and
|
||||||
|
Superseded statuses. Proposed records may become Accepted or Rejected;
|
||||||
- verify affected behavior and examples;
|
Accepted records may become Superseded, with links between the earlier record
|
||||||
- check commands, imports, declarations, defaults, schemas, and paths against
|
and its replacement. Records may be created as Accepted when the decision has
|
||||||
their implementation;
|
already been made. These lifecycle rules preserve existing local document
|
||||||
- keep unimplemented behavior in a roadmap, subject to the ADR exception;
|
semantics.
|
||||||
- validate links and fenced examples;
|
|
||||||
- confirm non-owning documents summarize and link rather than redefine;
|
|
||||||
- remove stale or unsupported claims; and
|
|
||||||
- confirm that no secrets or sensitive private data were added.
|
|
||||||
|
|||||||
27
docs/policy/meta.md
Normal file
27
docs/policy/meta.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Policy Set Provenance
|
||||||
|
|
||||||
|
**Base Policy Version:** 1.0
|
||||||
|
|
||||||
|
**Last Updated**: 2026-09-27
|
||||||
|
|
||||||
|
**Upstream Source:** [meta.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/meta.md)
|
||||||
|
|
||||||
|
## Origin And Local Authority
|
||||||
|
|
||||||
|
This repository's policy set is adapted from
|
||||||
|
[Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy).
|
||||||
|
The adopted local policies govern work in this repository. Upstream revisions
|
||||||
|
become local policy through deliberate adoption, preserving local decisions.
|
||||||
|
Each policy's `Upstream Source` identifies its adopted baseline.
|
||||||
|
|
||||||
|
## Adoption And Updates
|
||||||
|
|
||||||
|
For policy adoption, upstream revision reviews, and updates, consult the
|
||||||
|
[upstream adoption and update guide](https://gitea.maximumdirect.net/eric/policy/src/branch/main/docs/adoption.md).
|
||||||
|
That guide and the reference example collection are maintained upstream and
|
||||||
|
accessed through links. Keep discovery links pointed at the current upstream
|
||||||
|
project and guide; keep adopted-source and example links pinned to commits.
|
||||||
|
|
||||||
|
The local [developer guide](../development.md) routes tasks to applicable
|
||||||
|
policies. Formal credit and licensing notices for the copied policy material
|
||||||
|
belong in the local [attribution document](../oss/attribution.md).
|
||||||
227
docs/policy/oss.md
Normal file
227
docs/policy/oss.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# Open Source Licensing And Attribution Policy
|
||||||
|
|
||||||
|
**Base Policy Version:** 1.8
|
||||||
|
|
||||||
|
**Last Updated**: 2026-09-27
|
||||||
|
|
||||||
|
**Upstream Source:** [oss.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/oss.md)
|
||||||
|
|
||||||
|
## Purpose And Scope
|
||||||
|
|
||||||
|
This policy defines project licensing conventions and a repeatable House Format
|
||||||
|
for upstream attribution and license notices. It covers the project's own code
|
||||||
|
and upstream components incorporated into or distributed with the application
|
||||||
|
or library, including relevant transitive dependencies and copied code,
|
||||||
|
documentation, or assets.
|
||||||
|
Determine treatment from the component's terms and actual use.
|
||||||
|
|
||||||
|
Consulting development guidance or using tooling alone does not require an
|
||||||
|
attribution entry. Copying or adapting documentation for inclusion in the
|
||||||
|
repository is different: preserve its applicable attribution and license terms.
|
||||||
|
A material's role as development guidance does not exempt copied documents.
|
||||||
|
Apply any express permissions for attribution-free reuse of example formatting.
|
||||||
|
|
||||||
|
The baseline is a publicly available source repository with releases published
|
||||||
|
alongside it. The format is independent of language, forge, and build system.
|
||||||
|
It does not require attribution for every development tool, establish license
|
||||||
|
compatibility, or resolve every obligation arising from use or distribution.
|
||||||
|
|
||||||
|
## Canonical Locations
|
||||||
|
|
||||||
|
The [documentation policy](documentation.md) assigns ownership:
|
||||||
|
|
||||||
|
- `COPYRIGHT`: project name, copyright statements, SPDX expression, grant,
|
||||||
|
warranty disclaimer, and third-party statement.
|
||||||
|
- `LICENSE` contains the complete, unmodified project license text, including
|
||||||
|
incorporated companion text. Dependency attribution does not relicense the project.
|
||||||
|
- `docs/oss/attribution.md`: canonical component entries.
|
||||||
|
- `docs/oss/licenses/`: complete, verbatim standard and applicable companion texts.
|
||||||
|
|
||||||
|
Use recognizable license filenames, such as `Apache-2.0.txt` or `LGPL-3.0.txt`.
|
||||||
|
A shared standard text may serve multiple entries. Keep explanations outside
|
||||||
|
license texts and preserve distinct versions and exceptions.
|
||||||
|
|
||||||
|
## Upstream Reference Examples
|
||||||
|
|
||||||
|
Use the commit-pinned upstream examples to create actual project notices.
|
||||||
|
Retain their links and keep the example collection upstream; keep applicable
|
||||||
|
license texts locally as specified above.
|
||||||
|
|
||||||
|
## Project Licensing
|
||||||
|
|
||||||
|
Unless the author specifies otherwise, use **GPL version 3 or later**
|
||||||
|
(`GPL-3.0-or-later`) for applications and **LGPL version 3 or later**
|
||||||
|
(`LGPL-3.0-or-later`) for libraries. An author's explicit choice takes precedence:
|
||||||
|
for example, a one-off script may use MIT. Such a choice requires no exception
|
||||||
|
rationale, additional approval, or compensating controls under this policy.
|
||||||
|
|
||||||
|
These defaults guide new license selection. Adopting this policy does not
|
||||||
|
relicense existing code or override third-party terms. Preserve an existing
|
||||||
|
license unless the author deliberately changes it. For mixed-purpose projects,
|
||||||
|
follow the author's selected scope rather than inferring different licenses for
|
||||||
|
individual packages.
|
||||||
|
|
||||||
|
Use the upstream [application COPYRIGHT example](https://gitea.maximumdirect.net/eric/policy/src/commit/a469273adda336625f96b9d769ea27b33999f146/examples/licensing/application/COPYRIGHT)
|
||||||
|
or [library COPYRIGHT example](https://gitea.maximumdirect.net/eric/policy/src/commit/a469273adda336625f96b9d769ea27b33999f146/examples/licensing/library/COPYRIGHT)
|
||||||
|
to create the project’s root `COPYRIGHT` file.
|
||||||
|
Use verified project names, contribution years, and copyright holders.
|
||||||
|
For another selected license, adapt the grant, SPDX expression, disclaimer, and
|
||||||
|
text references together to reflect the author's selected grant.
|
||||||
|
|
||||||
|
For GPLv3 projects, copy the complete GPLv3 text into `LICENSE`. For LGPLv3
|
||||||
|
projects, place the complete LGPLv3 text followed by the complete GPLv3 text in
|
||||||
|
`LICENSE`; the LGPL incorporates the GPL terms. Keep the texts unmodified and
|
||||||
|
place project-specific statements in `COPYRIGHT`. For the default grants, the
|
||||||
|
grant and SPDX expression in `COPYRIGHT` must expressly identify `or-later`;
|
||||||
|
follow the author’s choice if a different grant is selected.
|
||||||
|
|
||||||
|
For other licenses, provide their complete text and any required project-specific
|
||||||
|
fields; the standard reference collection remains unchanged. Keep `COPYRIGHT`,
|
||||||
|
`LICENSE`, SPDX expressions, and README descriptions consistent with the actual
|
||||||
|
grant. This convention introduces no blanket requirement for per-file headers;
|
||||||
|
preserve notices or add them where the applicable terms require them.
|
||||||
|
|
||||||
|
Identify separately licensed documentation or assets in the project grant, with
|
||||||
|
links to their attribution and license information. An application's default
|
||||||
|
code license does not override the terms of incorporated documentation.
|
||||||
|
|
||||||
|
## Upstream Attribution House Format
|
||||||
|
|
||||||
|
Begin `docs/oss/attribution.md` with `# Open Source Attribution Notices`,
|
||||||
|
followed directly by component entries. These comprise the complete document.
|
||||||
|
|
||||||
|
Each entry presents these five fields in order, with blank lines between them:
|
||||||
|
|
||||||
|
1. **Project name** as a level-two heading. An optional identification paragraph
|
||||||
|
may follow, such as `Selected module: MODULE at VERSION.` with the module and
|
||||||
|
version in backticks. For copied documentation, a concise `Material:` sentence
|
||||||
|
may identify the affected files and whether they were adapted or copied
|
||||||
|
without modification. A factual qualifier such as `(transitive dependency)`
|
||||||
|
is permitted. Limit optional identification prose to factual component and
|
||||||
|
material scope.
|
||||||
|
2. **Upstream copyright statement or statements**, preserving their wording.
|
||||||
|
Put multiple statements in separate paragraphs. Include this field when the
|
||||||
|
selected upstream source supplies an applicable statement; otherwise, omit it.
|
||||||
|
3. **SPDX license identifier or expression**, when applicable, using
|
||||||
|
`SPDX-License-Identifier: EXPRESSION`.
|
||||||
|
4. **License and notice links**, beginning `License and notices:`, separated
|
||||||
|
by ` | `, and ending with a period. Label local standard texts by license
|
||||||
|
name, such as `MIT` or `Apache 2.0`. Label upstream files by their actual
|
||||||
|
filenames, such as `COPYRIGHT`, `LICENSE`, `NOTICE`, or `PATENTS`.
|
||||||
|
5. **Source repository link**, rendered exactly as:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
[Source](COMMIT_URL).
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the upstream [attribution example](https://gitea.maximumdirect.net/eric/policy/src/commit/a469273adda336625f96b9d769ea27b33999f146/examples/licensing/attribution.md)
|
||||||
|
to create the project’s `docs/oss/attribution.md`.
|
||||||
|
The example illustrates omitted fields and multiple copyrights and licenses.
|
||||||
|
Replace the uppercase placeholders and illustrative URLs with actual component
|
||||||
|
information. Local license links are relative to the destination
|
||||||
|
`docs/oss/attribution.md`; copy the applicable texts into its `licenses/`
|
||||||
|
directory. Do not copy example entries for components the project does not use.
|
||||||
|
|
||||||
|
These are five fields, not a line limit. Omit the SPDX field when no applicable
|
||||||
|
identifier exists; identify nonstandard terms through the linked text. Preserve
|
||||||
|
licensing alternatives and exceptions, and record a selected option when
|
||||||
|
relevant through the factual license identification. Do not invent an identifier
|
||||||
|
or silently resolve an unsettled licensing choice.
|
||||||
|
|
||||||
|
Keep entries factual. Do not add agent-authored explanations of license
|
||||||
|
expressions, file-level license scope, research performed, retained source
|
||||||
|
notices, or compliance conclusions. Avoid filler such as "none is invented
|
||||||
|
here" and embellished link labels such as "upstream LICENSE", "additional
|
||||||
|
patent grant", or "at the imported commit". Preserve actual required upstream
|
||||||
|
content; this rule excludes editorial additions, not required notices. Policy
|
||||||
|
interpretation and exception reasoning belong in the adopted OSS policy or an
|
||||||
|
appropriate decision record, not the attribution document.
|
||||||
|
|
||||||
|
## License Texts And Upstream Links
|
||||||
|
|
||||||
|
Link standard license texts to their local copies. Link project-specific
|
||||||
|
notices to their upstream copies. Every upstream repository or notice link must
|
||||||
|
identify the exact commit corresponding to the
|
||||||
|
imported version, including the selected source revision for copied material.
|
||||||
|
|
||||||
|
Classify material according to its contents. An upstream `LICENSE`,
|
||||||
|
`COPYING`, or similar file may contain project-specific notices or exceptions
|
||||||
|
in addition to standard wording. Link both the local standard text and the
|
||||||
|
commit-specific upstream file when needed. Make companion license texts directly
|
||||||
|
reachable from the entry.
|
||||||
|
|
||||||
|
For project-specific license and attribution material, this policy accepts
|
||||||
|
direct links to the applicable upstream texts as satisfying reproduction,
|
||||||
|
inclusion, or copy requirements. This is the adopted house interpretation.
|
||||||
|
Agents must apply it without adding local-copy requirements or seeking repeated
|
||||||
|
approval solely because a license uses those terms.
|
||||||
|
|
||||||
|
This interpretation concerns notice reproduction. Concrete obligations such as
|
||||||
|
marking modified files or supplying corresponding source receive their own
|
||||||
|
applicable treatment. Preserve required notices already present in incorporated
|
||||||
|
source files; the central attribution entry does not authorize stripping them.
|
||||||
|
|
||||||
|
## Discoverability
|
||||||
|
|
||||||
|
End the project's README with two friendly statements linking to its actual
|
||||||
|
grant in `COPYRIGHT` and the attribution document. For example, for a project
|
||||||
|
with the stated license:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
PROJECT_NAME is licensed under the [LGPL v3.0 or later](COPYRIGHT).
|
||||||
|
|
||||||
|
[Open Source attribution notices](docs/oss/attribution.md) are available.
|
||||||
|
```
|
||||||
|
|
||||||
|
Each new checked-in release document and its hosted download page must end
|
||||||
|
with links whose visible text is `COPYRIGHT | LICENSE | ATTRIBUTION`. Use absolute
|
||||||
|
URLs to those files at the release's immutable tag or corresponding immutable source
|
||||||
|
revision so the links work in both locations. This release navigation is distinct
|
||||||
|
from upstream component links, which must use the imported commit.
|
||||||
|
|
||||||
|
Reuse the checked-in release document as the hosted release body where supported.
|
||||||
|
The release procedure owns concrete URLs and publication mechanics for delivering
|
||||||
|
the links through the selected channel. Do not maintain duplicate notice lists.
|
||||||
|
These conventions do not by themselves require separate downloadable license
|
||||||
|
assets, source bundles, or a different binary package format.
|
||||||
|
|
||||||
|
## Maintenance And Exceptions
|
||||||
|
|
||||||
|
Update project licensing statements together when the author changes the grant
|
||||||
|
or its scope. Update upstream notices in the change that adds, removes, upgrades,
|
||||||
|
copies, or changes the distribution of a relevant component. Check the selected source's license
|
||||||
|
and notices rather than assuming they are unchanged. Update affected entries,
|
||||||
|
commit links, and texts; retain historical versions through Git.
|
||||||
|
|
||||||
|
Check affected statements, faithful standard-text copies, and link destinations
|
||||||
|
as part of ordinary change review. For a future release tag, check intended file
|
||||||
|
paths against the candidate source; the tag need not resolve remotely before
|
||||||
|
publication. Do not add publication-time network checks, CI polling, recurring
|
||||||
|
legal sign-offs, mandatory scanners, or a full software bill of materials merely
|
||||||
|
to implement this policy.
|
||||||
|
|
||||||
|
Use the House Format routinely. Raise an exception only for a concrete obligation
|
||||||
|
or unresolved rights question it does not address, including any arising from a
|
||||||
|
particular distribution channel. Record the issue and selected treatment in the
|
||||||
|
adopted OSS policy or an appropriate decision record. Attribution entries
|
||||||
|
contain the resulting factual identification and required material, without
|
||||||
|
repeating that reasoning. Resolve the issue before the affected use or
|
||||||
|
distribution without reopening settled choices.
|
||||||
|
Common ownership of projects is neither required nor a blanket exception.
|
||||||
|
|
||||||
|
Apply the formatting convention prospectively. Do not rewrite published tags,
|
||||||
|
notes, binaries, or checksums to retrofit it. Address actual omissions deliberately
|
||||||
|
under the applicable terms and the [release correction policy](release.md#immutability-and-failure-recovery).
|
||||||
|
Ordinary attribution maintenance and release links are preparation work, not new
|
||||||
|
release approval gates.
|
||||||
|
|
||||||
|
## Promptkit Adoption
|
||||||
|
|
||||||
|
Promptkit retains its LGPL-3.0-or-later library grant in
|
||||||
|
[COPYRIGHT](../../COPYRIGHT). Adapted policy documentation has the separate
|
||||||
|
scope identified there and in [attribution](../oss/attribution.md).
|
||||||
|
|
||||||
|
Promptkit publishes source-only Go module tags on Gitea, with no binaries or
|
||||||
|
separate hosted download page. The [release procedure](../release.md) owns
|
||||||
|
publication and concrete footer URLs. The hosted-page guidance applies if such
|
||||||
|
a channel is deliberately introduced; it does not require creating one.
|
||||||
252
docs/policy/release.md
Normal file
252
docs/policy/release.md
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
# Release Policy
|
||||||
|
|
||||||
|
**Base Policy Version:** 1.4
|
||||||
|
|
||||||
|
**Last Updated**: 2026-09-27
|
||||||
|
|
||||||
|
**Upstream Source:** [release.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/release.md)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This policy defines a proportionate workflow for validating and publishing an
|
||||||
|
intended source revision under a fresh version. Releases should require little
|
||||||
|
routine intervention, avoid repeated work, and preserve published contents.
|
||||||
|
|
||||||
|
The repository-specific commands belong in `docs/release.md`. Historical release
|
||||||
|
summaries belong in `docs/releases/`. The procedure must agree with the adopted
|
||||||
|
policy and link to it rather than introduce additional release requirements.
|
||||||
|
|
||||||
|
## Release Model And Completion
|
||||||
|
|
||||||
|
A source release identifies one exact committed source revision through an
|
||||||
|
immutable version tag and a version-matched, checked-in release note. The
|
||||||
|
primary release branch is `main` unless the adopted policy specifies otherwise.
|
||||||
|
The procedure must name the authoritative publication remote.
|
||||||
|
|
||||||
|
A successful push of the intended tag, with its source commit available on the
|
||||||
|
authoritative primary branch, completes the release. The source commit may be
|
||||||
|
pushed before the tag or published together with it through an atomic push.
|
||||||
|
The procedure must choose and document the supported approach.
|
||||||
|
|
||||||
|
Release commands must finish at successful publication. They must not wait for
|
||||||
|
or poll CI, require successful CI status, inspect downloadable artifacts, or
|
||||||
|
check consumer installation or Go module availability as completion gates.
|
||||||
|
A failed or ambiguous push requires resolving the authoritative remote state
|
||||||
|
before declaring success or retrying; successful pushes do not require a
|
||||||
|
separate remote read-back gate.
|
||||||
|
|
||||||
|
Repositories may publish source only. Additional binaries, images, packages,
|
||||||
|
checksums, or other declared deliverables may be published asynchronously from
|
||||||
|
the tagged source. The procedure must identify those deliverables and the
|
||||||
|
responsibility of their automation. Published deliverables must be traceable
|
||||||
|
to that source and identify the same version.
|
||||||
|
|
||||||
|
Track source-release completion and artifact delivery separately. Report delivery
|
||||||
|
failures and optional investigations under artifact-delivery status.
|
||||||
|
|
||||||
|
## Versioning And Compatibility
|
||||||
|
|
||||||
|
Stable releases use Semantic Versioning in the form `vMAJOR.MINOR.PATCH`, with
|
||||||
|
no leading zeroes. Prereleases require an explicit adopted policy defining
|
||||||
|
their format and use.
|
||||||
|
|
||||||
|
The selected version must be unused locally and on the authoritative remote.
|
||||||
|
Do not require queries to every configured remote or publication service.
|
||||||
|
A version known to have already been published must not be reused, even if the
|
||||||
|
normal guards do not inspect that channel. Adding a distribution channel
|
||||||
|
requires a deliberate update to the policy and procedure where needed.
|
||||||
|
|
||||||
|
For releases at or after `v1.0.0`:
|
||||||
|
|
||||||
|
- a major release may make intentional incompatible changes;
|
||||||
|
- a minor release may add backward-compatible behavior; and
|
||||||
|
- a patch release may make backward-compatible fixes.
|
||||||
|
|
||||||
|
Before `v1.0.0`, a minor release may change a documented public, operational,
|
||||||
|
configuration, data, integration, or compatibility contract when the release
|
||||||
|
note explains the impact and required consumer action. A patch release must
|
||||||
|
not intentionally break those contracts within its minor line.
|
||||||
|
|
||||||
|
Additional compatibility promises and deliberate exceptions belong in the
|
||||||
|
adopted policy or canonical contract documentation. Release notes explain
|
||||||
|
changes; they do not redefine current compatibility contracts.
|
||||||
|
|
||||||
|
## Release Documentation
|
||||||
|
|
||||||
|
Every new release must include `docs/releases/<version>.md` in the tagged
|
||||||
|
source tree. Use the complete version tag as the filename and begin with a
|
||||||
|
title containing the product name and that exact version.
|
||||||
|
|
||||||
|
Include a short summary and any relevant compatibility impact or upgrade
|
||||||
|
instructions. Use the Promptkit section structure below and keep the note
|
||||||
|
proportionate to the change.
|
||||||
|
|
||||||
|
Keep exact current contracts in their canonical documents and update affected
|
||||||
|
documentation with the behavior change. Link to those documents from the note
|
||||||
|
where useful. If annotated tags are used, derive their summary from the
|
||||||
|
checked-in note.
|
||||||
|
|
||||||
|
Include the release footer defined by the [OSS policy](oss.md#discoverability)
|
||||||
|
in each new release document and preserve it on the hosted download page. The
|
||||||
|
release procedure defines the concrete URLs and publication mechanics. Prepare
|
||||||
|
these links with the release note; they do not add a network check or approval
|
||||||
|
gate.
|
||||||
|
|
||||||
|
Handle version selection, release-note content, and retirement of completed
|
||||||
|
roadmaps during preparation. Preserve planned and deferred work and repair
|
||||||
|
affected links when retiring roadmaps. Reuse ordinary change review for
|
||||||
|
unintended files, credentials, and private data. Scope release-content review
|
||||||
|
to the tagged source.
|
||||||
|
|
||||||
|
## Candidate Validation
|
||||||
|
|
||||||
|
Provide one publication command that validates the final committed candidate,
|
||||||
|
checks publication state, and publishes the intended refs. A validation-only
|
||||||
|
mode or command may be provided for diagnosis, but must not be a required step
|
||||||
|
before publication repeats the same checks.
|
||||||
|
|
||||||
|
Run the complete deterministic, offline default test suite once for the final
|
||||||
|
candidate, following the [testing policy](testing.md). For repositories that
|
||||||
|
publish builds, verify the required build targets. Select those targets for
|
||||||
|
the application's actual support commitments; native testing on each platform
|
||||||
|
and access to particular hardware are not default release prerequisites.
|
||||||
|
|
||||||
|
Additional gates must address a concrete risk that the existing checks do not
|
||||||
|
adequately cover. Do not automatically require linting, race testing, module
|
||||||
|
hygiene audits, documentation audits, credential scans, example validation, or
|
||||||
|
expanded platform matrices solely because they are possible. Retain such
|
||||||
|
checks when their distinct protection justifies their cost. A one-time migration
|
||||||
|
check must not silently become a permanent release requirement.
|
||||||
|
|
||||||
|
Release tests must not require real credentials, mutable application services,
|
||||||
|
paid APIs, provider probes, or inference calls. The procedure may describe dependency
|
||||||
|
preparation separately from offline validation.
|
||||||
|
|
||||||
|
Avoid duplicate validation across release phases. Successful checks for the
|
||||||
|
same final candidate need not be repeated solely to satisfy another phase;
|
||||||
|
any reuse must clearly apply to that exact candidate. Persistent validation
|
||||||
|
caches are not required. If the candidate changes, validate the new candidate.
|
||||||
|
Cheap publication-state checks may be repeated immediately before pushing
|
||||||
|
because local or remote state may have changed during validation.
|
||||||
|
|
||||||
|
Automate mechanical guards. Human review gates are strongly disfavored: add one
|
||||||
|
only through an explicit project-specific policy decision explaining the
|
||||||
|
concrete risk and why ordinary change review or automation is insufficient.
|
||||||
|
Do not add routine confirmations for checks the command can perform itself.
|
||||||
|
|
||||||
|
## Publication Guards
|
||||||
|
|
||||||
|
Automate the checks needed to publish the intended source under a fresh version:
|
||||||
|
|
||||||
|
- the version has the adopted format and is unused locally and on the
|
||||||
|
authoritative remote;
|
||||||
|
- the worktree and index are clean on the primary release branch;
|
||||||
|
- the source being published is the exact candidate that passed validation,
|
||||||
|
including its matching release note;
|
||||||
|
- the remote primary branch already identifies that candidate, or an atomic
|
||||||
|
source/tag push will advance it to that candidate without discarding remote
|
||||||
|
history; and
|
||||||
|
- the tag identifies the validated candidate and only the intended refs are
|
||||||
|
published.
|
||||||
|
|
||||||
|
Fetch the authoritative branch and tag state as needed for these checks.
|
||||||
|
Do not force branch or tag updates to bypass a failed guard. Publish explicit
|
||||||
|
refs rather than all local tags. Do not require CI success, publication-service
|
||||||
|
availability checks, or a separate manual confirmation for each guard.
|
||||||
|
|
||||||
|
## Immutability And Failure Recovery
|
||||||
|
|
||||||
|
Published version tags and published contents are immutable. Do not move,
|
||||||
|
overwrite, recreate, or delete a published tag, or replace a published artifact,
|
||||||
|
checksum, signature, provenance statement, or release note under that version.
|
||||||
|
The checked-in note at the immutable tag is that release's historical record.
|
||||||
|
|
||||||
|
If validation fails before publication, correct and commit the candidate and
|
||||||
|
rerun the publication command. If a push fails or its outcome is ambiguous,
|
||||||
|
inspect the authoritative remote before retrying. Remove an unpublished local
|
||||||
|
tag only after confirming it was never published or used by a release channel.
|
||||||
|
An unavailable remote leaves publication status unknown; do not overwrite a tag
|
||||||
|
to resolve uncertainty.
|
||||||
|
|
||||||
|
Transient network, authentication, CI, or consumer-availability failures do not
|
||||||
|
by themselves require a new version. Asynchronous delivery may be retried from
|
||||||
|
the same immutable tag. Missing artifacts may be published later, provided
|
||||||
|
existing published contents are not replaced and the additions remain consistent
|
||||||
|
with the tagged source and any already-published checksum manifest. If that
|
||||||
|
cannot be achieved, publish a new version.
|
||||||
|
|
||||||
|
A defect requiring changes to published source or contents requires a corrected
|
||||||
|
commit, a new semantically appropriate version, a matching release note, and
|
||||||
|
the release procedure again. Leave the original release intact. A channel's
|
||||||
|
yank, deprecation, or advisory mechanism may warn consumers away from a defective
|
||||||
|
release without replacing its contents or reusing its version.
|
||||||
|
|
||||||
|
## Repository Release Procedure
|
||||||
|
|
||||||
|
Maintain `docs/release.md` as a short, executable maintainer runbook. It must
|
||||||
|
identify:
|
||||||
|
|
||||||
|
1. prerequisites, primary branch, and authoritative remote;
|
||||||
|
2. version and release-note preparation;
|
||||||
|
3. one publication command with its selected local checks and automated guards;
|
||||||
|
4. declared deliverables, required build targets, and automation responsibilities;
|
||||||
|
5. the successful-push completion point and separately reported delivery status;
|
||||||
|
and
|
||||||
|
6. failure handling, ambiguous-push recovery, and asynchronous delivery retries.
|
||||||
|
|
||||||
|
Use copyable commands where practical. Keep optional diagnostics and consumer
|
||||||
|
verification clearly optional. The procedure must not turn preparation work,
|
||||||
|
ordinary change review, or post-publication investigation into additional
|
||||||
|
release gates.
|
||||||
|
|
||||||
|
## Promptkit Release Configuration
|
||||||
|
|
||||||
|
The release-note requirements were adopted on 2026-09-23 and licensing
|
||||||
|
navigation on 2026-09-26. Apply them prospectively; previously published notes
|
||||||
|
and tags retain their original records.
|
||||||
|
|
||||||
|
Promptkit publishes source-only Go modules from main to origin on Gitea, using
|
||||||
|
annotated stable SemVer tags. It has no binaries, hosted download pages, or
|
||||||
|
asynchronous deliverables. The source commit is pushed through main before the
|
||||||
|
tag. The [release procedure](../release.md) owns commands and recovery.
|
||||||
|
|
||||||
|
The existing publication script requires Summary, Compatibility, Upgrade, and
|
||||||
|
Changes headings beneath a title of `# Promptkit vMAJOR.MINOR.PATCH`. State
|
||||||
|
“No action required” or “None” where appropriate. This retained local structure
|
||||||
|
matches the automated release-note guard; changing it is separate work.
|
||||||
|
|
||||||
|
The [publication script](../../scripts/release.sh) runs the
|
||||||
|
[local checks](../../scripts/release-check.sh) once for the final committed
|
||||||
|
candidate: race-enabled tests, vet, three offline consumer examples, tracked
|
||||||
|
Go formatting, Markdown links, module/workspace hygiene, whitespace, and a
|
||||||
|
credential-pattern scan. These protect concurrency, assembled library use,
|
||||||
|
source distribution, and documentation integrity. A separate ordinary test run
|
||||||
|
or standalone build is not an additional release gate. Candidate, tag, and
|
||||||
|
synchronized-main checks remain automated. No CI or module-availability wait
|
||||||
|
is required. Licensing footer URLs are preparation work, not a network gate.
|
||||||
|
|
||||||
|
## Catalog Profile Compatibility
|
||||||
|
|
||||||
|
External catalog profile data may be added, removed, renamed, or modified
|
||||||
|
without deprecation or advance notice. This exception is limited to profile
|
||||||
|
data imported from the maintained catalog modules. It does not weaken ordinary
|
||||||
|
compatibility for Promptkit's public Go API, configuration and format semantics,
|
||||||
|
runtime behavior, catalog-module APIs, manifest schema, backend definitions, or
|
||||||
|
consumer-owned definitions.
|
||||||
|
|
||||||
|
Within the catalog versions selected and tested by a Promptkit release, and
|
||||||
|
unless a consumer overrides the name or its base, Promptkit keeps the literal
|
||||||
|
profile IDs `promptkit-openrouter-standard` and
|
||||||
|
`promptkit-openrouter-auto` available. The names do not promise a permanent
|
||||||
|
model, backend, routing behavior, settings, cost, output, or upstream
|
||||||
|
availability. The [framework format reference](../formats.md#built-in-profile-catalog)
|
||||||
|
owns their current definitions.
|
||||||
|
|
||||||
|
`v0.10.0` is the initial minor release for this compatibility transition.
|
||||||
|
Afterward, catalog-only updates and adjustments to the current selections may
|
||||||
|
ship in a patch release when all other compatibility obligations remain intact.
|
||||||
|
External profile removals can require consumer action; they are not automatic
|
||||||
|
migrations.
|
||||||
|
|
||||||
|
Catalog adoption and release validation remain local and offline. They do not
|
||||||
|
probe provider endpoints, request model lists, or make inference calls.
|
||||||
@@ -1,39 +1,32 @@
|
|||||||
# Testing Policy
|
# Testing Policy
|
||||||
|
|
||||||
|
**Base Policy Version:** 1.3
|
||||||
|
|
||||||
|
**Last Updated**: 2026-09-27
|
||||||
|
|
||||||
|
**Upstream Source:** [testing.md](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/testing.md)
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Our tests exist to make **incorrect changes expensive and correct changes cheap**.
|
Our tests exist to make **incorrect changes expensive and correct changes cheap**.
|
||||||
|
|
||||||
We do not optimize for test count, line coverage, exhaustive isolation, or the fewest possible tests. We optimize for sufficient confidence in important behavior while imposing as little unnecessary friction as possible on future development.
|
We optimize for sufficient confidence in important behavior while minimizing friction on future development.
|
||||||
|
|
||||||
## Every test has a cost
|
## Every test has a cost
|
||||||
|
|
||||||
Testing is not an unqualified good. Every test imposes both an immediate cost and a continuing lifetime cost.
|
Every test has an immediate and continuing cost: writing, review, execution,
|
||||||
|
diagnosis, fixture maintenance, and updates when legitimate behavior changes.
|
||||||
A test must be:
|
Tests can also constrain refactoring, duplicate policy, and add failure noise.
|
||||||
|
|
||||||
- written and reviewed;
|
|
||||||
- understood by future maintainers and coding agents;
|
|
||||||
- executed in maintainer-run validation;
|
|
||||||
- diagnosed when it fails;
|
|
||||||
- updated when legitimate behavior changes;
|
|
||||||
- maintained as fixtures, APIs, and dependencies evolve; and
|
|
||||||
- removed or rewritten when it becomes redundant, brittle, misleading, or obsolete.
|
|
||||||
|
|
||||||
Tests also create cognitive and architectural friction. They can constrain refactoring, duplicate policy, slow feedback loops, add noise to failures, and cause harmless implementation changes to require unrelated edits across the suite.
|
|
||||||
|
|
||||||
A test is warranted only when the confidence it provides justifies these costs.
|
A test is warranted only when the confidence it provides justifies these costs.
|
||||||
|
Evaluate that tradeoff at two levels:
|
||||||
Apply this cost-benefit analysis at two levels:
|
|
||||||
|
|
||||||
1. **Per test:** What realistic defect does this test detect, how consequential would that defect be, and is that protection worth the test's lifetime cost?
|
1. **Per test:** What realistic defect does this test detect, how consequential would that defect be, and is that protection worth the test's lifetime cost?
|
||||||
2. **Across the suite:** Does this collection provide materially more confidence than a smaller, simpler suite would?
|
2. **Across the suite:** Does this collection provide materially more confidence than a smaller, simpler suite would?
|
||||||
|
|
||||||
The preferred test suite is a **lean suite that provides sufficient confidence in the risks that matter, without redundant or low-value tests**. We seek sufficient confidence with the least unnecessary testing friction, not the fewest possible tests.
|
Some friction is intentional. Tests should make dangerous changes—such as breaking compatibility, corrupting data, violating security boundaries, or reintroducing subtle bugs—require deliberate review. Keep ordinary internal changes inexpensive.
|
||||||
|
|
||||||
Some friction is intentional. Tests should make dangerous changes—such as breaking compatibility, corrupting data, violating security boundaries, or reintroducing subtle bugs—require deliberate review. They should not make ordinary internal changes needlessly expensive.
|
Evaluate maintenance cost against the specific failure being protected. When omitting a plausible test, be able to state why the protected failure is low-risk, already covered, obvious, reversible, or cheaper to detect elsewhere. For consequential, subtle, or difficult-to-observe behavior, the presumption should favor testing.
|
||||||
|
|
||||||
The cost of a test is not a reason to omit testing by default. Do not cite maintenance cost abstractly. When omitting a plausible test, be able to state why the protected failure is low-risk, already covered, obvious, reversible, or cheaper to detect elsewhere. For consequential, subtle, or difficult-to-observe behavior, the presumption should favor testing.
|
|
||||||
|
|
||||||
## Default testing style
|
## Default testing style
|
||||||
|
|
||||||
@@ -47,21 +40,34 @@ Use a **classical/Detroit-style** approach:
|
|||||||
|
|
||||||
Examples of appropriate seams include clocks, randomness, subprocesses, remote APIs, object storage, email, and paid LLM calls.
|
Examples of appropriate seams include clocks, randomness, subprocesses, remote APIs, object storage, email, and paid LLM calls.
|
||||||
|
|
||||||
|
## Promptkit Test Boundaries
|
||||||
|
|
||||||
|
Promptkit is a library without a CLI or hosted CI. The execution requirements,
|
||||||
|
facade test ownership, public error guidance, and offline consumer examples
|
||||||
|
below tailor the standard to that boundary.
|
||||||
|
|
||||||
## Test execution requirements
|
## Test execution requirements
|
||||||
|
|
||||||
Promptkit currently uses maintainer-run validation rather than hosted CI.
|
Promptkit currently uses maintainer-run validation rather than hosted CI.
|
||||||
Maintainers run the complete local workflow in the
|
Ordinary work requires validation proportionate to its scope and risk. Do not
|
||||||
[development guide](../development.md#maintainer-validation) before accepting
|
repeat successful checks for unchanged content merely to create a commit.
|
||||||
changes. That guide is the canonical owner of exact commands, formatting,
|
Release candidates require the complete local workflow in the
|
||||||
documentation-link validation, and repository-hygiene checks.
|
[development guide](../development.md#maintainer-validation). That guide routes
|
||||||
|
contributors to the executable checks for formatting, documentation links,
|
||||||
|
and repository hygiene.
|
||||||
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.
|
||||||
|
|
||||||
Maintainer validation must include ordinary and race-enabled package tests,
|
Full release-candidate validation includes race-enabled package tests, explicit
|
||||||
static analysis, a complete build, and execution of both maintained offline
|
static analysis, and execution with automated semantic checks of the three maintained
|
||||||
consumer examples. The preparation example protects assembled preparation and
|
offline consumer examples. The test run builds the packages; the examples
|
||||||
|
also exercise ordinary compilation. Separate ordinary tests and a standalone
|
||||||
|
build are not additional release gates. Ordinary tests remain useful during
|
||||||
|
development. The preparation example protects assembled preparation and
|
||||||
inspection behavior. The execution example separately protects assembled
|
inspection behavior. The execution example separately protects assembled
|
||||||
`Run`, injected-client, validation, usage, and result behavior.
|
`Run`, injected-client, validation, usage, and result behavior. The observation
|
||||||
|
example protects assembled built-in retries, structured proposal and assessment
|
||||||
|
runs, and application-owned interpretation of metadata.
|
||||||
|
|
||||||
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
|
||||||
@@ -87,7 +93,7 @@ Use each test type where it protects a distinct risk:
|
|||||||
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,
|
- External-package root tests exercise the public facade as a Go consumer,
|
||||||
while internal package tests own focused implementation behavior.
|
while internal package tests own focused implementation behavior.
|
||||||
- The maintained offline preparation and execution examples protect distinct
|
- The maintained offline preparation, execution, and observation examples protect distinct
|
||||||
representative assembled consumer workflows without contacting a model
|
representative assembled consumer workflows without contacting a model
|
||||||
provider.
|
provider.
|
||||||
- Fixtures should be minimal, synthetic, versioned with the behavior they
|
- Fixtures should be minimal, synthetic, versioned with the behavior they
|
||||||
@@ -99,7 +105,7 @@ Use each test type where it protects a distinct risk:
|
|||||||
|
|
||||||
## What deserves tests
|
## What deserves tests
|
||||||
|
|
||||||
Prioritize tests for:
|
To the extent the following features are present in the application, prioritize tests for:
|
||||||
|
|
||||||
1. Public and package-level contracts.
|
1. Public and package-level contracts.
|
||||||
2. Domain rules and important invariants.
|
2. Domain rules and important invariants.
|
||||||
@@ -107,17 +113,29 @@ Prioritize tests for:
|
|||||||
4. Failure handling, cancellation, retries, recovery, and partial success.
|
4. Failure handling, cancellation, retries, recovery, and partial success.
|
||||||
5. Serialization, schemas, compatibility, and round trips.
|
5. Serialization, schemas, compatibility, and round trips.
|
||||||
6. Previously observed or plausible regressions.
|
6. Previously observed or plausible regressions.
|
||||||
7. Representative integration and consumer workflows.
|
7. Representative integration and end-to-end workflows.
|
||||||
|
|
||||||
A package-level contract is behavior relied upon by another package or major collaborator, not every observable detail of a package implementation.
|
A package-level contract comprises behavior relied upon by another package or major collaborator.
|
||||||
|
|
||||||
For behavior involving **data integrity, destructive operations, compatibility, security, concurrency, idempotency, or recovery**, presume that durable tests are required unless the behavior is already credibly protected at another layer.
|
Choose tests for requested behavior and established contracts. Tests must not
|
||||||
|
introduce new restrictions, guarantees, or threat scenarios merely because they
|
||||||
|
could be asserted. Follow the [agent policy](agents.md) when deciding whether a
|
||||||
|
behavior belongs to the contract; current implementation behavior alone is not
|
||||||
|
sufficient authority.
|
||||||
|
|
||||||
|
For established behavior involving **data integrity, destructive operations,
|
||||||
|
compatibility, security, concurrency, idempotency, or recovery**, favor durable
|
||||||
|
tests for realistic failures whose consequences warrant protection. Identify
|
||||||
|
the failure and the existing contract it would violate. These categories call
|
||||||
|
for attention, not an automatic test inventory or permission to strengthen the
|
||||||
|
application's guarantees. Account for protection already provided at other layers.
|
||||||
|
|
||||||
Do not add tests merely because a function, branch, or line exists. Do not add a test when the same meaningful risk is already adequately protected elsewhere.
|
Do not add tests merely because a function, branch, or line exists. Do not add a test when the same meaningful risk is already adequately protected elsewhere.
|
||||||
|
|
||||||
## Choose the right test boundary
|
## Choose the right test boundary
|
||||||
|
|
||||||
Test through the narrowest stable boundary that expresses the behavior clearly.
|
Choose the narrowest stable boundary that expresses the behavior clearly and
|
||||||
|
provides durable confidence with minimal incidental coupling.
|
||||||
|
|
||||||
This is often the package API, but it may instead be:
|
This is often the package API, but it may instead be:
|
||||||
|
|
||||||
@@ -125,30 +143,15 @@ This is often the package API, but it may instead be:
|
|||||||
- a package-level operation when several internal collaborators jointly produce the behavior; or
|
- a package-level operation when several internal collaborators jointly produce the behavior; or
|
||||||
- a larger integration boundary when correctness emerges from interaction with a real dependency.
|
- a larger integration boundary when correctness emerges from interaction with a real dependency.
|
||||||
|
|
||||||
Do not force all behavior through oversized consumer-workflow tests. Do not
|
|
||||||
test every private helper merely because it exists. Choose the boundary that
|
|
||||||
gives durable confidence with the least incidental coupling.
|
|
||||||
|
|
||||||
## Test behavior, not implementation
|
## Test behavior, not implementation
|
||||||
|
|
||||||
A test should protect a decision, contract, or invariant—not memorialize the current implementation.
|
A test should protect a selected decision, contract, or invariant.
|
||||||
|
|
||||||
Before adding or retaining a test, ask:
|
A test is suspect when it primarily detects changes to private constants or
|
||||||
|
helpers, equivalent operation ordering, incidental formatting, equivalent
|
||||||
|
algorithms, or internal structure that preserves behavior.
|
||||||
|
|
||||||
> What realistic defect would this test catch?
|
Behavioral tests should normally remain stable through internal refactoring that preserves the contract.
|
||||||
|
|
||||||
A test is suspect when its main purpose is to detect that someone:
|
|
||||||
|
|
||||||
- changed an internal constant;
|
|
||||||
- renamed or split a private helper;
|
|
||||||
- reordered equivalent internal operations;
|
|
||||||
- changed incidental formatting;
|
|
||||||
- replaced one correct algorithm with another; or
|
|
||||||
- refactored internal object structure without changing behavior.
|
|
||||||
|
|
||||||
Refactoring should normally require no test edits unless the refactored structure is itself part of the contract.
|
|
||||||
|
|
||||||
A test can be factually correct and still have negative value. Accurately describing current behavior is not enough; the protected behavior must be important enough to justify the future friction.
|
|
||||||
|
|
||||||
## Expected effects of different changes
|
## Expected effects of different changes
|
||||||
|
|
||||||
@@ -165,13 +168,13 @@ A test failing is not the same as a test needing to be edited. Many tests may co
|
|||||||
|
|
||||||
## Separate mechanism from policy
|
## Separate mechanism from policy
|
||||||
|
|
||||||
Configurable thresholds and defaults must not be duplicated throughout the test suite.
|
Derive expectations from test-controlled configuration and relationships.
|
||||||
|
|
||||||
The following fragments are illustrative rather than standalone Go programs.
|
For this example, assume the selected contract rejects a request to start more
|
||||||
Do not encode an internal concurrency limit indirectly:
|
processes than a configured limit. Do not encode its production default indirectly:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// Production policy:
|
// Production default:
|
||||||
const maxConcurrency = 4
|
const maxConcurrency = 4
|
||||||
|
|
||||||
// Brittle test:
|
// Brittle test:
|
||||||
@@ -189,37 +192,40 @@ require.NoError(t, runner.Start(limit))
|
|||||||
require.ErrorIs(t, runner.Start(limit+1), ErrTooMuchConcurrency)
|
require.ErrorIs(t, runner.Start(limit+1), ErrTooMuchConcurrency)
|
||||||
```
|
```
|
||||||
|
|
||||||
The test should prove:
|
For this selected contract, the test should prove:
|
||||||
|
|
||||||
- the configured limit is accepted; and
|
- the configured limit is accepted; and
|
||||||
- one beyond the configured limit is rejected.
|
- one beyond the configured limit is rejected.
|
||||||
|
|
||||||
|
Rejection is not a general requirement for concurrency control. If the selected
|
||||||
|
contract queues excess work or limits active workers while accepting larger
|
||||||
|
batches, test that behavior instead. Do not add rejection or a configurable
|
||||||
|
limit solely to reproduce this example.
|
||||||
|
|
||||||
The production default should be tested exactly only when its literal value is itself a public, operational, safety, protocol, or compatibility requirement.
|
The production default should be tested exactly only when its literal value is itself a public, operational, safety, protocol, or compatibility requirement.
|
||||||
|
|
||||||
Apply the same rule to limits, timeouts, capacities, retry counts, and ranges: test relationships and behavior, not duplicated literals.
|
Apply the same rule to limits, timeouts, capacities, retry counts, and ranges: test relationships and behavior.
|
||||||
|
|
||||||
For concurrency limits, test both kinds of behavior when relevant:
|
For established concurrency limits, distinguish these behaviors where the contract requires them:
|
||||||
|
|
||||||
1. **Configuration enforcement:** invalid or excessive requested values are handled correctly.
|
1. **Configuration handling:** values are accepted or rejected according to the selected contract.
|
||||||
2. **Runtime enforcement:** observed peak concurrency never exceeds the configured limit.
|
2. **Runtime enforcement:** observed peak concurrency never exceeds the configured limit.
|
||||||
|
|
||||||
Use a test-controlled limit and measure the behavior relative to that limit. Do not merely assert today's default value.
|
Use a test-controlled limit and measure the behavior relative to that limit.
|
||||||
|
|
||||||
## Avoid semantic duplication across layers
|
## Assign each behavior a test owner
|
||||||
|
|
||||||
Each behavior should have a clear test owner.
|
Where these layers exist, assign ownership as follows:
|
||||||
|
|
||||||
- Parser tests own parsing cases.
|
- Parser tests own parsing cases.
|
||||||
- Validator tests own validation rules.
|
- Validator tests own validation rules.
|
||||||
- Domain tests own transformations and invariants.
|
- Domain tests own transformations and invariants.
|
||||||
- Boundary tests own external integration behavior.
|
- Adapter tests own external integration behavior.
|
||||||
- Orchestrator tests own coordination and failure propagation.
|
- Orchestrator tests own coordination and failure propagation.
|
||||||
- Consumer-workflow tests prove that representative assembled library use
|
- External-package root tests own the public Go facade contract.
|
||||||
works.
|
- Consumer-workflow tests prove that representative assembled library use works.
|
||||||
|
|
||||||
Higher-level tests should not repeat every lower-level case. A single intentional policy change should not require unrelated edits across many test files.
|
Give each layer its distinct responsibility and keep expectation updates localized to the contract being changed.
|
||||||
|
|
||||||
Tests that are individually reasonable may still be collectively redundant. Evaluate the marginal value of each additional test in light of the protection already provided by the rest of the suite.
|
|
||||||
|
|
||||||
## Use test doubles deliberately
|
## Use test doubles deliberately
|
||||||
|
|
||||||
@@ -240,111 +246,78 @@ Mocks are appropriate when the contract includes facts such as:
|
|||||||
- an expensive API is called no more than once; or
|
- an expensive API is called no more than once; or
|
||||||
- a security audit event is emitted.
|
- a security audit event is emitted.
|
||||||
|
|
||||||
Do not use mocks merely to isolate every object or reproduce the implementation's call graph.
|
|
||||||
|
|
||||||
## Go-specific guidance
|
## Go-specific guidance
|
||||||
|
|
||||||
Use:
|
Select techniques by behavior and risk:
|
||||||
|
|
||||||
- table-driven tests for meaningful behavioral categories and boundaries;
|
- table-driven tests for meaningful behavioral categories and boundaries;
|
||||||
- `t.TempDir()` for real filesystem behavior;
|
- `t.TempDir()` for real filesystem behavior;
|
||||||
- `httptest.Server` for realistic HTTP interactions;
|
- `httptest.Server` for realistic HTTP interactions;
|
||||||
- fuzz tests for parsers, normalization, path handling, and broad input spaces;
|
- fuzz tests when exploring broad input spaces, such as parsers or path handling, adds useful protection beyond representative cases;
|
||||||
- golden files only when the complete output is intentionally stable;
|
- golden files only when the complete output is intentionally stable;
|
||||||
- integration tests where correctness depends on component interaction; and
|
- integration tests where correctness depends on component interaction; and
|
||||||
- a small number of representative consumer-workflow tests.
|
- a small number of representative end-to-end tests when assembled workflows expose meaningful risks not adequately covered at narrower boundaries.
|
||||||
|
|
||||||
Avoid exact error-string assertions unless the wording is itself contractual. Prefer `errors.Is`, `errors.As`, typed errors, or structured error fields.
|
Prefer `errors.Is`, `errors.As`, typed errors, or structured error fields. Use exact error-wording assertions only when the wording is contractual.
|
||||||
|
|
||||||
At public API boundaries, prefer stable error identity, structured values, and
|
At public API boundaries, prefer stable error identity, structured values, and
|
||||||
the smallest semantic fragment needed to identify the failure. Do not snapshot
|
the smallest semantic fragment needed to identify the failure. Use complete
|
||||||
complete diagnostic wording unless it is contractual.
|
diagnostic snapshots only when the wording is contractual.
|
||||||
|
|
||||||
Golden-file updates must require an explicit local flag. Ordinary validation
|
Golden-file updates must require an explicit local flag. Ordinary validation runs must never update golden files automatically, and reviewers must inspect the semantic diff before accepting an update.
|
||||||
runs must never update golden files automatically, and maintainers must inspect
|
|
||||||
the semantic diff before accepting an update.
|
|
||||||
|
|
||||||
Keep tests readable and direct. Test helpers and fixture frameworks must earn their own maintenance cost; do not build elaborate test infrastructure for small or isolated needs.
|
Keep tests readable and direct. Size test helpers and fixture frameworks to the maintenance benefit they provide.
|
||||||
|
|
||||||
## Coverage
|
## Coverage
|
||||||
|
|
||||||
Coverage is a diagnostic, not a target.
|
Use coverage to locate untested critical branches and unexpectedly weak packages. Evaluate test quality through meaningful behavioral protection and choose coverage according to risk.
|
||||||
|
|
||||||
Use it to find untested critical branches and unexpectedly weak packages. Do not write low-value tests solely to increase a percentage, and do not infer test quality from coverage alone.
|
Pure domain logic will often warrant higher coverage than facade wiring or external adapters. Uneven coverage is acceptable when it reflects risk.
|
||||||
|
|
||||||
Pure domain logic will often warrant higher coverage than facade wiring or
|
|
||||||
external adapters. Uneven coverage is acceptable when it reflects risk.
|
|
||||||
|
|
||||||
Increasing coverage is valuable only when the newly covered behavior protects a meaningful risk at an acceptable cost.
|
|
||||||
|
|
||||||
## Regression tests
|
## Regression tests
|
||||||
|
|
||||||
A bug fix should normally include a regression test that fails before the fix and passes afterward.
|
A bug fix should normally include a regression test that fails before the fix and passes afterward.
|
||||||
|
|
||||||
Retain the test when the defect could realistically recur and its consequences justify the ongoing cost. Prefer the narrowest durable test of the violated contract or invariant; do not preserve accidental implementation details from the original bug.
|
Retain the test when the defect could realistically recur and its consequences justify the ongoing cost. Prefer the narrowest durable test of the violated contract or invariant.
|
||||||
|
|
||||||
Not every historical bug requires a permanent test. If the underlying design has made recurrence impossible, the test has become redundant, or a stronger invariant test now subsumes it, remove or consolidate it.
|
Remove or consolidate a test when the underlying design has made recurrence impossible, its protection is redundant, or a stronger invariant test subsumes it.
|
||||||
|
|
||||||
## Deleting or rewriting tests
|
## Deleting or rewriting tests
|
||||||
|
|
||||||
Tests are maintained code, not permanent historical artifacts.
|
Delete or rewrite tests when maintenance cost exceeds their continuing
|
||||||
|
protective value.
|
||||||
Delete or rewrite a test when its maintenance cost exceeds the confidence it provides.
|
|
||||||
|
|
||||||
Strong candidates include tests that:
|
Strong candidates include tests that:
|
||||||
|
|
||||||
- require updates after harmless internal changes;
|
- require updates after harmless internal changes;
|
||||||
- directly assert private constants without protecting a real contract;
|
- assert incidental private constants or snapshot large amounts of incidental output;
|
||||||
- duplicate the same policy across several layers;
|
|
||||||
- verify mock choreography rather than outcomes;
|
- verify mock choreography rather than outcomes;
|
||||||
- snapshot large amounts of incidental output;
|
- duplicate policy across layers, retest trivial helpers covered by package
|
||||||
- test trivial private helpers already exercised through stable package behavior;
|
behavior, or protect risks covered more effectively elsewhere;
|
||||||
- protect risks already covered more effectively elsewhere;
|
|
||||||
- are flaky, misleading, obsolete, or disproportionately expensive to diagnose; or
|
- are flaky, misleading, obsolete, or disproportionately expensive to diagnose; or
|
||||||
- no longer correspond to a plausible failure mode.
|
- no longer correspond to a plausible failure mode.
|
||||||
|
|
||||||
Several brittle tests may encode one genuine requirement. Replace them with one durable behavior-level or invariant test rather than preserving all of them.
|
Several brittle tests may encode one genuine requirement. Consolidate them into one durable behavior-level or invariant test.
|
||||||
|
|
||||||
Deleting a low-value test can improve the quality of the suite by reducing noise, maintenance burden, and friction around legitimate change.
|
|
||||||
|
|
||||||
## Reviewing a proposed test
|
## Reviewing a proposed test
|
||||||
|
|
||||||
Use the following questions when the value, boundary, or durability of a proposed test is not self-evident. Significant test additions should be reviewable against them, but written answers are not required for every routine test.
|
Use the following questions when a proposed test needs closer evaluation. Significant test additions should be reviewable against them; written answers are optional for routine tests.
|
||||||
|
|
||||||
1. What realistic defect would it catch?
|
1. Which requested behavior or established contract does it protect, and what realistic defect would violate it?
|
||||||
2. How likely is that defect?
|
2. How likely and consequential is that defect, and is it already protected elsewhere?
|
||||||
3. How consequential would it be?
|
3. What is the narrowest stable boundary that can test it clearly?
|
||||||
4. Is the behavior already protected elsewhere?
|
4. Would a correct internal refactor or a change to an incidental default require test edits?
|
||||||
5. At which layer should this behavior be owned?
|
5. Does its confidence justify its maintenance, execution, and diagnostic cost, or would a smaller test suffice?
|
||||||
6. Does the test assert a durable contract or an incidental implementation detail?
|
|
||||||
7. Could the implementation be refactored without changing the behavior and without editing this test?
|
|
||||||
8. What should cause this test to fail?
|
|
||||||
9. What legitimate changes should not cause this test to fail?
|
|
||||||
10. What ongoing maintenance, execution, and diagnostic cost will the test impose?
|
|
||||||
11. Is there a smaller or more direct test that protects the same risk?
|
|
||||||
|
|
||||||
Do not add the test when its expected lifetime cost exceeds its expected protective value.
|
|
||||||
|
|
||||||
When deciding not to test plausible behavior, record or be able to explain why the risk is low, already protected, obvious, reversible, or cheaper to detect elsewhere.
|
|
||||||
|
|
||||||
## Definition of sufficient
|
## Definition of sufficient
|
||||||
|
|
||||||
A test suite is sufficient when:
|
A test suite is sufficient when:
|
||||||
|
|
||||||
- important contracts and invariants are protected;
|
- important requested behavior and established contracts are protected against realistic, consequential failures;
|
||||||
- meaningful boundaries and failure modes are exercised;
|
- boundaries and component interactions have coverage where they introduce meaningful risks not protected elsewhere;
|
||||||
- realistic and consequential regressions are credibly protected against silent recurrence;
|
- failures provide useful, distinct signals;
|
||||||
- behavior involving data integrity, destructive operations, compatibility, security, concurrency, idempotency, and recovery is credibly protected;
|
- tests usually remain stable through legitimate internal changes; and
|
||||||
- important external boundaries have realistic integration coverage;
|
|
||||||
- representative complete workflows are tested;
|
|
||||||
- failures provide useful signal rather than redundant noise;
|
|
||||||
- legitimate internal changes usually do not require test edits; and
|
|
||||||
- additional tests would mostly repeat existing protection or preserve inconsequential implementation details.
|
- additional tests would mostly repeat existing protection or preserve inconsequential implementation details.
|
||||||
|
|
||||||
Sufficiency is a risk judgment, not a coverage percentage or test count.
|
Reassess sufficiency as the application, its users, and the consequences of
|
||||||
Reassess it as the library, its consumers, and the consequences of failure
|
failure evolve.
|
||||||
evolve.
|
|
||||||
|
|
||||||
The governing rule is:
|
|
||||||
|
|
||||||
> Test heavily where failure is consequential, subtle, or difficult to detect after the fact. Test lightly where failure is obvious, reversible, and inexpensive—and retain no test whose lifetime cost exceeds the confidence it provides.
|
|
||||||
|
|||||||
295
docs/release.md
295
docs/release.md
@@ -1,242 +1,97 @@
|
|||||||
# Release Procedure
|
# Release Procedure
|
||||||
|
|
||||||
## Release Model
|
This runbook implements the [release policy](policy/release.md). A successful
|
||||||
|
push of the selected annotated tag to origin completes the release. Never wait
|
||||||
|
for CI to finish or succeed.
|
||||||
|
|
||||||
Promptkit publishes a Go library through source commits and semantic Go module
|
## Prepare The Candidate
|
||||||
tags. It does not publish runnable binaries or binary packages and does not
|
|
||||||
currently use hosted CI. The release maintainer performs and records the
|
|
||||||
required validation.
|
|
||||||
|
|
||||||
`v0.1.0` is the initial published release. Later releases use semantic
|
Required tools are Git, Go with race-detector support, POSIX shell, and Python
|
||||||
`vMAJOR.MINOR.PATCH` tags. Before `v1`, minor releases may change the public
|
3. Publication requires push access to origin. Work from the repository root.
|
||||||
API and patch releases preserve compatibility within their minor line. Every
|
|
||||||
pre-`v1` release note must summarize compatibility, identify public API
|
|
||||||
changes, and state any action required of consumers.
|
|
||||||
|
|
||||||
Promptkit releases are source-only. The annotated tag message is the release
|
Select an unused version and create `docs/releases/<version>.md` using the
|
||||||
note; there is no separate hosted release or binary packaging step.
|
[required structure](policy/release.md#release-documentation). Describe
|
||||||
|
compatibility impact and consumer actions and update affected canonical docs.
|
||||||
|
For catalog updates, record selected catalog versions and any consumer action,
|
||||||
|
linking to the catalog policy rather than repeating its contracts.
|
||||||
|
|
||||||
## Establish The Candidate
|
Follow the [OSS policy](policy/oss.md) when preparing licensing and attribution
|
||||||
|
changes. Each new note must end with the following navigation, replacing
|
||||||
|
`vMAJOR.MINOR.PATCH` with the selected release tag:
|
||||||
|
|
||||||
Choose a version that has not been published and export it as
|
```markdown
|
||||||
`RELEASE_VERSION`. Run every command in this procedure from the Promptkit
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/promptkit/src/tag/vMAJOR.MINOR.PATCH/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/promptkit/src/tag/vMAJOR.MINOR.PATCH/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/promptkit/src/tag/vMAJOR.MINOR.PATCH/docs/oss/attribution.md)
|
||||||
repository root in the same POSIX shell. Do not reuse `v0.1.0` or another
|
```
|
||||||
existing version.
|
|
||||||
|
|
||||||
The following guard derives the release commit from `HEAD` and stops on a
|
Check these file paths against the candidate; a future tag need not resolve
|
||||||
missing or malformed version, a checkout other than synchronized `main`,
|
remotely during preparation. The annotated tag derives from this same note,
|
||||||
uncommitted changes, an active Go workspace, a module replacement, a vendor
|
including its footer. Promptkit publishes no separate hosted release body or
|
||||||
tree, or an existing local or remote tag:
|
download page. If such a channel is deliberately introduced, reuse the note
|
||||||
|
and preserve its footer. No extra network checks, artifacts, or approval gates
|
||||||
|
are introduced, and published notes are not retrofitted.
|
||||||
|
|
||||||
|
Retire completed roadmaps, preserving planned or deferred work and repairing
|
||||||
|
links. This is housekeeping, not a separate approval gate. Review changes
|
||||||
|
through the ordinary development workflow, commit them, and push main to
|
||||||
|
origin. No repeated release-specific diff review is required.
|
||||||
|
|
||||||
|
## Validate And Publish
|
||||||
|
|
||||||
|
Run the following with the chosen unused version (replace the placeholder):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
set -eu
|
./scripts/release.sh vMAJOR.MINOR.PATCH
|
||||||
|
|
||||||
: "${RELEASE_VERSION:?export an unpublished vMAJOR.MINOR.PATCH version}"
|
|
||||||
if ! printf '%s\n' "$RELEASE_VERSION" |
|
|
||||||
grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
|
||||||
then
|
|
||||||
printf '%s\n' "invalid release version: $RELEASE_VERSION" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
RELEASE_COMMIT=$(git rev-parse --verify 'HEAD^{commit}')
|
|
||||||
export RELEASE_COMMIT
|
|
||||||
|
|
||||||
check_release_candidate() {
|
|
||||||
test "$(git branch --show-current)" = main
|
|
||||||
test -z "$(git status --porcelain)"
|
|
||||||
|
|
||||||
gowork_value=$(go env GOWORK)
|
|
||||||
case "$gowork_value" in
|
|
||||||
''|off) ;;
|
|
||||||
*)
|
|
||||||
printf '%s\n' "active Go workspace: $gowork_value" >&2
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
test -z "$(git ls-files go.work go.work.sum)"
|
|
||||||
test ! -e vendor
|
|
||||||
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod
|
|
||||||
then
|
|
||||||
printf '%s\n' 'go.mod contains a replacement' >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
git fetch origin main --tags
|
|
||||||
test "$RELEASE_COMMIT" = \
|
|
||||||
"$(git rev-parse --verify 'refs/remotes/origin/main^{commit}')"
|
|
||||||
|
|
||||||
if git show-ref --verify --quiet "refs/tags/$RELEASE_VERSION"
|
|
||||||
then
|
|
||||||
printf '%s\n' "local tag already exists: $RELEASE_VERSION" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if test -n "$(
|
|
||||||
git ls-remote --tags origin \
|
|
||||||
"refs/tags/$RELEASE_VERSION" \
|
|
||||||
"refs/tags/$RELEASE_VERSION^{}"
|
|
||||||
)"
|
|
||||||
then
|
|
||||||
printf '%s\n' "remote tag already exists: $RELEASE_VERSION" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
check_release_candidate
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not continue unless the guard completes successfully. In particular, push
|
The [publication script](../scripts/release.sh) checks a clean, synchronized
|
||||||
the intended commit through the normal `main` branch workflow before release;
|
main candidate, the version and unused tag, and the release note's title and
|
||||||
the tag procedure is not a substitute for publishing the source commit.
|
required headings. It runs the [local checks](../scripts/release-check.sh)
|
||||||
|
once, rechecks candidate identity and publication state, generates the
|
||||||
|
annotation from the committed note plus the validated commit, and pushes only
|
||||||
|
the selected annotated tag.
|
||||||
|
|
||||||
## Validate The Candidate
|
There is no separate tag-message authoring, example-output inspection,
|
||||||
|
metadata inspection, or human sign-off. Compatibility and release-note content
|
||||||
|
remain ordinary editorial responsibilities. Development validation is useful
|
||||||
|
but does not require a second full run before invoking the publication script.
|
||||||
|
|
||||||
Confirm the module and root package metadata:
|
The script stops successfully after the tag push. CI completion, CI success,
|
||||||
|
remote read-back, and Go module availability are not release completion gates.
|
||||||
|
Do not wait or poll for them. Provider probes and inference calls are never
|
||||||
|
part of release validation.
|
||||||
|
|
||||||
|
## Catalog Profile Compatibility
|
||||||
|
|
||||||
|
The [release policy](policy/release.md#catalog-profile-compatibility) owns the
|
||||||
|
catalog compatibility boundary. This heading preserves older incoming links.
|
||||||
|
|
||||||
|
## Failure Recovery
|
||||||
|
|
||||||
|
If local checks fail, fix the candidate, commit and push changes, then rerun
|
||||||
|
the publication command. It never automatically removes a tag.
|
||||||
|
|
||||||
|
If a push fails or its result is ambiguous, set `RELEASE_VERSION` to the
|
||||||
|
attempted version and inspect origin before retrying:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go list -m -f '{{.Path}} {{.GoVersion}}'
|
git ls-remote --tags origin "refs/tags/$RELEASE_VERSION" "refs/tags/$RELEASE_VERSION^{}"
|
||||||
go list -f '{{.Name}} {{.ImportPath}}' .
|
git rev-parse "refs/tags/$RELEASE_VERSION" "refs/tags/$RELEASE_VERSION^{}"
|
||||||
```
|
```
|
||||||
|
|
||||||
The output must be:
|
Matching remote tag and peeled commit values confirm publication, even if the
|
||||||
|
push command reported an error. If the remote query fails, publication status
|
||||||
|
is still unknown. Do not delete or recreate the local tag until absence on
|
||||||
|
origin is confirmed. Once confirmed unpublished, an unsuccessful attempt's
|
||||||
|
local tag can be removed and the publication command rerun. Never overwrite
|
||||||
|
a published tag.
|
||||||
|
|
||||||
```text
|
CI or module-resolution problems may be investigated separately; temporary
|
||||||
gitea.maximumdirect.net/eric/promptkit 1.25.5
|
service, network, or authentication failures do not require another release.
|
||||||
promptkit gitea.maximumdirect.net/eric/promptkit
|
Only an actual defect requiring changes to published contents warrants a new
|
||||||
```
|
appropriate version. See the [recovery policy](policy/release.md#immutability-and-failure-recovery).
|
||||||
|
|
||||||
As a release prerequisite, run the complete
|
|
||||||
[maintainer validation workflow](development.md#maintainer-validation) against
|
|
||||||
the clean candidate. Do not substitute a partial command list: the development
|
|
||||||
guide owns the tests, race checks, analysis, build, both offline examples,
|
|
||||||
formatting, Markdown links, generated-output and credential review, and
|
|
||||||
repository hygiene. Record the successful workflow result with the candidate.
|
|
||||||
|
|
||||||
## Write The Release Note
|
|
||||||
|
|
||||||
Prepare a plain-text annotated-tag message outside the repository and export
|
|
||||||
its path as `RELEASE_NOTES_FILE`. Use this form, replacing each summary with
|
|
||||||
release-specific text; write `None.` when there are no public API changes or
|
|
||||||
consumer actions:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Promptkit vMAJOR.MINOR.PATCH
|
|
||||||
|
|
||||||
Validated commit: full commit ID
|
|
||||||
Compatibility: compatibility summary
|
|
||||||
Public API changes: changes or None.
|
|
||||||
Consumer action: required action or None.
|
|
||||||
```
|
|
||||||
|
|
||||||
After writing it, require all release-note fields, the selected version, and
|
|
||||||
the validated commit to be present:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
: "${RELEASE_NOTES_FILE:?export the path to the release-note file}"
|
|
||||||
test -f "$RELEASE_NOTES_FILE"
|
|
||||||
test -s "$RELEASE_NOTES_FILE"
|
|
||||||
grep -F "Promptkit $RELEASE_VERSION" "$RELEASE_NOTES_FILE"
|
|
||||||
grep -F "Validated commit: $RELEASE_COMMIT" "$RELEASE_NOTES_FILE"
|
|
||||||
grep -F 'Compatibility:' "$RELEASE_NOTES_FILE"
|
|
||||||
grep -F 'Public API changes:' "$RELEASE_NOTES_FILE"
|
|
||||||
grep -F 'Consumer action:' "$RELEASE_NOTES_FILE"
|
|
||||||
```
|
|
||||||
|
|
||||||
Inspect the complete message and confirm that it accurately records the
|
|
||||||
compatibility impact, public API changes, and required consumer action.
|
|
||||||
|
|
||||||
## Create And Inspect The Tag
|
|
||||||
|
|
||||||
Run the candidate guard again immediately before tag creation. This ensures
|
|
||||||
that validation or release-note preparation did not change the checkout and
|
|
||||||
that the commit is still published and untagged:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
check_release_candidate
|
|
||||||
```
|
|
||||||
|
|
||||||
Create the annotated tag from the prepared release note and bind it explicitly
|
|
||||||
to the validated commit:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git tag --annotate "$RELEASE_VERSION" \
|
|
||||||
--file "$RELEASE_NOTES_FILE" \
|
|
||||||
"$RELEASE_COMMIT"
|
|
||||||
```
|
|
||||||
|
|
||||||
Inspect both the tag message and its source commit before publication:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
test "$(git cat-file -t "refs/tags/$RELEASE_VERSION")" = tag
|
|
||||||
git show --no-patch --decorate "refs/tags/$RELEASE_VERSION"
|
|
||||||
test "$(
|
|
||||||
git rev-parse --verify "refs/tags/$RELEASE_VERSION^{commit}"
|
|
||||||
)" = "$RELEASE_COMMIT"
|
|
||||||
```
|
|
||||||
|
|
||||||
If inspection finds an error, delete the unpublished local tag, correct the
|
|
||||||
release note or candidate, and repeat the guards. Never move or recreate a tag
|
|
||||||
that has been published.
|
|
||||||
|
|
||||||
## Publish The Selected Tag
|
|
||||||
|
|
||||||
Push only the selected tag ref. Do not use `git push --tags`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git push origin \
|
|
||||||
"refs/tags/$RELEASE_VERSION:refs/tags/$RELEASE_VERSION"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verify Publication
|
|
||||||
|
|
||||||
Compare the remote annotated-tag object with the local object, then compare the
|
|
||||||
remote peeled source commit with the validated commit:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
remote_tag=$(
|
|
||||||
git ls-remote --tags origin "refs/tags/$RELEASE_VERSION" |
|
|
||||||
awk 'NR == 1 { print $1 }'
|
|
||||||
)
|
|
||||||
remote_commit=$(
|
|
||||||
git ls-remote --tags origin "refs/tags/$RELEASE_VERSION^{}" |
|
|
||||||
awk 'NR == 1 { print $1 }'
|
|
||||||
)
|
|
||||||
test -n "$remote_tag"
|
|
||||||
test "$remote_tag" = \
|
|
||||||
"$(git rev-parse --verify "refs/tags/$RELEASE_VERSION")"
|
|
||||||
test "$remote_commit" = "$RELEASE_COMMIT"
|
|
||||||
```
|
|
||||||
|
|
||||||
Finally, resolve the version as an ordinary Go module in a temporary module
|
|
||||||
outside this repository and without a workspace or replacement:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
resolution_dir=$(mktemp -d)
|
|
||||||
(
|
|
||||||
trap 'rm -rf "$resolution_dir"' 0 1 2 15
|
|
||||||
cd "$resolution_dir"
|
|
||||||
GOWORK=off go mod init example.com/promptkit-release-check
|
|
||||||
GOWORK=off go mod download \
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit@$RELEASE_VERSION"
|
|
||||||
resolved_version=$(
|
|
||||||
GOWORK=off go list -m -f '{{.Version}}' \
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit@$RELEASE_VERSION"
|
|
||||||
)
|
|
||||||
test "$resolved_version" = "$RELEASE_VERSION"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Promptkit must publish and verify the required version before Scriptorium or
|
|
||||||
another consumer publishes a release that depends on it. This ordering does
|
|
||||||
not replace the consumer project's own release procedure. Released consumers
|
|
||||||
must select the published Promptkit tag through ordinary module resolution,
|
|
||||||
without a workspace, replacement, vendored Promptkit source, or unpublished
|
|
||||||
revision.
|
|
||||||
|
|
||||||
## Policy Changes
|
## Policy Changes
|
||||||
|
|
||||||
Document and approve a durable policy change before introducing hosted
|
Update the policy and this procedure together when changing release governance.
|
||||||
automation, binary artifacts, or different release governance. Update this
|
Consumers own their dependency-availability and release checks; their readiness
|
||||||
procedure in the same change so maintainers do not rely on hidden release
|
does not delay completion of a Promptkit release.
|
||||||
requirements.
|
|
||||||
|
|||||||
44
docs/releases/v0.10.0.md
Normal file
44
docs/releases/v0.10.0.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Promptkit v0.10.0
|
||||||
|
|
||||||
|
This supplemental guide summarizes the `v0.10.0` changes and migration needs.
|
||||||
|
The annotated tag remains the authoritative release record.
|
||||||
|
|
||||||
|
## Transition Summary
|
||||||
|
|
||||||
|
`v0.10.0` adopts independently versioned OpenRouter and Rakestrawhome catalog
|
||||||
|
modules whose external profile data can change between catalog releases. It
|
||||||
|
also introduces the maintained literal names
|
||||||
|
`promptkit-openrouter-standard` and `promptkit-openrouter-auto`.
|
||||||
|
|
||||||
|
The public package now delegates assembly to an internal engine while retaining
|
||||||
|
the existing Go API. This refactor requires no consumer code changes.
|
||||||
|
|
||||||
|
This release selects OpenRouter `v2.20260922.1` and Rakestrawhome
|
||||||
|
`v2.20260922.0`; their exact inventories are linked from the
|
||||||
|
[format reference](../formats.md#external-profile-inventories).
|
||||||
|
|
||||||
|
The maintained names provide continuity only within the catalog versions
|
||||||
|
selected and tested by a Promptkit release and when consumers do not override
|
||||||
|
the name or its base. Their current selections may change. External catalog
|
||||||
|
profile IDs can be removed, renamed, or modified, so an application that
|
||||||
|
selects one directly may need to update its configuration when upgrading.
|
||||||
|
|
||||||
|
## Consumer Migration
|
||||||
|
|
||||||
|
Before upgrading, review every profile ID used by prompts, requests, and
|
||||||
|
application-owned inheritance. Choose a maintained name, accept an external
|
||||||
|
catalog profile's volatility, or define a complete application-owned profile
|
||||||
|
when the application must control its target and settings. An alias that
|
||||||
|
inherits from an external base is not a frozen copy.
|
||||||
|
|
||||||
|
In particular, the selected OpenRouter catalog removes `deepseek-3-2`,
|
||||||
|
`deepseek-4-flash`, and `deepseek-4-pro`. Consumers using those IDs must choose
|
||||||
|
an available profile or supply their own complete definition; the new
|
||||||
|
`deepseek-flash-latest` and `deepseek-pro-latest` profiles are not automatic
|
||||||
|
aliases and need not preserve the previous settings.
|
||||||
|
|
||||||
|
See the [release policy](../release.md#catalog-profile-compatibility) for the
|
||||||
|
compatibility boundary, the [format reference](../formats.md#built-in-profile-catalog)
|
||||||
|
for current Promptkit-owned definitions and selected external inventories, and
|
||||||
|
the [consumer guide](../consumers/pkg-promptkit.md#choose-a-profile-ownership-model)
|
||||||
|
for configuration choices.
|
||||||
76
docs/releases/v0.11.0.md
Normal file
76
docs/releases/v0.11.0.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Promptkit v0.11.0
|
||||||
|
|
||||||
|
This release adds reliable, observable model execution and changes Promptkit's
|
||||||
|
license to LGPL-3.0-or-later.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- Built-in model calls can recover from selected transient HTTP failures.
|
||||||
|
- Optional execution observations report attempts, timing, outcomes, and known
|
||||||
|
token usage, including work completed before a run fails.
|
||||||
|
- Public errors distinguish transport failures, malformed responses, and retry
|
||||||
|
exhaustion without parsing diagnostic text.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
The built-in client now defaults to two additional transient attempts. This can
|
||||||
|
increase latency, request counts, and cost; retries do not guarantee exactly-once
|
||||||
|
generation. Injected `LLMClient` implementations retain their own retry policy
|
||||||
|
and are not automatically replayed. Existing generation timeouts apply per
|
||||||
|
attempt, while the caller context governs the complete operation. See the
|
||||||
|
[provider integration contract](../integrations/openai-compatible-chat.md).
|
||||||
|
|
||||||
|
Public retry and observation controls are additive. Existing successful-result
|
||||||
|
usage semantics remain unchanged, and operational failures still return no
|
||||||
|
partial `RunResult`. Observations distinguish unknown usage from reported zero;
|
||||||
|
they are not a complete billing ledger. See the
|
||||||
|
[consumer guide](../consumers/pkg-promptkit.md) and
|
||||||
|
[observation GoDoc](../../observations.go).
|
||||||
|
|
||||||
|
Promptkit is now licensed under LGPL-3.0-or-later. Review the
|
||||||
|
[license summary](../../README.md#license), [LGPL text](../../LICENSE), and
|
||||||
|
[accompanying GPL text](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.0/COPYING) before adoption or distribution.
|
||||||
|
Previously published releases and dependency licenses are unchanged.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Update the dependency:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/promptkit@v0.11.0
|
||||||
|
```
|
||||||
|
|
||||||
|
To retain single-attempt behavior for built-in generation, set
|
||||||
|
`Config.TransientRetries` to a pointer to zero. `RunRequest.TransientRetries`
|
||||||
|
can override the engine choice per request. Review any existing application or
|
||||||
|
HTTP-transport retry loops to avoid unintentionally multiplying attempts.
|
||||||
|
Transient retries and structural repairs have separate budgets; provide a
|
||||||
|
caller deadline when a total execution time limit is needed.
|
||||||
|
|
||||||
|
Observation is opt-in through `RunRequest.Observer`, including for prepared
|
||||||
|
execution. Callbacks are synchronous; shared observers must support concurrent
|
||||||
|
runs. Injected clients can supply `GenerateResponse.UsagePresence` to explicitly
|
||||||
|
report known zero counts. No observer or usage-presence changes are required
|
||||||
|
for existing consumers. See the [consumer guide](../consumers/pkg-promptkit.md)
|
||||||
|
for adoption and the [offline example](../../examples/go-library/observe/main.go)
|
||||||
|
for a structured proposal-and-assessment workflow.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Centralized built-in retry coordination with cancellable backoff and provider
|
||||||
|
retry timing. Retries and structural repair share existing backend capacity;
|
||||||
|
backoff releases active permits without releasing logical run admission.
|
||||||
|
- Added `ErrTransport`, `ErrMalformedResponse`, `ErrRetriesExhausted`, and
|
||||||
|
`RetryExhaustedError`, retaining applicable underlying error identities.
|
||||||
|
- Added request-scoped lifecycle observations and presence-aware token accounting
|
||||||
|
for ordinary and prepared execution, without recording prompt/response bodies.
|
||||||
|
- Isolated delegated operations' observation state and made injected-client
|
||||||
|
attempt classifications consistent with terminal classifications.
|
||||||
|
- Added an offline observation example and regression coverage for retry,
|
||||||
|
repair, capacity, error, and observation interactions.
|
||||||
|
- Adopted LGPL-3.0-or-later and streamlined the documented local release process.
|
||||||
|
|
||||||
|
Backend catalog selections are unchanged from v0.10.0: OpenRouter
|
||||||
|
`v2.20260922.1` and Rakestrawhome `v2.20260922.0`. See the
|
||||||
|
[catalog compatibility policy](../policy/release.md#catalog-profile-compatibility)
|
||||||
|
and [profile inventories](../formats.md#external-profile-inventories).
|
||||||
43
docs/releases/v0.11.1.md
Normal file
43
docs/releases/v0.11.1.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Promptkit v0.11.1
|
||||||
|
|
||||||
|
This patch updates the maintained catalogs to MIT-licensed releases and
|
||||||
|
improves licensing documentation.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Both OpenRouter and Rakestrawhome catalogs now use `v2.20260926.0`. These
|
||||||
|
catalog releases change licensing and documentation only; their profiles,
|
||||||
|
backend definitions, and Go code are unchanged.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
No public API, runtime behavior, or profile changes are introduced.
|
||||||
|
Promptkit remains LGPL-3.0-or-later; the catalogs are now MIT-licensed.
|
||||||
|
See the [project grant](../../COPYRIGHT),
|
||||||
|
[component attribution](../oss/attribution.md), and
|
||||||
|
[catalog compatibility policy](../policy/release.md#catalog-profile-compatibility).
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Update the module dependency:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/promptkit@v0.11.1
|
||||||
|
```
|
||||||
|
|
||||||
|
No code or configuration migration is required from v0.11.0.
|
||||||
|
Consumers upgrading from earlier versions should also read the
|
||||||
|
[v0.11.0 migration guide](v0.11.0.md).
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Adopt the MIT-licensed catalog releases and update inventory links.
|
||||||
|
- Add centralized attribution and dependency license texts.
|
||||||
|
- Separate the project grant into COPYRIGHT and consolidate the LGPLv3 and
|
||||||
|
incorporated GPLv3 texts in LICENSE, replacing the separate COPYING file.
|
||||||
|
- Align documentation ownership and future release navigation with the
|
||||||
|
[OSS policy](../policy/oss.md), without adding release gates.
|
||||||
|
- Repair the historical v0.11.0 GPL-text link and retire the completed
|
||||||
|
licensing roadmap.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.1/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.1/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.1/docs/oss/attribution.md)
|
||||||
38
docs/releases/v0.11.2.md
Normal file
38
docs/releases/v0.11.2.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Promptkit v0.11.2
|
||||||
|
|
||||||
|
This patch aligns repository policies with the current standard and clarifies
|
||||||
|
licensing and attribution for the copied policy documents.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The update adds policy provenance, selective contributor reading guidance, and
|
||||||
|
consistent upstream policy language while preserving Promptkit-specific rules.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
No public API, runtime behavior, dependency, or catalog changes are introduced.
|
||||||
|
Promptkit remains LGPL-3.0-or-later. The [project grant](../../COPYRIGHT)
|
||||||
|
identifies the separate CC-BY-SA-4.0 scope for copied or adapted policy material;
|
||||||
|
the [attribution notices](../oss/attribution.md) identify the affected documents.
|
||||||
|
The existing release checks and successful-tag-push completion point are unchanged.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/promptkit@v0.11.2
|
||||||
|
```
|
||||||
|
|
||||||
|
No code or configuration migration is required from v0.11.1.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Align adopted policies with Standard Repository Policies while retaining
|
||||||
|
library-specific requirements and deliberate local decisions.
|
||||||
|
- Add per-document upstream source links and the
|
||||||
|
[policy-set provenance document](../policy/meta.md).
|
||||||
|
- Add selective policy reading to the [contributor guide](../development.md)
|
||||||
|
and update agent orientation.
|
||||||
|
- Standardize factual attribution entries and identify copied-policy licensing.
|
||||||
|
- Retire the completed policy-alignment roadmap.
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.2/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.2/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.2/docs/oss/attribution.md)
|
||||||
155
docs/releases/v0.7.0.md
Normal file
155
docs/releases/v0.7.0.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# Promptkit v0.7.0
|
||||||
|
|
||||||
|
This supplemental changelog and migration guide summarizes the consumer-facing
|
||||||
|
changes from `v0.6.0` to `v0.7.0`. The annotated `v0.7.0` tag is the
|
||||||
|
authoritative release record. Exact current contracts belong to the linked
|
||||||
|
GoDoc and durable documentation.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`v0.7.0` expands provider integration and profile composition while making
|
||||||
|
credential and generation-failure handling more flexible:
|
||||||
|
|
||||||
|
- Promptkit now includes the `rakestrawhome` backend and its Gemma profile;
|
||||||
|
- built-in generation failures expose bounded structured provider details;
|
||||||
|
- an unavailable optional API-key environment source no longer prevents a
|
||||||
|
request from reaching an upstream that permits unauthenticated access; and
|
||||||
|
- profiles can inherit from and selectively refine another profile.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This release adds public declarations and fields but removes none. Existing
|
||||||
|
keyed configuration literals and ordinary `errors.Is` handling continue to
|
||||||
|
work.
|
||||||
|
|
||||||
|
Adding `BaseProfileID` to `Profile` and `OpenAICompatibleProfileConfig` changes
|
||||||
|
their struct shape. Consumers using positional composite literals for either
|
||||||
|
type must convert them to keyed literals. Existing keyed literals require no
|
||||||
|
change.
|
||||||
|
|
||||||
|
The `rakestrawhome` backend ID is now built in and reserved. A consumer that
|
||||||
|
previously registered that exact ID with `WithBackend` must remove its manual
|
||||||
|
registration before upgrading. Other custom backend registrations are
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
When an optional backend, profile, or request `APIKeyEnv` is unset, empty, or
|
||||||
|
whitespace-only, the built-in client now omits `Authorization` and sends the
|
||||||
|
request. Previously this condition could fail before transport. Set
|
||||||
|
`Profile.APIKeyRequired` when missing credentials must remain a local
|
||||||
|
preflight error.
|
||||||
|
|
||||||
|
Provider non-success responses continue to match `ErrLLMGenerate`. Their
|
||||||
|
rendered wording is not a compatibility contract; consumers can now use
|
||||||
|
`errors.As` with `*GenerationError` when structured status information is
|
||||||
|
needed.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Update the module dependency with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/promptkit@v0.7.0
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove any manual `rakestrawhome` backend registration, convert positional
|
||||||
|
profile literals to keyed literals, and run the consuming project's ordinary
|
||||||
|
and race-enabled tests.
|
||||||
|
|
||||||
|
## Rakestrawhome Built-In Backend And Profile
|
||||||
|
|
||||||
|
Every engine now includes the reserved `rakestrawhome` backend, identified by
|
||||||
|
`BackendRakestrawHome`. The built-in `rakestrawhome-gemma-4-31b` profile
|
||||||
|
selects that backend. Consumers can use the maintained endpoint, credential,
|
||||||
|
capacity, and model defaults without registering either definition themselves.
|
||||||
|
|
||||||
|
See the [built-in backend and profile catalogs](../formats.md#built-in-backends)
|
||||||
|
and the [consumer adoption example](../consumers/pkg-promptkit.md#use-the-rakestrawhome-built-in-profile)
|
||||||
|
for the current contracts.
|
||||||
|
|
||||||
|
## Structured Generation Errors
|
||||||
|
|
||||||
|
Non-2xx responses from the built-in OpenAI-compatible client now return an
|
||||||
|
immutable `*GenerationError`. Consumers can inspect the HTTP status and any
|
||||||
|
safely extracted provider code, type, or message while retaining the ordinary
|
||||||
|
generation-error category:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var generationErr *promptkit.GenerationError
|
||||||
|
if errors.As(err, &generationErr) {
|
||||||
|
status := generationErr.StatusCode()
|
||||||
|
_ = status
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Provider fields are bounded and normalized but remain untrusted and may
|
||||||
|
contain sensitive request or schema details. Default and Go-syntax formatting
|
||||||
|
omit those fields. Applications must apply their own disclosure policy before
|
||||||
|
logging or presenting accessor values.
|
||||||
|
|
||||||
|
See the [`GenerationError` GoDoc](../../generation_error.go), the
|
||||||
|
[consumer error-handling guide](../consumers/pkg-promptkit.md#handle-errors),
|
||||||
|
and the [OpenAI-compatible response contract](../integrations/openai-compatible-chat.md#response-handling).
|
||||||
|
|
||||||
|
## Optional Credential Sources
|
||||||
|
|
||||||
|
`APIKeyEnv` names an optional environment lookup source unless the selected
|
||||||
|
profile explicitly sets `APIKeyRequired`. When neither a direct request key nor
|
||||||
|
a usable environment value exists, the built-in client omits the bearer header
|
||||||
|
and handles the upstream response normally. This supports local and other
|
||||||
|
OpenAI-compatible providers that permit unauthenticated requests without
|
||||||
|
hiding an authentication error returned by a provider that requires one.
|
||||||
|
|
||||||
|
The [credential format reference](../formats.md#credentials), the
|
||||||
|
[`Backend` GoDoc](../../backends.go), the
|
||||||
|
[`ExecutionTargetOverride` GoDoc](../../types.go), and the
|
||||||
|
[authentication integration contract](../integrations/openai-compatible-chat.md#authentication)
|
||||||
|
define the current precedence and availability rules.
|
||||||
|
|
||||||
|
## Profile Inheritance
|
||||||
|
|
||||||
|
YAML profiles can name one parent with `base_profile`; in-memory profiles use
|
||||||
|
`Profile.BaseProfileID`, and `OpenAICompatibleProfileConfig` forwards the same
|
||||||
|
field. A profile can act as an application-owned alias of a built-in or refine
|
||||||
|
selected inherited settings:
|
||||||
|
|
||||||
|
```go
|
||||||
|
promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: "weather-light",
|
||||||
|
BaseProfileID: "deepseek-4-flash",
|
||||||
|
ReasoningEffort: "high",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Base lookup observes the existing source precedence. Chains are linear,
|
||||||
|
cycle-safe, and resolved afresh for ordinary operations. Prepared execution
|
||||||
|
freezes the fully resolved target. The selected leaf ID remains public while
|
||||||
|
effective execution settings reflect the resolved chain.
|
||||||
|
|
||||||
|
See the [profile inheritance format reference](../formats.md#profile-inheritance),
|
||||||
|
the [consumer alias example](../consumers/pkg-promptkit.md#alias-a-built-in-profile),
|
||||||
|
and the [`Profile` GoDoc](../../types.go) for exact merge and validation
|
||||||
|
behavior.
|
||||||
|
|
||||||
|
## Public API Changes
|
||||||
|
|
||||||
|
The release adds:
|
||||||
|
|
||||||
|
- `BackendRakestrawHome`;
|
||||||
|
- `GenerationError`, including `StatusCode`, `ProviderCode`, `ProviderType`,
|
||||||
|
`ProviderMessage`, `Error`, `GoString`, and `Unwrap`;
|
||||||
|
- `Profile.BaseProfileID`; and
|
||||||
|
- `OpenAICompatibleProfileConfig.BaseProfileID`.
|
||||||
|
|
||||||
|
No public declaration was removed.
|
||||||
|
|
||||||
|
## Consumer Action
|
||||||
|
|
||||||
|
- Remove a manual backend registration whose ID is exactly `rakestrawhome`.
|
||||||
|
- Convert positional `Profile` or `OpenAICompatibleProfileConfig` literals to
|
||||||
|
keyed literals.
|
||||||
|
- Set `Profile.APIKeyRequired` where a missing credential must fail locally
|
||||||
|
instead of reaching the provider unauthenticated.
|
||||||
|
- Treat `GenerationError` provider fields as untrusted and potentially
|
||||||
|
sensitive when adopting the new accessors.
|
||||||
|
- Run consumer ordinary and race-enabled tests after updating the module.
|
||||||
109
docs/releases/v0.8.0.md
Normal file
109
docs/releases/v0.8.0.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Promptkit v0.8.0
|
||||||
|
|
||||||
|
This supplemental changelog and migration guide summarizes the consumer-facing
|
||||||
|
changes from `v0.7.0` to `v0.8.0`. The annotated `v0.8.0` tag is the
|
||||||
|
authoritative release record. Exact current contracts belong to the linked
|
||||||
|
GoDoc and durable documentation.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`v0.8.0` activates Promptkit's bounded output-repair workflow:
|
||||||
|
|
||||||
|
- failed nonempty-text, JSON, and JSON Schema validation can make a limited
|
||||||
|
number of corrective model calls;
|
||||||
|
- corrective calls preserve the original rendered conversation, effective
|
||||||
|
target, session, structured-output contract, and backend capacity policy;
|
||||||
|
- results report cumulative usage and the number of corrective calls actually
|
||||||
|
made; and
|
||||||
|
- explicitly empty OpenAI-compatible response content now reaches output
|
||||||
|
validation instead of being classified as a malformed provider envelope.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This release adds no public declarations or fields and removes none. Existing
|
||||||
|
source code remains source-compatible.
|
||||||
|
|
||||||
|
The behavior of the existing `OutputContract.RepairAttempts` field and prompt
|
||||||
|
YAML `repair_attempts` field has changed. A positive value now authorizes real
|
||||||
|
additional model calls after eligible validation failures; earlier releases
|
||||||
|
accepted the field but the public engine remained single-pass. Consumers that
|
||||||
|
set a positive value should expect additional latency, token usage, and
|
||||||
|
provider cost when repair is needed.
|
||||||
|
|
||||||
|
Repair budgets must now be between zero and three. A positive budget requires
|
||||||
|
`basic`, `json`, or `json_schema` validation. Values above three and a positive
|
||||||
|
budget paired with `none` are invalid contracts rather than ignored settings.
|
||||||
|
|
||||||
|
An explicitly present empty or whitespace-only string returned by the built-in
|
||||||
|
OpenAI-compatible client is now a completed generation candidate. `none`
|
||||||
|
validation permits it, while `basic`, `json`, and `json_schema` classify it
|
||||||
|
under their ordinary validation rules and may repair it when configured.
|
||||||
|
Missing, `null`, or non-string content remains a malformed provider response.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Update the module dependency with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/promptkit@v0.8.0
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
Review every prompt definition and request override that sets a positive repair
|
||||||
|
budget. Use zero or omit the field to retain single-pass execution. Ensure each
|
||||||
|
positive budget is no greater than three and uses an eligible validation mode,
|
||||||
|
then run the consuming project's ordinary and race-enabled tests.
|
||||||
|
|
||||||
|
## Bounded Output Repair
|
||||||
|
|
||||||
|
`repair_attempts` counts corrective calls in addition to the initial model
|
||||||
|
call. Promptkit validates each completed candidate, stops at the first valid
|
||||||
|
one, and never exceeds the configured bound. If every candidate remains
|
||||||
|
invalid, the run completes successfully with the final candidate and its
|
||||||
|
failed validation result rather than returning an operational error.
|
||||||
|
|
||||||
|
Each correction starts from the original rendered messages and includes only
|
||||||
|
the latest invalid candidate and latest validation diagnostics. JSON Schema
|
||||||
|
mode retains the provider-native structured-output request as its first line of
|
||||||
|
defense. Promptkit performs only deterministic structural validation; a valid
|
||||||
|
response is not necessarily factual or correct for an application's domain.
|
||||||
|
|
||||||
|
Usage in the final result is cumulative across the initial response and every
|
||||||
|
completed corrective response. `ValidationResult.RepairAttempts` reports the
|
||||||
|
number of corrective calls actually made. Corrective generation failures use
|
||||||
|
the same public generation-error categories and structured provider details as
|
||||||
|
an initial generation failure.
|
||||||
|
|
||||||
|
See the [output-contract format reference](../formats.md#output-contract), the
|
||||||
|
[consumer repair example](../consumers/pkg-promptkit.md#repair-a-structured-result),
|
||||||
|
and the [`OutputContract` and `ValidationResult` GoDoc](../../types.go) for the
|
||||||
|
current contracts.
|
||||||
|
|
||||||
|
## Explicit Empty Content
|
||||||
|
|
||||||
|
The built-in OpenAI-compatible client now distinguishes an explicitly present
|
||||||
|
empty string from a missing or malformed `content` field. This aligns built-in
|
||||||
|
and injected clients by letting the selected output contract decide whether an
|
||||||
|
empty candidate is acceptable, invalid, or eligible for repair.
|
||||||
|
|
||||||
|
See the
|
||||||
|
[OpenAI-compatible response contract](../integrations/openai-compatible-chat.md#response-handling)
|
||||||
|
for the exact envelope behavior.
|
||||||
|
|
||||||
|
## Public API Changes
|
||||||
|
|
||||||
|
None. This release activates and tightens the documented behavior of existing
|
||||||
|
fields.
|
||||||
|
|
||||||
|
## Consumer Action
|
||||||
|
|
||||||
|
- Remove or set `repair_attempts` to zero where execution must remain
|
||||||
|
single-pass.
|
||||||
|
- Keep every positive repair budget at three or fewer and pair it with
|
||||||
|
`basic`, `json`, or `json_schema` validation.
|
||||||
|
- Account for additional latency, usage, and provider cost when enabling
|
||||||
|
repair.
|
||||||
|
- Continue checking the returned validation status because bounded repair can
|
||||||
|
exhaust without producing a valid candidate.
|
||||||
|
- Review workflows that previously treated explicit empty provider content as
|
||||||
|
a generation error.
|
||||||
125
docs/releases/v0.9.0.md
Normal file
125
docs/releases/v0.9.0.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# Promptkit v0.9.0
|
||||||
|
|
||||||
|
This supplemental changelog and migration guide summarizes the consumer-facing
|
||||||
|
changes from `v0.8.0` to `v0.9.0`. The annotated `v0.9.0` tag is the
|
||||||
|
authoritative release record. Exact current contracts belong to the linked
|
||||||
|
GoDoc and durable documentation.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`v0.9.0` adds stateless request-message composition and separates maintained
|
||||||
|
provider data from Promptkit's core implementation:
|
||||||
|
|
||||||
|
- callers can append already-rendered messages to a configured prompt for
|
||||||
|
application-owned conversations and semantic correction workflows;
|
||||||
|
- the public package now publishes constants for the four supported text-chat
|
||||||
|
roles, and prompt definitions use the same normalized role vocabulary; and
|
||||||
|
- the OpenRouter and Rakestrawhome backend/profile catalogs now come from two
|
||||||
|
independently versioned Go module dependencies.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This release adds one field and four constants to the public API and removes no
|
||||||
|
public declarations. Existing keyed `RunRequest` literals that omit
|
||||||
|
`AppendedMessages` retain their behavior. Adding the field changes the struct
|
||||||
|
shape, so consumers using positional `RunRequest` literals must convert them
|
||||||
|
to keyed literals.
|
||||||
|
|
||||||
|
Message roles in prompt definitions are now trimmed, lowercased, and required
|
||||||
|
to be `developer`, `system`, `user`, or `assistant`. Definitions using another
|
||||||
|
role that earlier releases accepted as an arbitrary nonblank string now fail
|
||||||
|
prompt loading. In particular, the text-only message contract does not support
|
||||||
|
`tool` or the deprecated `function` role. Otherwise valid roles with different
|
||||||
|
case or surrounding whitespace are normalized rather than rejected.
|
||||||
|
|
||||||
|
The catalog extraction preserves Promptkit's public API, built-in backend and
|
||||||
|
profile IDs, configuration, precedence, credential handling, and capacity
|
||||||
|
behavior. Consumers do not import or register either catalog themselves, and
|
||||||
|
no configuration migration is required. Promptkit now selects two
|
||||||
|
independently versioned data dependencies and guarantees only the catalog
|
||||||
|
versions selected and tested by this Promptkit release.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Update the module dependency with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/promptkit@v0.9.0
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
Convert any positional `RunRequest` literals to keyed literals. Review prompt
|
||||||
|
definitions for unsupported roles, then run the consuming project's ordinary
|
||||||
|
and race-enabled tests.
|
||||||
|
|
||||||
|
## Appended Request Messages
|
||||||
|
|
||||||
|
`RunRequest.AppendedMessages` accepts caller-owned `RenderedMessage` values
|
||||||
|
that Promptkit validates, copies, and appends after every rendered
|
||||||
|
prompt-definition message in caller order. Promptkit does not template this
|
||||||
|
content, retain conversation state between calls, impose a retry policy, or
|
||||||
|
apply a message-count, byte-size, token, or context-window limit. Upstream
|
||||||
|
rejections continue through the ordinary generation-error boundary.
|
||||||
|
|
||||||
|
This primitive supports application-owned conversation continuations and
|
||||||
|
domain-aware correction loops while preserving Promptkit's existing
|
||||||
|
preparation, hashing, prepared-execution, structural repair, backend-capacity,
|
||||||
|
credential, and cancellation behavior. Appended content can include sensitive
|
||||||
|
model output or application feedback; prepared values expose the complete
|
||||||
|
effective messages by design, while default request formatting reports only
|
||||||
|
the appended-message count.
|
||||||
|
|
||||||
|
See the
|
||||||
|
[consumer appended-message example](../consumers/pkg-promptkit.md#append-already-rendered-messages),
|
||||||
|
the [`RunRequest`, `RenderedMessage`, and `CacheControl` GoDoc](../../types.go),
|
||||||
|
and the [OpenAI-compatible request contract](../integrations/openai-compatible-chat.md#request-body)
|
||||||
|
for current behavior.
|
||||||
|
|
||||||
|
## Supported Message Roles
|
||||||
|
|
||||||
|
The new `RoleDeveloper`, `RoleSystem`, `RoleUser`, and `RoleAssistant`
|
||||||
|
constants identify the complete role vocabulary accepted by Promptkit's
|
||||||
|
text-chat message model. The same validation and normalization now apply to
|
||||||
|
prompt-definition messages and request-supplied appended messages. Promptkit
|
||||||
|
does not translate between roles; provider- or model-specific rejection of an
|
||||||
|
otherwise supported role remains an upstream generation error.
|
||||||
|
|
||||||
|
See the [message format reference](../formats.md#messages-and-templates) for
|
||||||
|
the canonical prompt-definition contract.
|
||||||
|
|
||||||
|
## Independently Versioned Backend Catalogs
|
||||||
|
|
||||||
|
Promptkit imports immutable catalog data for its maintained OpenRouter and
|
||||||
|
Rakestrawhome backends and profiles. Engine construction validates and
|
||||||
|
assembles both catalogs behind the existing built-in registry and profile
|
||||||
|
precedence rules. Promptkit no longer keeps duplicate embedded profile assets
|
||||||
|
or hard-coded definitions for those maintained backends.
|
||||||
|
|
||||||
|
The module versions in Promptkit's `go.mod` identify the catalog releases
|
||||||
|
tested with this release. The [built-in backend and profile format
|
||||||
|
reference](../formats.md#built-in-backends) remains the canonical consumer
|
||||||
|
contract, while the [internal source documentation](../internal/sources.md#profiles-and-built-ins)
|
||||||
|
describes the dependency boundary.
|
||||||
|
|
||||||
|
## Public API Changes
|
||||||
|
|
||||||
|
The release adds:
|
||||||
|
|
||||||
|
- `RunRequest.AppendedMessages`;
|
||||||
|
- `RoleDeveloper`;
|
||||||
|
- `RoleSystem`;
|
||||||
|
- `RoleUser`; and
|
||||||
|
- `RoleAssistant`.
|
||||||
|
|
||||||
|
No public declaration was removed.
|
||||||
|
|
||||||
|
## Consumer Action
|
||||||
|
|
||||||
|
- Convert positional `RunRequest` literals to keyed literals.
|
||||||
|
- Replace unsupported prompt-definition roles with an appropriate supported
|
||||||
|
role, or keep richer tool-call protocols in an application-owned client.
|
||||||
|
- Treat appended messages and prepared effective messages according to the
|
||||||
|
application's sensitive-data policy.
|
||||||
|
- Do not add direct catalog imports or registration calls; existing Promptkit
|
||||||
|
construction and configuration remain correct.
|
||||||
|
- Run consumer ordinary and race-enabled tests after updating the module.
|
||||||
@@ -38,33 +38,8 @@ consumers.
|
|||||||
|
|
||||||
## Ideas
|
## Ideas
|
||||||
|
|
||||||
### Public bounded output repair
|
No ideas are currently awaiting selection. Active feature work belongs in its
|
||||||
|
focused roadmap rather than this catalog.
|
||||||
After the codebase-audit remediations are complete, Promptkit should make its
|
|
||||||
bounded output-repair capability available through the public engine. A
|
|
||||||
consumer should be able to request a limited number of corrective generation
|
|
||||||
attempts when JSON or JSON Schema output fails content validation, without
|
|
||||||
having to reproduce Promptkit's generation, validation, capacity, and result-
|
|
||||||
accounting orchestration.
|
|
||||||
|
|
||||||
- Repair is validation recovery, not a general provider retry, failover, or
|
|
||||||
backoff policy. Transport failures, cancellation, and operational schema or
|
|
||||||
validation errors must retain their ordinary error behavior.
|
|
||||||
- Repair must stop after the first valid result or the configured attempt
|
|
||||||
bound. Exhausting the bound should preserve the final invalid result and its
|
|
||||||
validation diagnostics rather than inventing success.
|
|
||||||
- Initial generation and every repair attempt must use the same resolved
|
|
||||||
backend, effective execution settings and presence semantics, session,
|
|
||||||
credential boundary, structured-output contract, and backend-capacity
|
|
||||||
policy.
|
|
||||||
- Results should report the number of repair attempts and cumulative usage for
|
|
||||||
every model call made by the run.
|
|
||||||
- Ordinary and prepared execution should expose coherent behavior, including
|
|
||||||
cancellation, frozen prepared state, error identity, and capacity lifetime.
|
|
||||||
|
|
||||||
Select this work only after the accepted audit findings affecting shared
|
|
||||||
execution invariants, validation, orchestration, transport, and repair
|
|
||||||
internals have been remediated.
|
|
||||||
|
|
||||||
## Entry Format
|
## Entry Format
|
||||||
|
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
# Structured Generation Errors
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Promptkit should give downstream applications actionable, machine-readable
|
|
||||||
details when the built-in OpenAI-compatible client receives a non-success HTTP
|
|
||||||
response. Today the client reports only the status code and discards the
|
|
||||||
provider response body. This makes ordinary configuration failures—such as an
|
|
||||||
unsupported strict JSON Schema keyword—unnecessarily difficult to diagnose.
|
|
||||||
|
|
||||||
## Target End State
|
|
||||||
|
|
||||||
Failures from the built-in transport are available through a public typed error
|
|
||||||
that works with `errors.As` while continuing to match `ErrLLMGenerate` through
|
|
||||||
`errors.Is`. The error should expose:
|
|
||||||
|
|
||||||
- the HTTP status code;
|
|
||||||
- a normalized provider error code or type when supplied; and
|
|
||||||
- a bounded provider message extracted from a recognized OpenAI-compatible
|
|
||||||
JSON error envelope.
|
|
||||||
|
|
||||||
The ordinary `Error()` string should remain safe and concise: it should include
|
|
||||||
the status and provider code or type, but not automatically include the
|
|
||||||
provider message. Consumers that deliberately want the provider's diagnostic
|
|
||||||
text can retrieve it from the typed error and apply their own disclosure and
|
|
||||||
logging policy.
|
|
||||||
|
|
||||||
This contract should be available for both ordinary and prepared execution.
|
|
||||||
Errors returned by injected model clients must continue to preserve their own
|
|
||||||
identity and should not be converted into fabricated HTTP details.
|
|
||||||
|
|
||||||
## Safety And Compatibility Boundaries
|
|
||||||
|
|
||||||
- Never expose the raw response body, response headers, endpoint, credentials,
|
|
||||||
request messages, schema document, or generated content through this API.
|
|
||||||
- Read only a small fixed maximum response body, reject malformed or
|
|
||||||
unrecognized envelopes, normalize invalid UTF-8 and control characters, and
|
|
||||||
cap every retained diagnostic field independently.
|
|
||||||
- Treat the extracted provider message as untrusted and potentially sensitive:
|
|
||||||
its GoDoc must tell consumers not to log or display it without applying their
|
|
||||||
own policy.
|
|
||||||
- Preserve the existing generic behavior when a response is empty, non-JSON,
|
|
||||||
oversized, or does not match a recognized error envelope.
|
|
||||||
- Do not assign retryability from an HTTP status. Promptkit supplies facts;
|
|
||||||
downstream applications retain retry and presentation policy.
|
|
||||||
|
|
||||||
## Recommended API Direction
|
|
||||||
|
|
||||||
Prefer one immutable public `GenerationError` value, constructed internally and
|
|
||||||
carrying accessors for HTTP status, provider code or type, and provider message.
|
|
||||||
This keeps the exact representation evolvable while giving consumers an
|
|
||||||
idiomatic `errors.As` contract. Public Go declarations and GoDoc should own the
|
|
||||||
final exact names and semantics.
|
|
||||||
|
|
||||||
The internal OpenAI-compatible client should parse only the conventional
|
|
||||||
top-level `error` envelope and pass normalized details through the use-case and
|
|
||||||
public error-mapping layers. The integration documentation should continue to
|
|
||||||
own wire behavior; the public declarations should own the consumer contract.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- A downstream consumer can distinguish a provider HTTP 400 from other
|
|
||||||
generation failures and obtain a bounded provider explanation when present.
|
|
||||||
- The typed error still satisfies `errors.Is(err, ErrLLMGenerate)`.
|
|
||||||
- Existing cancellation, capacity, validation, and injected-client error
|
|
||||||
identities remain unchanged.
|
|
||||||
- Tests cover recognized string and numeric provider codes, absent and malformed
|
|
||||||
envelopes, oversized bodies and fields, control characters, and error-chain
|
|
||||||
behavior without making live provider requests.
|
|
||||||
- Current-state GoDoc and the OpenAI-compatible integration and internal-client
|
|
||||||
documents are updated only when the implementation lands.
|
|
||||||
397
engine.go
397
engine.go
@@ -6,23 +6,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
artifactadapter "gitea.maximumdirect.net/eric/promptkit/internal/artifact"
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
internalengine "gitea.maximumdirect.net/eric/promptkit/internal/engine"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
"gitea.maximumdirect.net/eric/promptkit/internal/observation"
|
||||||
"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/usecase"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/validate"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrInvalidConfig identifies invalid engine construction, including missing
|
// ErrInvalidConfig identifies invalid engine construction, including missing
|
||||||
@@ -53,9 +42,9 @@ var (
|
|||||||
// an execution profile or resolve its backend, except for the profile
|
// an execution profile or resolve its backend, except for the profile
|
||||||
// not-found case represented by ErrProfileNotFound.
|
// not-found case represented by ErrProfileNotFound.
|
||||||
ErrProfileLoad = errors.New("failed to load execution profile")
|
ErrProfileLoad = errors.New("failed to load execution profile")
|
||||||
// ErrAPIKeyEnvMissing identifies an APIKeyEnv whose environment variable is
|
// ErrAPIKeyEnvMissing identifies an explicitly required APIKeyEnv whose
|
||||||
// unset or empty when no direct RunRequest.APIKey takes precedence. Such an
|
// environment variable is unset or empty after direct RunRequest.APIKey
|
||||||
// error also matches ErrInvalidRequest.
|
// precedence is applied. Such an error also matches ErrInvalidRequest.
|
||||||
ErrAPIKeyEnvMissing = errors.New("api_key_env points to an unset environment variable")
|
ErrAPIKeyEnvMissing = errors.New("api_key_env points to an unset environment variable")
|
||||||
// ErrArtifactLoad identifies a failure to resolve an input artifact. Errors
|
// ErrArtifactLoad identifies a failure to resolve an input artifact. Errors
|
||||||
// returned by an injected ArtifactReader remain available through errors.Is.
|
// returned by an injected ArtifactReader remain available through errors.Is.
|
||||||
@@ -69,9 +58,23 @@ var (
|
|||||||
// request, an LLM or provider rate-limit response, or ErrLLMGenerate.
|
// request, an LLM or provider rate-limit response, or ErrLLMGenerate.
|
||||||
ErrCapacityExceeded = errors.New("backend capacity exceeded")
|
ErrCapacityExceeded = errors.New("backend capacity exceeded")
|
||||||
// ErrLLMGenerate identifies a model-client failure or a nil successful
|
// ErrLLMGenerate identifies a model-client failure or a nil successful
|
||||||
// response. Errors returned by an injected LLMClient remain available
|
// response. A built-in OpenAI-compatible non-2xx response is available as a
|
||||||
// through errors.Is.
|
// [GenerationError]. Errors returned by an injected LLMClient remain
|
||||||
|
// available through errors.Is.
|
||||||
ErrLLMGenerate = errors.New("failed to generate output")
|
ErrLLMGenerate = errors.New("failed to generate output")
|
||||||
|
// ErrTransport identifies a failed HTTP request by the built-in model
|
||||||
|
// client. Generation errors in this category also match ErrLLMGenerate and
|
||||||
|
// retain the transport cause for errors.Is and errors.As. It does not imply
|
||||||
|
// that the provider received no work.
|
||||||
|
ErrTransport = errors.New("model transport failed")
|
||||||
|
// ErrMalformedResponse identifies an invalid successful model response or
|
||||||
|
// a nil successful response from an injected client. Generation errors in
|
||||||
|
// this category also match ErrLLMGenerate.
|
||||||
|
ErrMalformedResponse = errors.New("malformed model response")
|
||||||
|
// ErrRetriesExhausted identifies a built-in generation sequence whose
|
||||||
|
// positive transient retry budget ended with another eligible HTTP status.
|
||||||
|
// RetryExhaustedError retains the final cause and actual attempt count.
|
||||||
|
ErrRetriesExhausted = errors.New("transient model retries exhausted")
|
||||||
// ErrValidation identifies an operational failure to load or compile a
|
// ErrValidation identifies an operational failure to load or compile a
|
||||||
// schema or validate output. A completed validation whose Status is
|
// schema or validate output. A completed validation whose Status is
|
||||||
// ValidationFailed is returned in RunResult without this error.
|
// ValidationFailed is returned in RunResult without this error.
|
||||||
@@ -88,7 +91,7 @@ var (
|
|||||||
// model generation. Injected collaborators may still be invoked concurrently
|
// model generation. Injected collaborators may still be invoked concurrently
|
||||||
// across different backend pools or for unlimited backends.
|
// across different backend pools or for unlimited backends.
|
||||||
type Engine struct {
|
type Engine struct {
|
||||||
runner *usecase.Runner
|
internal *internalengine.Engine
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config selects the directory-backed sources and built-in model-client
|
// Config selects the directory-backed sources and built-in model-client
|
||||||
@@ -99,7 +102,7 @@ type Config struct {
|
|||||||
// prompt source.
|
// prompt source.
|
||||||
PromptDir string
|
PromptDir string
|
||||||
// ProfileDir is an optional ordinary configured source whose profiles take
|
// ProfileDir is an optional ordinary configured source whose profiles take
|
||||||
// precedence over application fallback and embedded built-in profiles. An
|
// precedence over application fallback and maintained catalog profiles. An
|
||||||
// empty value selects the lower-precedence sources unless a profile-source
|
// empty value selects the lower-precedence sources unless a profile-source
|
||||||
// option supplies the ordinary source.
|
// option supplies the ordinary source.
|
||||||
ProfileDir string
|
ProfileDir string
|
||||||
@@ -115,6 +118,11 @@ type Config struct {
|
|||||||
// Timeout or the 10-minute default. The supplied client is not mutated. This
|
// Timeout or the 10-minute default. The supplied client is not mutated. This
|
||||||
// field is ignored when WithLLMClient is used.
|
// field is ignored when WithLLMClient is used.
|
||||||
HTTPClient *http.Client
|
HTTPClient *http.Client
|
||||||
|
// TransientRetries selects additional built-in HTTP attempts after eligible
|
||||||
|
// provider statuses. Nil defaults to two; zero disables retries. Negative
|
||||||
|
// values are invalid. Injected LLMClient implementations are never retried
|
||||||
|
// automatically.
|
||||||
|
TransientRetries *int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option customizes engine construction.
|
// Option customizes engine construction.
|
||||||
@@ -137,20 +145,7 @@ func (f optionFunc) apply(options *engineOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type engineOptions struct {
|
type engineOptions struct {
|
||||||
llmClient llm.Client
|
internal internalengine.Options
|
||||||
artifactReader artifactadapter.Reader
|
|
||||||
promptDefs promptdef.Repository
|
|
||||||
profiles profile.Repository
|
|
||||||
fallbackProfiles profile.Repository
|
|
||||||
memoryProfiles profile.Repository
|
|
||||||
backends []domain.Backend
|
|
||||||
validator validate.Validator
|
|
||||||
promptSource bool
|
|
||||||
profileSource bool
|
|
||||||
fallbackProfileSource bool
|
|
||||||
memorySource bool
|
|
||||||
validatorSource bool
|
|
||||||
artifactSource bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithLLMClient replaces the built-in model client used by [Engine.Run] and
|
// WithLLMClient replaces the built-in model client used by [Engine.Run] and
|
||||||
@@ -166,7 +161,7 @@ func WithLLMClient(client LLMClient) Option {
|
|||||||
if client == nil {
|
if client == nil {
|
||||||
return ErrInvalidConfig
|
return ErrInvalidConfig
|
||||||
}
|
}
|
||||||
options.llmClient = publicLLMClientAdapter{client: client}
|
options.internal.SetLLMClient(publicLLMClientAdapter{client: client})
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -181,8 +176,7 @@ func WithArtifactReader(reader ArtifactReader) Option {
|
|||||||
if reader == nil {
|
if reader == nil {
|
||||||
return ErrInvalidConfig
|
return ErrInvalidConfig
|
||||||
}
|
}
|
||||||
options.artifactReader = publicArtifactReaderAdapter{reader: reader}
|
options.internal.SetArtifactReader(publicArtifactReaderAdapter{reader: reader})
|
||||||
options.artifactSource = true
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -196,15 +190,7 @@ func WithArtifactReader(reader ArtifactReader) Option {
|
|||||||
// prompt-source options.
|
// prompt-source options.
|
||||||
func WithPromptFS(fsys fs.FS, root string) Option {
|
func WithPromptFS(fsys fs.FS, root string) Option {
|
||||||
return optionFunc(func(options *engineOptions) error {
|
return optionFunc(func(options *engineOptions) error {
|
||||||
if fsys == nil {
|
return options.internal.SetPromptFS(fsys, root)
|
||||||
return ErrInvalidConfig
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(root) == "" {
|
|
||||||
return ErrInvalidConfig
|
|
||||||
}
|
|
||||||
options.promptDefs = promptdef.NewFSRepository(fsys, root)
|
|
||||||
options.promptSource = true
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,13 +201,7 @@ func WithPromptFS(fsys fs.FS, root string) Option {
|
|||||||
// This option replaces Config.PromptDir and earlier prompt-source options.
|
// This option replaces Config.PromptDir and earlier prompt-source options.
|
||||||
func WithPromptFile(path string) Option {
|
func WithPromptFile(path string) Option {
|
||||||
return optionFunc(func(options *engineOptions) error {
|
return optionFunc(func(options *engineOptions) error {
|
||||||
fsys, root, err := fileSource(path)
|
return options.internal.SetPromptFile(path)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
options.promptDefs = promptdef.NewFileRepository(fsys, root, filepath.Dir(path))
|
|
||||||
options.promptSource = true
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,15 +215,7 @@ func WithPromptFile(path string) Option {
|
|||||||
// FS profile-source options, but remains below WithProfiles in precedence.
|
// FS profile-source options, but remains below WithProfiles in precedence.
|
||||||
func WithProfileFS(fsys fs.FS, root string) Option {
|
func WithProfileFS(fsys fs.FS, root string) Option {
|
||||||
return optionFunc(func(options *engineOptions) error {
|
return optionFunc(func(options *engineOptions) error {
|
||||||
if fsys == nil {
|
return options.internal.SetProfileFS(fsys, root)
|
||||||
return ErrInvalidConfig
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(root) == "" {
|
|
||||||
return ErrInvalidConfig
|
|
||||||
}
|
|
||||||
options.profiles = profile.NewFSRepository(fsys, root)
|
|
||||||
options.profileSource = true
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,13 +229,7 @@ func WithProfileFS(fsys fs.FS, root string) Option {
|
|||||||
// precedence.
|
// precedence.
|
||||||
func WithProfileFile(path string) Option {
|
func WithProfileFile(path string) Option {
|
||||||
return optionFunc(func(options *engineOptions) error {
|
return optionFunc(func(options *engineOptions) error {
|
||||||
fsys, root, err := fileSource(path)
|
return options.internal.SetProfileFile(path)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
options.profiles = profile.NewFSRepository(fsys, root)
|
|
||||||
options.profileSource = true
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +238,7 @@ func WithProfileFile(path string) Option {
|
|||||||
//
|
//
|
||||||
// Profile lookup checks, in order, profiles supplied by WithProfiles; the
|
// Profile lookup checks, in order, profiles supplied by WithProfiles; the
|
||||||
// ordinary configured source selected by WithProfileFile, WithProfileFS, or
|
// ordinary configured source selected by WithProfileFile, WithProfileFS, or
|
||||||
// Config.ProfileDir; this fallback source; and Promptkit's embedded built-in
|
// Config.ProfileDir; this fallback source; and Promptkit's maintained catalog
|
||||||
// profiles. Each source supplies a complete profile definition; profile fields
|
// profiles. Each source supplies a complete profile definition; profile fields
|
||||||
// are not merged between sources. Only an absent profile ID proceeds to the
|
// are not merged between sources. Only an absent profile ID proceeds to the
|
||||||
// next source. A matching read, parse, duplicate, validation, or credential
|
// next source. A matching read, parse, duplicate, validation, or credential
|
||||||
@@ -288,34 +254,26 @@ func WithProfileFile(path string) Option {
|
|||||||
// failover.
|
// failover.
|
||||||
func WithFallbackProfileFS(fsys fs.FS, root string) Option {
|
func WithFallbackProfileFS(fsys fs.FS, root string) Option {
|
||||||
return optionFunc(func(options *engineOptions) error {
|
return optionFunc(func(options *engineOptions) error {
|
||||||
if fsys == nil {
|
return options.internal.SetFallbackProfileFS(fsys, root)
|
||||||
return ErrInvalidConfig
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(root) == "" {
|
|
||||||
return ErrInvalidConfig
|
|
||||||
}
|
|
||||||
options.fallbackProfiles = profile.NewFSRepository(fsys, root)
|
|
||||||
options.fallbackProfileSource = true
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithProfiles configures in-memory profiles that take precedence over
|
// WithProfiles configures in-memory profiles that take precedence over
|
||||||
// ordinary configured, application fallback, and built-in profiles.
|
// ordinary configured, application fallback, and built-in profiles.
|
||||||
//
|
//
|
||||||
// NewEngine validates and copies every profile. IDs must be unique within one
|
// NewEngine locally validates and copies every profile. IDs must be unique
|
||||||
// call. An invalid profile, duplicate ID, or unsupported ExtraParams value
|
// within one call. An invalid local definition, duplicate ID, or unsupported
|
||||||
// makes construction fail with ErrInvalidConfig. Repeating WithProfiles
|
// ExtraParams value makes construction fail with ErrInvalidConfig. A derived
|
||||||
// replaces the complete earlier in-memory set rather than merging it.
|
// profile's base reference and resolved target completeness are checked when it
|
||||||
|
// is selected or inspected. Repeating WithProfiles replaces the complete
|
||||||
|
// earlier in-memory set rather than merging it.
|
||||||
func WithProfiles(profiles ...Profile) Option {
|
func WithProfiles(profiles ...Profile) Option {
|
||||||
return optionFunc(func(options *engineOptions) error {
|
return optionFunc(func(options *engineOptions) error {
|
||||||
repo, err := newMemoryProfileRepository(profiles)
|
definitions := make([]domain.ExecutionProfile, len(profiles))
|
||||||
if err != nil {
|
for index, publicProfile := range profiles {
|
||||||
return err
|
definitions[index] = toDomainProfile(publicProfile)
|
||||||
}
|
}
|
||||||
options.memoryProfiles = repo
|
return options.internal.SetMemoryProfiles(definitions)
|
||||||
options.memorySource = true
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,15 +285,7 @@ func WithProfiles(profiles ...Profile) Option {
|
|||||||
// replaces Config.SchemaDir and earlier schema-source options.
|
// replaces Config.SchemaDir and earlier schema-source options.
|
||||||
func WithSchemaFS(fsys fs.FS, root string) Option {
|
func WithSchemaFS(fsys fs.FS, root string) Option {
|
||||||
return optionFunc(func(options *engineOptions) error {
|
return optionFunc(func(options *engineOptions) error {
|
||||||
if fsys == nil {
|
return options.internal.SetSchemaFS(fsys, root)
|
||||||
return ErrInvalidConfig
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(root) == "" {
|
|
||||||
return ErrInvalidConfig
|
|
||||||
}
|
|
||||||
options.validator = validate.NewFSValidator(fsys, root)
|
|
||||||
options.validatorSource = true
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,13 +296,7 @@ func WithSchemaFS(fsys fs.FS, root string) Option {
|
|||||||
// replaces Config.SchemaDir and earlier schema-source options.
|
// replaces Config.SchemaDir and earlier schema-source options.
|
||||||
func WithSchemaFile(path string) Option {
|
func WithSchemaFile(path string) Option {
|
||||||
return optionFunc(func(options *engineOptions) error {
|
return optionFunc(func(options *engineOptions) error {
|
||||||
fsys, root, err := fileSource(path)
|
return options.internal.SetSchemaFile(path)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
options.validator = validate.NewFSValidator(fsys, root)
|
|
||||||
options.validatorSource = true
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,7 +311,8 @@ func WithSchemaFile(path string) Option {
|
|||||||
// NewEngine returns an error matching ErrInvalidConfig for invalid
|
// NewEngine returns an error matching ErrInvalidConfig for invalid
|
||||||
// configuration, options, or backend-capacity policies. Each constructed
|
// configuration, options, or backend-capacity policies. Each constructed
|
||||||
// Engine has independent backend-capacity pools. Construction does not perform
|
// Engine has independent backend-capacity pools. Construction does not perform
|
||||||
// model requests or require credentials.
|
// model requests or require credentials. A negative Config.TransientRetries
|
||||||
|
// is invalid.
|
||||||
func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
|
func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
|
||||||
var options engineOptions
|
var options engineOptions
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
@@ -379,104 +324,14 @@ func NewEngine(cfg Config, opts ...Option) (*Engine, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
promptDefs := options.promptDefs
|
assembled, err := internalengine.New(
|
||||||
if !options.promptSource {
|
internalengine.Config{PromptDir: cfg.PromptDir, ProfileDir: cfg.ProfileDir, SchemaDir: cfg.SchemaDir, Timeout: cfg.Timeout, HTTPClient: cfg.HTTPClient, TransientRetries: copyIntPtr(cfg.TransientRetries)},
|
||||||
if strings.TrimSpace(cfg.PromptDir) == "" {
|
options.internal,
|
||||||
return nil, fmt.Errorf("%w: prompt directory is required", ErrInvalidConfig)
|
)
|
||||||
}
|
|
||||||
promptDefs = promptdef.NewFilesystemRepository(cfg.PromptDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
profiles := newProfileRepository(cfg.ProfileDir, options)
|
|
||||||
|
|
||||||
backendRegistry, err := backend.NewRegistry(options.backends)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: failed to construct backend registry: %v", ErrInvalidConfig, err)
|
return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
|
||||||
}
|
}
|
||||||
|
return &Engine{internal: assembled}, nil
|
||||||
capacityManager, err := capacity.NewManager(backendRegistry.CapacityPolicies())
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: failed to construct backend capacity manager: %v", ErrInvalidConfig, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
llmClient = capacity.NewClient(capacityManager, llmClient)
|
|
||||||
|
|
||||||
artifacts := options.artifactReader
|
|
||||||
if !options.artifactSource {
|
|
||||||
artifacts = artifactadapter.NewCompositeReader()
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Engine{
|
|
||||||
runner: usecase.NewRunner(
|
|
||||||
promptDefs,
|
|
||||||
profiles,
|
|
||||||
backendRegistry,
|
|
||||||
artifacts,
|
|
||||||
prompt.NewGoRenderer(),
|
|
||||||
llmClient,
|
|
||||||
validator,
|
|
||||||
capacityManager,
|
|
||||||
),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newProfileRepository(profileDir string, options engineOptions) profile.Repository {
|
|
||||||
repository := builtin.NewRepository()
|
|
||||||
|
|
||||||
if options.fallbackProfileSource {
|
|
||||||
repository = profile.NewOverlayRepository(options.fallbackProfiles, repository)
|
|
||||||
}
|
|
||||||
|
|
||||||
if options.profileSource {
|
|
||||||
repository = profile.NewOverlayRepository(options.profiles, repository)
|
|
||||||
} else if strings.TrimSpace(profileDir) != "" {
|
|
||||||
repository = profile.NewOverlayRepository(profile.NewFilesystemRepository(profileDir), repository)
|
|
||||||
}
|
|
||||||
|
|
||||||
if options.memorySource {
|
|
||||||
repository = profile.NewOverlayRepository(options.memoryProfiles, repository)
|
|
||||||
}
|
|
||||||
|
|
||||||
return repository
|
|
||||||
}
|
|
||||||
|
|
||||||
func fileSource(name string) (fs.FS, string, error) {
|
|
||||||
if strings.TrimSpace(name) == "" {
|
|
||||||
return nil, "", ErrInvalidConfig
|
|
||||||
}
|
|
||||||
dir := filepath.Dir(name)
|
|
||||||
base := filepath.Base(name)
|
|
||||||
if base == "." || base == string(filepath.Separator) {
|
|
||||||
return nil, "", ErrInvalidConfig
|
|
||||||
}
|
|
||||||
info, err := os.Stat(name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", fmt.Errorf("%w: failed to access source file %q: %v", ErrInvalidConfig, name, err)
|
|
||||||
}
|
|
||||||
if info.IsDir() {
|
|
||||||
return nil, "", fmt.Errorf("%w: source path %q must be a file", ErrInvalidConfig, name)
|
|
||||||
}
|
|
||||||
return os.DirFS(dir), filepath.ToSlash(base), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// InspectPrompt resolves one explicit prompt definition without selecting a
|
// InspectPrompt resolves one explicit prompt definition without selecting a
|
||||||
@@ -515,11 +370,11 @@ func (e *Engine) InspectPrompt(
|
|||||||
promptID string,
|
promptID string,
|
||||||
promptVersion string,
|
promptVersion string,
|
||||||
) (*PromptInspection, error) {
|
) (*PromptInspection, error) {
|
||||||
if e == nil || e.runner == nil {
|
if e == nil || e.internal == nil {
|
||||||
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
inspection, err := e.runner.InspectPrompt(ctx, promptID, promptVersion)
|
inspection, err := e.internal.InspectPrompt(ctx, promptID, promptVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, mapPublicError(err)
|
return nil, mapPublicError(err)
|
||||||
}
|
}
|
||||||
@@ -555,11 +410,11 @@ func (e *Engine) InspectPrompt(
|
|||||||
// profile loading matches ErrProfileLoad while preserving the context error.
|
// profile loading matches ErrProfileLoad while preserving the context error.
|
||||||
// InspectProfile returns no partial result on error.
|
// InspectProfile returns no partial result on error.
|
||||||
func (e *Engine) InspectProfile(ctx context.Context, profileID string) (*ProfileInspection, error) {
|
func (e *Engine) InspectProfile(ctx context.Context, profileID string) (*ProfileInspection, error) {
|
||||||
if e == nil || e.runner == nil {
|
if e == nil || e.internal == nil {
|
||||||
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
inspection, err := e.runner.InspectProfile(ctx, profileID)
|
inspection, err := e.internal.InspectProfile(ctx, profileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, mapPublicError(err)
|
return nil, mapPublicError(err)
|
||||||
}
|
}
|
||||||
@@ -567,6 +422,8 @@ func (e *Engine) InspectProfile(ctx context.Context, profileID string) (*Profile
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prepare resolves and renders a prompt request without calling an LLM.
|
// Prepare resolves and renders a prompt request without calling an LLM.
|
||||||
|
// It appends any validated RunRequest.AppendedMessages after rendered
|
||||||
|
// definition messages in the returned caller-owned snapshot.
|
||||||
//
|
//
|
||||||
// Prepare selects the prompt and profile, resolves any selected backend and
|
// Prepare selects the prompt and profile, resolves any selected backend and
|
||||||
// effective execution settings, resolves the output contract, loads and hashes
|
// effective execution settings, resolves the output contract, loads and hashes
|
||||||
@@ -582,9 +439,10 @@ func (e *Engine) InspectProfile(ctx context.Context, profileID string) (*Profile
|
|||||||
// applicable. Cancellation is passed to the active collaborator and is
|
// applicable. Cancellation is passed to the active collaborator and is
|
||||||
// reported in the applicable operation category; no general errors.Is
|
// reported in the applicable operation category; no general errors.Is
|
||||||
// relationship to ctx.Err is promised. Prepare returns no partial result on
|
// relationship to ctx.Err is promised. Prepare returns no partial result on
|
||||||
// error.
|
// error. It validates RunRequest.TransientRetries but makes no model attempts.
|
||||||
func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, error) {
|
func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, error) {
|
||||||
if e == nil || e.runner == nil {
|
ctx = observation.WithoutExecutionState(ctx)
|
||||||
|
if e == nil || e.internal == nil {
|
||||||
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,7 +451,7 @@ func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, err
|
|||||||
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
prepared, err := e.runner.Prepare(ctx, domainReq)
|
prepared, err := e.internal.Prepare(ctx, domainReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, mapPublicError(err)
|
return nil, mapPublicError(err)
|
||||||
}
|
}
|
||||||
@@ -601,7 +459,8 @@ func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PrepareExecution completely prepares a prompt request without calling the
|
// PrepareExecution completely prepares a prompt request without calling the
|
||||||
// configured LLMClient or reserving backend admission capacity.
|
// configured LLMClient or reserving backend admission capacity. Validated
|
||||||
|
// RunRequest.AppendedMessages are included in the frozen effective messages.
|
||||||
//
|
//
|
||||||
// The returned opaque handle is bound to this Engine and permits one
|
// The returned opaque handle is bound to this Engine and permits one
|
||||||
// [Engine.RunPrepared] invocation. Preparation freezes the selected sources,
|
// [Engine.RunPrepared] invocation. Preparation freezes the selected sources,
|
||||||
@@ -614,9 +473,12 @@ func (e *Engine) Prepare(ctx context.Context, req RunRequest) (*PreparedRun, err
|
|||||||
// returns does not invalidate the handle or propagate to RunPrepared.
|
// returns does not invalidate the handle or propagate to RunPrepared.
|
||||||
// PrepareExecution returns the same error categories as [Engine.Prepare] and
|
// PrepareExecution returns the same error categories as [Engine.Prepare] and
|
||||||
// returns no handle on error. A nil Engine returns an error matching
|
// returns no handle on error. A nil Engine returns an error matching
|
||||||
// ErrInvalidConfig.
|
// ErrInvalidConfig. The effective transient retry budget is frozen privately
|
||||||
|
// for RunPrepared and is absent from Details. A supplied Observer is retained
|
||||||
|
// privately for RunPrepared; preparation itself emits no events.
|
||||||
func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*PreparedExecution, error) {
|
func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*PreparedExecution, error) {
|
||||||
if e == nil || e.runner == nil {
|
ctx = observation.WithoutExecutionState(ctx)
|
||||||
|
if e == nil || e.internal == nil {
|
||||||
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,7 +487,7 @@ func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*Prepare
|
|||||||
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
prepared, err := e.runner.PrepareExecution(ctx, domainReq)
|
prepared, err := e.internal.PrepareExecution(ctx, domainReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, mapPublicError(err)
|
return nil, mapPublicError(err)
|
||||||
}
|
}
|
||||||
@@ -633,26 +495,67 @@ func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*Prepare
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run prepares a request, invokes the configured LLMClient, and validates the
|
// Run prepares a request, invokes the configured LLMClient, and validates the
|
||||||
// generated output.
|
// generated output. Each call resolves current sources and composes a fresh,
|
||||||
|
// stateless effective prompt with any validated RunRequest.AppendedMessages.
|
||||||
//
|
//
|
||||||
// A content-validation failure is a successful run whose
|
// A content-validation failure is a successful run whose
|
||||||
// RunResult.Validation has Status ValidationFailed. An inability to perform
|
// RunResult.Validation has Status ValidationFailed. When its output contract
|
||||||
// validation returns an error matching ErrValidation and no partial result.
|
// has a positive repair budget, a failed eligible validation can make bounded
|
||||||
// The public Engine does not perform output repair, so validation is
|
// additional model calls and stops at the first valid candidate. Exhaustion
|
||||||
// single-pass even when OutputContract.RepairAttempts is positive.
|
// returns the final failed validation result with cumulative usage and actual
|
||||||
|
// repair attempts. An inability to generate or validate returns an error and
|
||||||
|
// no partial result.
|
||||||
|
//
|
||||||
|
// The built-in client retries eligible provider statuses using the effective
|
||||||
|
// Config or request budget. With R additional transient attempts and P repair
|
||||||
|
// generations, one run can dispatch at most (R+1)×(P+1) Promptkit-controlled
|
||||||
|
// model attempts. Each dispatched attempt receives its own generation and
|
||||||
|
// HTTP-client timeout; the caller context governs the full operation.
|
||||||
|
// Injected LLMClient implementations are not retried automatically.
|
||||||
|
// With Observer, Run emits started before facade request conversion or a nil
|
||||||
|
// engine check, and finished on every normal return after public error mapping
|
||||||
|
// and resource release. The event RunID matches a successful result. Without
|
||||||
|
// Observer, result timing retains its existing convention; observed wall
|
||||||
|
// duration can be slightly longer because it spans the facade boundary.
|
||||||
//
|
//
|
||||||
// Run can return every error category documented by [Engine.Prepare], plus
|
// Run can return every error category documented by [Engine.Prepare], plus
|
||||||
// ErrCapacityExceeded and ErrLLMGenerate. An engine admission rejection is
|
// ErrCapacityExceeded and ErrLLMGenerate. Built-in HTTP execution failures
|
||||||
|
// additionally match ErrTransport; malformed successful responses and nil
|
||||||
|
// successful injected responses additionally match ErrMalformedResponse.
|
||||||
|
// An engine admission rejection is
|
||||||
// discoverable as [CapacityError] and still matches ErrCapacityExceeded. It
|
// discoverable as [CapacityError] and still matches ErrCapacityExceeded. It
|
||||||
// occurs before artifacts, schemas, rendering, or model generation because the
|
// occurs before artifacts, schemas, rendering, or model generation because the
|
||||||
// selected backend's admission capacity is full; it does not match
|
// selected backend's admission capacity is full; it does not match
|
||||||
// ErrInvalidRequest or ErrLLMGenerate. Errors from injected clients remain
|
// ErrInvalidRequest or ErrLLMGenerate. A built-in OpenAI-compatible non-2xx
|
||||||
// available through errors.Is. Cancellation while waiting for model-generation
|
// response is discoverable as [GenerationError]. Eligible-status exhaustion
|
||||||
// capacity matches both ErrLLMGenerate and the context error. Cancellation
|
// with a positive budget is discoverable as [RetryExhaustedError]. Injected
|
||||||
// otherwise follows the active collaborator's documented behavior. A nil
|
// errors remain available through errors.Is. Cancellation while waiting for
|
||||||
// Engine returns ErrInvalidConfig. Run returns no partial result on error.
|
// model-generation capacity matches both ErrLLMGenerate and the context error.
|
||||||
|
// Cancellation otherwise follows the active collaborator's documented
|
||||||
|
// behavior. A nil Engine returns ErrInvalidConfig. Run returns no partial
|
||||||
|
// result on error.
|
||||||
func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||||
if e == nil || e.runner == nil {
|
ctx = observation.WithoutExecutionState(ctx)
|
||||||
|
if req.Observer == nil {
|
||||||
|
return e.run(ctx, req)
|
||||||
|
}
|
||||||
|
recorder, idErr := observation.New(func(event domain.ExecutionEvent) { req.Observer(fromDomainExecutionEvent(event)) })
|
||||||
|
recorder.Start()
|
||||||
|
if idErr != nil {
|
||||||
|
err := fmt.Errorf("failed to create run id: %w", idErr)
|
||||||
|
terminal := observedFinish(nil, err)
|
||||||
|
recorder.Finish(observation.FinishData{Outcome: terminal.Outcome, Failure: terminal.Failure})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result, err := e.run(observation.WithRecorder(ctx, recorder), req)
|
||||||
|
terminal := observedFinish(result, err)
|
||||||
|
recorder.Finish(observation.FinishData{Outcome: terminal.Outcome, Failure: terminal.Failure,
|
||||||
|
StatusCode: terminal.StatusCode, RetriesExhausted: terminal.RetriesExhausted})
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||||
|
if e == nil || e.internal == nil {
|
||||||
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,7 +564,7 @@ func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
|||||||
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := e.runner.Run(ctx, domainReq)
|
result, err := e.internal.Run(ctx, domainReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, mapPublicError(err)
|
return nil, mapPublicError(err)
|
||||||
}
|
}
|
||||||
@@ -681,18 +584,30 @@ func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
|||||||
//
|
//
|
||||||
// The supplied context governs this execution attempt independently of the
|
// The supplied context governs this execution attempt independently of the
|
||||||
// preparation context. It covers credential revalidation, admission,
|
// preparation context. It covers credential revalidation, admission,
|
||||||
// generation, validation, and any internal repair. Result timing begins after
|
// generation, validation, and any bounded output repair. Result timing begins
|
||||||
// the claim and excludes preparation and consumer-held delay.
|
// after the claim and excludes preparation and consumer-held delay.
|
||||||
//
|
//
|
||||||
// RunPrepared can return ErrInvalidRequest, ErrAPIKeyEnvMissing,
|
// RunPrepared can return ErrInvalidRequest, ErrAPIKeyEnvMissing,
|
||||||
// ErrCapacityExceeded, ErrLLMGenerate, or ErrValidation as applicable while
|
// ErrCapacityExceeded, ErrLLMGenerate, or ErrValidation as applicable while
|
||||||
// preserving documented collaborator and context identities. An engine
|
// preserving documented collaborator and context identities. An engine
|
||||||
// admission rejection is discoverable as [CapacityError] and still matches
|
// admission rejection is discoverable as [CapacityError] and still matches
|
||||||
// ErrCapacityExceeded. A completed content-validation rejection is returned
|
// ErrCapacityExceeded. A built-in OpenAI-compatible non-2xx response is
|
||||||
// in RunResult, not as an operational error. An operational error returns no
|
// discoverable as [GenerationError]. Built-in HTTP execution failures match
|
||||||
// partial RunResult.
|
// ErrTransport; malformed successful responses and nil successful injected
|
||||||
|
// responses match ErrMalformedResponse. Positive-budget eligible-status
|
||||||
|
// exhaustion is discoverable as [RetryExhaustedError]. A completed
|
||||||
|
// content-validation rejection, including repair exhaustion, is returned in
|
||||||
|
// RunResult, not as an operational
|
||||||
|
// error. RunPrepared uses the retry choice frozen during PrepareExecution. An
|
||||||
|
// operational error returns no partial RunResult. A retained Observer begins
|
||||||
|
// only after a successful atomic claim, with identities from the frozen
|
||||||
|
// snapshot, and receives a terminal event on normal return after public error
|
||||||
|
// mapping. Invalid, foreign, discarded, or reused handles emit no events.
|
||||||
|
// Callbacks run outside the handle and capacity locks; panics propagate and
|
||||||
|
// do not promise a terminal event.
|
||||||
func (e *Engine) RunPrepared(ctx context.Context, prepared *PreparedExecution) (*RunResult, error) {
|
func (e *Engine) RunPrepared(ctx context.Context, prepared *PreparedExecution) (*RunResult, error) {
|
||||||
if e == nil || e.runner == nil {
|
ctx = observation.WithoutExecutionState(ctx)
|
||||||
|
if e == nil || e.internal == nil {
|
||||||
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -700,9 +615,21 @@ func (e *Engine) RunPrepared(ctx context.Context, prepared *PreparedExecution) (
|
|||||||
if prepared != nil {
|
if prepared != nil {
|
||||||
internal = prepared.internal
|
internal = prepared.internal
|
||||||
}
|
}
|
||||||
result, err := e.runner.RunPrepared(ctx, internal)
|
var recorder *observation.Recorder
|
||||||
|
result, err := e.internal.RunPrepared(observation.WithClaimSink(ctx, &recorder), internal)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, mapPublicError(err)
|
mapped := mapPublicError(err)
|
||||||
|
if recorder != nil {
|
||||||
|
terminal := observedFinish(nil, mapped)
|
||||||
|
recorder.Finish(observation.FinishData{Outcome: terminal.Outcome, Failure: terminal.Failure,
|
||||||
|
StatusCode: terminal.StatusCode, RetriesExhausted: terminal.RetriesExhausted})
|
||||||
|
}
|
||||||
|
return nil, mapped
|
||||||
}
|
}
|
||||||
return fromDomainRunResult(result), nil
|
publicResult := fromDomainRunResult(result)
|
||||||
|
if recorder != nil {
|
||||||
|
terminal := observedFinish(publicResult, nil)
|
||||||
|
recorder.Finish(observation.FinishData{Outcome: terminal.Outcome})
|
||||||
|
}
|
||||||
|
return publicResult, nil
|
||||||
}
|
}
|
||||||
|
|||||||
234
engine_test.go
234
engine_test.go
@@ -220,6 +220,14 @@ func TestRunRequestFormattingRedactsDirectAPIKey(t *testing.T) {
|
|||||||
"transcript": promptkit.InlineWithURI(inputURI, inputBody),
|
"transcript": promptkit.InlineWithURI(inputURI, inputBody),
|
||||||
},
|
},
|
||||||
Vars: map[string]string{"audience": variableValue},
|
Vars: map[string]string{"audience": variableValue},
|
||||||
|
AppendedMessages: []promptkit.RenderedMessage{{
|
||||||
|
Role: "private-role-sentinel",
|
||||||
|
Content: "private-appended-content-sentinel",
|
||||||
|
CacheControl: &promptkit.CacheControl{
|
||||||
|
Type: "private-cache-sentinel",
|
||||||
|
TTL: "private-ttl-sentinel",
|
||||||
|
},
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, formatted := range []string{
|
for _, formatted := range []string{
|
||||||
@@ -229,7 +237,7 @@ func TestRunRequestFormattingRedactsDirectAPIKey(t *testing.T) {
|
|||||||
fmt.Sprintf("%+v", req),
|
fmt.Sprintf("%+v", req),
|
||||||
fmt.Sprintf("%#v", req),
|
fmt.Sprintf("%#v", req),
|
||||||
} {
|
} {
|
||||||
for _, privateValue := range []string{secret, inputURI, inputBody, variableValue} {
|
for _, privateValue := range []string{secret, inputURI, inputBody, variableValue, "private-role-sentinel", "private-appended-content-sentinel", "private-cache-sentinel", "private-ttl-sentinel"} {
|
||||||
if strings.Contains(formatted, privateValue) {
|
if strings.Contains(formatted, privateValue) {
|
||||||
t.Fatalf("formatted RunRequest leaked private value %q: %s", privateValue, formatted)
|
t.Fatalf("formatted RunRequest leaked private value %q: %s", privateValue, formatted)
|
||||||
}
|
}
|
||||||
@@ -240,6 +248,7 @@ func TestRunRequestFormattingRedactsDirectAPIKey(t *testing.T) {
|
|||||||
"APIKeySet:true",
|
"APIKeySet:true",
|
||||||
"Inputs:1",
|
"Inputs:1",
|
||||||
"Vars:1",
|
"Vars:1",
|
||||||
|
"AppendedMessages:1",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(formatted, summary) {
|
if !strings.Contains(formatted, summary) {
|
||||||
t.Fatalf("formatted RunRequest omitted structural summary %q: %s", summary, formatted)
|
t.Fatalf("formatted RunRequest omitted structural summary %q: %s", summary, formatted)
|
||||||
@@ -977,37 +986,72 @@ func TestPrepareDirectAPIKeyBypassesMissingEnvWithoutLeakingOrHashing(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMissingCredentialsFailClearlyWhenProfileRequiresAuth(t *testing.T) {
|
func TestOptionalMissingCredentialsReachUpstream(t *testing.T) {
|
||||||
const missingEnv = "PROMPTKIT_PUBLIC_AUTH_MISSING"
|
const missingEnv = "PROMPTKIT_PUBLIC_AUTH_MISSING"
|
||||||
|
const providerBody = `{"error":{"message":"authentication failed","type":"authentication_error","code":"invalid_api_key"}}`
|
||||||
t.Setenv(missingEnv, "")
|
t.Setenv(missingEnv, "")
|
||||||
|
|
||||||
profileDir := t.TempDir()
|
called := false
|
||||||
writePublicProfileFileWithAPIKeyEnv(t, profileDir, "requires-auth", "http://localhost:8000/v1", "test-model", missingEnv)
|
config := promptkit.Config{
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{
|
PromptDir: frameworkPromptDir,
|
||||||
PromptDir: frameworkPromptDir,
|
SchemaDir: frameworkSchemaDir,
|
||||||
ProfileDir: profileDir,
|
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
SchemaDir: frameworkSchemaDir,
|
called = true
|
||||||
})
|
if values := req.Header.Values("Authorization"); len(values) != 0 {
|
||||||
|
t.Fatalf("Authorization values = %q, want absent", values)
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusUnauthorized,
|
||||||
|
ContentLength: int64(len(providerBody)),
|
||||||
|
Body: io.NopCloser(strings.NewReader(providerBody)),
|
||||||
|
}, nil
|
||||||
|
})},
|
||||||
|
}
|
||||||
|
engine, err := promptkit.NewEngine(config,
|
||||||
|
promptkit.WithBackend(promptkit.Backend{
|
||||||
|
ID: "optional-auth",
|
||||||
|
Endpoint: "http://provider.test/v1",
|
||||||
|
APIKeyEnv: missingEnv,
|
||||||
|
}),
|
||||||
|
promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: "optional-auth-profile",
|
||||||
|
BackendID: "optional-auth",
|
||||||
|
Model: "test-model",
|
||||||
|
}),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected engine construction to succeed, got %v", err)
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = engine.Prepare(context.Background(), promptkit.RunRequest{
|
result, err := engine.Run(context.Background(), promptkit.RunRequest{
|
||||||
PromptID: frameworkMarkdownSummaryPromptID,
|
PromptID: frameworkMarkdownSummaryPromptID,
|
||||||
ProfileID: "requires-auth",
|
ProfileID: "optional-auth-profile",
|
||||||
Inputs: map[string]promptkit.ArtifactRef{
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
"transcript": promptkit.Inline("Rin opens the gate."),
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
||||||
"glossary": promptkit.Inline("gate: A guarded passage."),
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if !errors.Is(err, promptkit.ErrInvalidRequest) {
|
if !called {
|
||||||
t.Fatalf("expected invalid request for missing credentials, got %v", err)
|
t.Fatal("optional missing credential did not reach upstream")
|
||||||
}
|
}
|
||||||
if !errors.Is(err, promptkit.ErrAPIKeyEnvMissing) {
|
if result != nil {
|
||||||
t.Fatalf("expected missing credential environment error, got %v", err)
|
t.Fatalf("result = %+v, want nil", result)
|
||||||
}
|
}
|
||||||
if err == nil || !strings.Contains(err.Error(), missingEnv) {
|
if errors.Is(err, promptkit.ErrInvalidRequest) || errors.Is(err, promptkit.ErrAPIKeyEnvMissing) {
|
||||||
t.Fatalf("expected missing env name in error, got %v", err)
|
t.Fatalf("error = %v, want upstream generation error without credential identities", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, promptkit.ErrLLMGenerate) {
|
||||||
|
t.Fatalf("error = %v, want ErrLLMGenerate", err)
|
||||||
|
}
|
||||||
|
var generationErr *promptkit.GenerationError
|
||||||
|
if !errors.As(err, &generationErr) {
|
||||||
|
t.Fatalf("error = %v, want GenerationError", err)
|
||||||
|
}
|
||||||
|
if generationErr.StatusCode() != http.StatusUnauthorized ||
|
||||||
|
generationErr.ProviderType() != "authentication_error" ||
|
||||||
|
generationErr.ProviderCode() != "invalid_api_key" ||
|
||||||
|
generationErr.ProviderMessage() != "authentication failed" {
|
||||||
|
t.Fatalf("GenerationError = %+v, want structured upstream authentication failure", generationErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1164,8 +1208,9 @@ func TestArtifactReaderFailuresPreserveArtifactLoadErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunAddsLLMGenerateToCollaboratorPublicError(t *testing.T) {
|
func TestRunAddsLLMGenerateToCollaboratorPublicError(t *testing.T) {
|
||||||
|
injectedErr := errors.New("injected model client failure")
|
||||||
engine := newContractEngineWithOptions(t, frameworkSchemaDir,
|
engine := newContractEngineWithOptions(t, frameworkSchemaDir,
|
||||||
promptkit.WithLLMClient(&fakeLLMClient{err: promptkit.ErrArtifactLoad}),
|
promptkit.WithLLMClient(&fakeLLMClient{err: injectedErr}),
|
||||||
)
|
)
|
||||||
|
|
||||||
_, err := engine.Run(context.Background(), promptkit.RunRequest{
|
_, err := engine.Run(context.Background(), promptkit.RunRequest{
|
||||||
@@ -1178,8 +1223,12 @@ func TestRunAddsLLMGenerateToCollaboratorPublicError(t *testing.T) {
|
|||||||
if !errors.Is(err, promptkit.ErrLLMGenerate) {
|
if !errors.Is(err, promptkit.ErrLLMGenerate) {
|
||||||
t.Fatalf("expected ErrLLMGenerate, got %v", err)
|
t.Fatalf("expected ErrLLMGenerate, got %v", err)
|
||||||
}
|
}
|
||||||
if !errors.Is(err, promptkit.ErrArtifactLoad) {
|
if !errors.Is(err, injectedErr) {
|
||||||
t.Fatalf("expected preserved ErrArtifactLoad, got %v", err)
|
t.Fatalf("expected preserved injected error, got %v", err)
|
||||||
|
}
|
||||||
|
var generationErr *promptkit.GenerationError
|
||||||
|
if errors.As(err, &generationErr) {
|
||||||
|
t.Fatalf("injected error became GenerationError: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1492,43 +1541,51 @@ func TestSelectedProfileRepositoryReadFailureMapsToProfileLoad(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPrepareUsesBuiltInProfileWithoutProfileDir(t *testing.T) {
|
func TestPrepareUsesBuiltInProfileWithoutProfileDir(t *testing.T) {
|
||||||
t.Setenv("OPENROUTER_API_KEY", "test-key")
|
tests := []struct {
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{
|
name string
|
||||||
PromptDir: frameworkPromptDir,
|
profileID string
|
||||||
SchemaDir: frameworkSchemaDir,
|
backendID string
|
||||||
})
|
}{
|
||||||
if err != nil {
|
{
|
||||||
t.Fatalf("expected engine construction to succeed, got %v", err)
|
name: "OpenRouter",
|
||||||
|
profileID: "mistral-small-3",
|
||||||
|
backendID: promptkit.BackendOpenRouter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Rakestrawhome",
|
||||||
|
profileID: "rakestrawhome-gemma-4-31b",
|
||||||
|
backendID: promptkit.BackendRakestrawHome,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
for _, tc := range tests {
|
||||||
PromptID: frameworkMarkdownSummaryPromptID,
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
ProfileID: "mistral-small-3",
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
||||||
Inputs: map[string]promptkit.ArtifactRef{
|
PromptDir: frameworkPromptDir,
|
||||||
"transcript": promptkit.Inline("Rin opens the gate."),
|
SchemaDir: frameworkSchemaDir,
|
||||||
"glossary": promptkit.Inline("gate: A guarded passage."),
|
})
|
||||||
},
|
if err != nil {
|
||||||
})
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
||||||
if err != nil {
|
}
|
||||||
t.Fatalf("expected built-in profile prepare to succeed, got %v", err)
|
|
||||||
}
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
||||||
if prepared.SelectedProfileID != "mistral-small-3" {
|
PromptID: frameworkMarkdownSummaryPromptID,
|
||||||
t.Fatalf("unexpected selected profile: %q", prepared.SelectedProfileID)
|
ProfileID: tc.profileID,
|
||||||
}
|
APIKey: "test-key",
|
||||||
if prepared.SelectedBackendID != promptkit.BackendOpenRouter {
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
t.Fatalf("unexpected selected backend: %q", prepared.SelectedBackendID)
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
||||||
}
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
||||||
if prepared.EffectiveModelParams.BackendID != promptkit.BackendOpenRouter {
|
},
|
||||||
t.Fatalf("unexpected effective backend: %q", prepared.EffectiveModelParams.BackendID)
|
})
|
||||||
}
|
if err != nil {
|
||||||
if prepared.EffectiveModelParams.Endpoint != "https://openrouter.ai/api/v1" {
|
t.Fatalf("expected built-in profile prepare to succeed, got %v", err)
|
||||||
t.Fatalf("unexpected built-in endpoint: %q", prepared.EffectiveModelParams.Endpoint)
|
}
|
||||||
}
|
if prepared.SelectedProfileID != tc.profileID ||
|
||||||
if prepared.EffectiveModelParams.APIKeyEnv != "OPENROUTER_API_KEY" {
|
prepared.SelectedBackendID != tc.backendID ||
|
||||||
t.Fatalf("unexpected built-in api key environment name: %q", prepared.EffectiveModelParams.APIKeyEnv)
|
prepared.EffectiveModelParams.BackendID != tc.backendID {
|
||||||
}
|
t.Fatalf("unexpected built-in preparation: %#v", prepared)
|
||||||
if prepared.EffectiveModelParams.Model != "mistralai/mistral-small-3.2-24b-instruct" {
|
}
|
||||||
t.Fatalf("unexpected built-in model: %q", prepared.EffectiveModelParams.Model)
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1958,6 +2015,25 @@ func TestWithProfilesRejectsDuplicateIDs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWithProfilesAcceptsDerivedDefinitionWithoutTargetFields(t *testing.T) {
|
||||||
|
_, err := promptkit.NewEngine(promptkit.Config{PromptDir: frameworkPromptDir},
|
||||||
|
promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: " derived-profile ",
|
||||||
|
BaseProfileID: " base-profile ",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("derived profile should be accepted during construction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = promptkit.NewEngine(promptkit.Config{PromptDir: frameworkPromptDir},
|
||||||
|
promptkit.WithProfiles(promptkit.Profile{ID: "standalone-profile"}),
|
||||||
|
)
|
||||||
|
if !errors.Is(err, promptkit.ErrInvalidConfig) {
|
||||||
|
t.Fatalf("standalone incomplete profile error = %v, want ErrInvalidConfig", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWithProfilesRejectsInvalidExecutionSettings(t *testing.T) {
|
func TestWithProfilesRejectsInvalidExecutionSettings(t *testing.T) {
|
||||||
type testCase struct {
|
type testCase struct {
|
||||||
name string
|
name string
|
||||||
@@ -2110,6 +2186,7 @@ func TestOpenAICompatibleProfileMapsEveryField(t *testing.T) {
|
|||||||
extraParams := map[string]any{"provider_option": "distinct-extra-params"}
|
extraParams := map[string]any{"provider_option": "distinct-extra-params"}
|
||||||
got := promptkit.OpenAICompatibleProfile(promptkit.OpenAICompatibleProfileConfig{
|
got := promptkit.OpenAICompatibleProfile(promptkit.OpenAICompatibleProfileConfig{
|
||||||
ID: "distinct-id",
|
ID: "distinct-id",
|
||||||
|
BaseProfileID: "distinct-base",
|
||||||
BackendID: "distinct-backend",
|
BackendID: "distinct-backend",
|
||||||
Endpoint: "https://distinct.example/v1",
|
Endpoint: "https://distinct.example/v1",
|
||||||
Model: "distinct-model",
|
Model: "distinct-model",
|
||||||
@@ -2124,6 +2201,7 @@ func TestOpenAICompatibleProfileMapsEveryField(t *testing.T) {
|
|||||||
})
|
})
|
||||||
want := promptkit.Profile{
|
want := promptkit.Profile{
|
||||||
ID: "distinct-id",
|
ID: "distinct-id",
|
||||||
|
BaseProfileID: "distinct-base",
|
||||||
BackendID: "distinct-backend",
|
BackendID: "distinct-backend",
|
||||||
Endpoint: "https://distinct.example/v1",
|
Endpoint: "https://distinct.example/v1",
|
||||||
Model: "distinct-model",
|
Model: "distinct-model",
|
||||||
@@ -2468,6 +2546,38 @@ func TestInMemoryProfileExtraParamsAreCopiedAcrossPublicBoundary(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWithProfilesCopiesDefinitionsWhenApplied(t *testing.T) {
|
||||||
|
nested := map[string]any{"value": "when option is created"}
|
||||||
|
option := promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: "profile",
|
||||||
|
Endpoint: "http://copy.example/v1",
|
||||||
|
Model: "copy-model",
|
||||||
|
ExtraParams: map[string]any{"nested": nested},
|
||||||
|
})
|
||||||
|
nested["value"] = "when option is applied"
|
||||||
|
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{PromptDir: frameworkPromptDir}, option)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct engine: %v", err)
|
||||||
|
}
|
||||||
|
nested["value"] = "after construction"
|
||||||
|
|
||||||
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
||||||
|
PromptID: frameworkMarkdownSummaryPromptID,
|
||||||
|
ProfileID: "profile",
|
||||||
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
||||||
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepare with copied profile: %v", err)
|
||||||
|
}
|
||||||
|
if got := prepared.EffectiveModelParams.ExtraParams["nested"].(map[string]any)["value"]; got != "when option is applied" {
|
||||||
|
t.Fatalf("profile copy value = %q, want value at option application", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWithProfilesRejectsInvalidExtraParams(t *testing.T) {
|
func TestWithProfilesRejectsInvalidExtraParams(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -3405,9 +3515,10 @@ extra_params:
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fakeLLMClient struct {
|
type fakeLLMClient struct {
|
||||||
response *promptkit.GenerateResponse
|
response *promptkit.GenerateResponse
|
||||||
err error
|
responses []*promptkit.GenerateResponse
|
||||||
requests []promptkit.GenerateRequest
|
err error
|
||||||
|
requests []promptkit.GenerateRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
type recordingArtifactReader struct {
|
type recordingArtifactReader struct {
|
||||||
@@ -3583,5 +3694,12 @@ func (f *fakeLLMClient) Generate(_ context.Context, req promptkit.GenerateReques
|
|||||||
if f.err != nil {
|
if f.err != nil {
|
||||||
return nil, f.err
|
return nil, f.err
|
||||||
}
|
}
|
||||||
|
if len(f.responses) > 0 {
|
||||||
|
index := len(f.requests) - 1
|
||||||
|
if index >= len(f.responses) {
|
||||||
|
return nil, fmt.Errorf("no response configured for generation %d", index+1)
|
||||||
|
}
|
||||||
|
return f.responses[index], nil
|
||||||
|
}
|
||||||
return f.response, nil
|
return f.response, nil
|
||||||
}
|
}
|
||||||
|
|||||||
35
errors.go
35
errors.go
@@ -6,6 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/generation"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
"gitea.maximumdirect.net/eric/promptkit/internal/promptdef"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
||||||
@@ -20,11 +22,42 @@ func mapPublicError(err error) error {
|
|||||||
strings.TrimSpace(internalCapacityError.BackendID) != "" {
|
strings.TrimSpace(internalCapacityError.BackendID) != "" {
|
||||||
return &CapacityError{BackendID: internalCapacityError.BackendID}
|
return &CapacityError{BackendID: internalCapacityError.BackendID}
|
||||||
}
|
}
|
||||||
|
var internalExhausted *generation.ExhaustedError
|
||||||
|
if errors.As(err, &internalExhausted) && internalExhausted != nil {
|
||||||
|
mapped := newRetryExhaustedError(internalExhausted.Attempts(), mapPublicError(internalExhausted.Unwrap()))
|
||||||
|
if errors.Is(err, usecase.ErrValidation) && !errors.Is(mapped, ErrValidation) {
|
||||||
|
return fmt.Errorf("%w: %w", ErrValidation, mapped)
|
||||||
|
}
|
||||||
|
if publicErr := publicErrorFor(err); publicErr != nil && !errors.Is(mapped, publicErr) {
|
||||||
|
return fmt.Errorf("%w: %w", publicErr, mapped)
|
||||||
|
}
|
||||||
|
return mapped
|
||||||
|
}
|
||||||
publicErr := publicErrorFor(err)
|
publicErr := publicErrorFor(err)
|
||||||
|
var providerHTTPError *llm.ProviderHTTPError
|
||||||
|
if errors.As(err, &providerHTTPError) && providerHTTPError != nil {
|
||||||
|
generationErr := newGenerationError(
|
||||||
|
providerHTTPError.StatusCode(),
|
||||||
|
providerHTTPError.ProviderCode(),
|
||||||
|
providerHTTPError.ProviderType(),
|
||||||
|
providerHTTPError.ProviderMessage(),
|
||||||
|
)
|
||||||
|
if publicErr != nil && !errors.Is(publicErr, ErrLLMGenerate) {
|
||||||
|
return fmt.Errorf("%w: %w", publicErr, generationErr)
|
||||||
|
}
|
||||||
|
return generationErr
|
||||||
|
}
|
||||||
if publicErr == nil {
|
if publicErr == nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return fmt.Errorf("%w: %w", publicErr, err)
|
switch {
|
||||||
|
case errors.Is(err, llm.ErrTransport):
|
||||||
|
return fmt.Errorf("%w: %w: %w", publicErr, ErrTransport, err)
|
||||||
|
case errors.Is(err, llm.ErrMalformedResponse):
|
||||||
|
return fmt.Errorf("%w: %w: %w", publicErr, ErrMalformedResponse, err)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: %w", publicErr, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func publicErrorFor(err error) error {
|
func publicErrorFor(err error) error {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
"gitea.maximumdirect.net/eric/promptkit/internal/usecase"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,3 +49,34 @@ func TestMapPublicErrorTranslatesCapacityError(t *testing.T) {
|
|||||||
t.Fatalf("mapped backend ID changed with source error: %q", publicErr.BackendID)
|
t.Fatalf("mapped backend ID changed with source error: %q", publicErr.BackendID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMapPublicErrorPreservesValidationAroundGenerationError(t *testing.T) {
|
||||||
|
internalErr := fmt.Errorf(
|
||||||
|
"%w: %w",
|
||||||
|
usecase.ErrValidation,
|
||||||
|
&llm.ProviderHTTPError{},
|
||||||
|
)
|
||||||
|
|
||||||
|
err := mapPublicError(internalErr)
|
||||||
|
if !errors.Is(err, ErrValidation) {
|
||||||
|
t.Fatalf("mapped error=%v, want ErrValidation", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrLLMGenerate) {
|
||||||
|
t.Fatalf("mapped error=%v, want ErrLLMGenerate", err)
|
||||||
|
}
|
||||||
|
var generationErr *GenerationError
|
||||||
|
if !errors.As(err, &generationErr) || generationErr == nil {
|
||||||
|
t.Fatalf("mapped error=%v, want GenerationError", err)
|
||||||
|
}
|
||||||
|
var leakedInternalErr *llm.ProviderHTTPError
|
||||||
|
if errors.As(err, &leakedInternalErr) {
|
||||||
|
t.Fatalf("mapped error exposes internal ProviderHTTPError: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapPublicErrorDoesNotTreatRequestEncodingAsTransport(t *testing.T) {
|
||||||
|
err := mapPublicError(fmt.Errorf("%w: %w", usecase.ErrLLMGenerate, llm.ErrRequestFailed))
|
||||||
|
if !errors.Is(err, ErrLLMGenerate) || errors.Is(err, ErrTransport) {
|
||||||
|
t.Fatalf("request encoding category = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
157
examples/go-library/observe/main.go
Normal file
157
examples/go-library/observe/main.go
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed prompts/*.yaml schemas/*.json
|
||||||
|
var assets embed.FS
|
||||||
|
|
||||||
|
type scriptedTransport struct {
|
||||||
|
proposalCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *scriptedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
if err := req.Context().Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var request struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&request); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
switch request.Model {
|
||||||
|
case "proposal-model":
|
||||||
|
t.proposalCalls++
|
||||||
|
if t.proposalCalls == 1 {
|
||||||
|
return httpResponse(http.StatusServiceUnavailable, `{"error":{}}`), nil
|
||||||
|
}
|
||||||
|
return modelResponse(`{"title":"Queue cleanup","risk":"low"}`, 10, 8), nil
|
||||||
|
case "assessment-model":
|
||||||
|
return modelResponse(`{"recommendation":"accept","confidence":0.9}`, 7, 5), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unexpected model in offline transport")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelResponse(content string, promptTokens, completionTokens int) *http.Response {
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"choices": []any{map[string]any{"message": map[string]string{"content": content}}},
|
||||||
|
"usage": map[string]int{"prompt_tokens": promptTokens, "completion_tokens": completionTokens},
|
||||||
|
})
|
||||||
|
return httpResponse(http.StatusOK, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpResponse(status int, body string) *http.Response {
|
||||||
|
return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}
|
||||||
|
}
|
||||||
|
|
||||||
|
type eventLog struct{ events []promptkit.ExecutionEvent }
|
||||||
|
|
||||||
|
func (l *eventLog) record(event promptkit.ExecutionEvent) { l.events = append(l.events, event) }
|
||||||
|
|
||||||
|
func (l *eventLog) terminal() (promptkit.ExecutionEvent, error) {
|
||||||
|
if len(l.events) == 0 || l.events[len(l.events)-1].Kind != promptkit.ExecutionFinished {
|
||||||
|
return promptkit.ExecutionEvent{}, fmt.Errorf("execution did not finish")
|
||||||
|
}
|
||||||
|
return l.events[len(l.events)-1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type summary struct {
|
||||||
|
ProposalAttempts uint64 `json:"proposal_attempts"`
|
||||||
|
ProposalTransientRetries uint64 `json:"proposal_transient_retries"`
|
||||||
|
ProposalUsageComplete bool `json:"proposal_usage_complete"`
|
||||||
|
AssessmentAttempts uint64 `json:"assessment_attempts"`
|
||||||
|
AssessmentTransientRetries uint64 `json:"assessment_transient_retries"`
|
||||||
|
AssessmentOutcome string `json:"assessment_outcome"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() (summary, error) {
|
||||||
|
transport := &scriptedTransport{}
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: &http.Client{Transport: transport}},
|
||||||
|
promptkit.WithPromptFS(assets, "prompts"),
|
||||||
|
promptkit.WithSchemaFS(assets, "schemas"),
|
||||||
|
promptkit.WithBackend(promptkit.Backend{ID: "offline-backend", Endpoint: "https://example.invalid/v1", ConcurrencyLimit: 1}),
|
||||||
|
promptkit.WithProfiles(
|
||||||
|
promptkit.Profile{ID: "proposal-profile", BackendID: "offline-backend", Model: "proposal-model"},
|
||||||
|
promptkit.Profile{ID: "assessment-profile", BackendID: "offline-backend", Model: "assessment-model"},
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
return summary{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var proposalLog eventLog
|
||||||
|
proposal, err := engine.Run(context.Background(), promptkit.RunRequest{
|
||||||
|
PromptID: "example.proposal", Inputs: map[string]promptkit.ArtifactRef{"idea": promptkit.Inline("Reduce queue cleanup toil.")}, Observer: proposalLog.record,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return summary{}, err
|
||||||
|
}
|
||||||
|
if !proposal.Validation.IsValid {
|
||||||
|
return summary{}, fmt.Errorf("proposal failed structural validation")
|
||||||
|
}
|
||||||
|
proposalTerminal, err := proposalLog.terminal()
|
||||||
|
if err != nil {
|
||||||
|
return summary{}, err
|
||||||
|
}
|
||||||
|
var assessmentLog eventLog
|
||||||
|
assessment, err := engine.Run(context.Background(), promptkit.RunRequest{
|
||||||
|
PromptID: "example.assessment", Inputs: map[string]promptkit.ArtifactRef{"proposal": promptkit.Inline(proposal.RawOutput)}, Observer: assessmentLog.record,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return summary{}, err
|
||||||
|
}
|
||||||
|
if !assessment.Validation.IsValid {
|
||||||
|
return summary{}, fmt.Errorf("assessment failed structural validation")
|
||||||
|
}
|
||||||
|
assessmentTerminal, err := assessmentLog.terminal()
|
||||||
|
if err != nil {
|
||||||
|
return summary{}, err
|
||||||
|
}
|
||||||
|
var proposalValue struct {
|
||||||
|
Risk string `json:"risk"`
|
||||||
|
}
|
||||||
|
var assessmentValue struct {
|
||||||
|
Recommendation string `json:"recommendation"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(proposal.RawOutput), &proposalValue); err != nil {
|
||||||
|
return summary{}, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(assessment.RawOutput), &assessmentValue); err != nil {
|
||||||
|
return summary{}, err
|
||||||
|
}
|
||||||
|
decision := "rejected"
|
||||||
|
if proposalValue.Risk == "low" && assessmentValue.Recommendation == "accept" {
|
||||||
|
decision = "accepted"
|
||||||
|
}
|
||||||
|
return summary{
|
||||||
|
ProposalAttempts: proposalTerminal.Attempts,
|
||||||
|
ProposalTransientRetries: proposalTerminal.TransientRetries,
|
||||||
|
ProposalUsageComplete: proposalTerminal.Usage.PromptTokens.Complete,
|
||||||
|
AssessmentAttempts: assessmentTerminal.Attempts,
|
||||||
|
AssessmentTransientRetries: assessmentTerminal.TransientRetries,
|
||||||
|
AssessmentOutcome: decision,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
value, err := run()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := json.NewEncoder(os.Stdout).Encode(value); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
examples/go-library/observe/prompts/assessment.yaml
Normal file
17
examples/go-library/observe/prompts/assessment.yaml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
id: example.assessment
|
||||||
|
version: "1.0.0"
|
||||||
|
default_profile: assessment-profile
|
||||||
|
description: Assess a structured proposal independently of schema validity.
|
||||||
|
inputs:
|
||||||
|
- name: proposal
|
||||||
|
required: true
|
||||||
|
content_type: application/json
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content: Return a concise assessment as JSON.
|
||||||
|
- role: user
|
||||||
|
content: '{{input "proposal"}}'
|
||||||
|
output:
|
||||||
|
format: json
|
||||||
|
validation_mode: json_schema
|
||||||
|
schema_path: assessment.schema.json
|
||||||
17
examples/go-library/observe/prompts/proposal.yaml
Normal file
17
examples/go-library/observe/prompts/proposal.yaml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
id: example.proposal
|
||||||
|
version: "1.0.0"
|
||||||
|
default_profile: proposal-profile
|
||||||
|
description: Propose a bounded improvement from a synthetic idea.
|
||||||
|
inputs:
|
||||||
|
- name: idea
|
||||||
|
required: true
|
||||||
|
content_type: text/plain
|
||||||
|
messages:
|
||||||
|
- role: system
|
||||||
|
content: Return a concise proposal as JSON.
|
||||||
|
- role: user
|
||||||
|
content: '{{input "idea"}}'
|
||||||
|
output:
|
||||||
|
format: json
|
||||||
|
validation_mode: json_schema
|
||||||
|
schema_path: proposal.schema.json
|
||||||
10
examples/go-library/observe/schemas/assessment.schema.json
Normal file
10
examples/go-library/observe/schemas/assessment.schema.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["recommendation", "confidence"],
|
||||||
|
"properties": {
|
||||||
|
"recommendation": {"type": "string"},
|
||||||
|
"confidence": {"type": "number"}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
10
examples/go-library/observe/schemas/proposal.schema.json
Normal file
10
examples/go-library/observe/schemas/proposal.schema.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["title", "risk"],
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string"},
|
||||||
|
"risk": {"type": "string"}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ func (r RunRequest) GoString() string {
|
|||||||
|
|
||||||
func (r RunRequest) redactedString() string {
|
func (r RunRequest) redactedString() string {
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"promptkit.RunRequest{PromptID:%q PromptVersion:%q ProfileID:%q APIKeySet:%t Inputs:%d Vars:%d ExecutionSet:%t ValidationSet:%t}",
|
"promptkit.RunRequest{PromptID:%q PromptVersion:%q ProfileID:%q APIKeySet:%t Inputs:%d Vars:%d ExecutionSet:%t ValidationSet:%t AppendedMessages:%d}",
|
||||||
r.PromptID,
|
r.PromptID,
|
||||||
r.PromptVersion,
|
r.PromptVersion,
|
||||||
r.ProfileID,
|
r.ProfileID,
|
||||||
@@ -27,6 +27,7 @@ func (r RunRequest) redactedString() string {
|
|||||||
len(r.Vars),
|
len(r.Vars),
|
||||||
r.Execution != nil,
|
r.Execution != nil,
|
||||||
r.Validation != nil,
|
r.Validation != nil,
|
||||||
|
len(r.AppendedMessages),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
87
generation_error.go
Normal file
87
generation_error.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// GenerationError reports a non-2xx response from Promptkit's built-in
|
||||||
|
// OpenAI-compatible client during [Engine.Run] or [Engine.RunPrepared].
|
||||||
|
//
|
||||||
|
// Engine-produced values are immutable, caller-owned values. Use errors.Is to
|
||||||
|
// match [ErrLLMGenerate] and errors.As with a *GenerationError target to obtain
|
||||||
|
// this type. The four provider accessors expose untrusted provider-controlled
|
||||||
|
// values that can contain sensitive request or schema fragments. Applications
|
||||||
|
// must apply their own disclosure policy before logging, displaying, or
|
||||||
|
// returning them to another caller.
|
||||||
|
//
|
||||||
|
// Accessors, Error, GoString, and Unwrap are safe on a nil receiver and a zero
|
||||||
|
// value. Default and Go-syntax formatting deliberately redact provider details.
|
||||||
|
// GenerationError has no stable JSON representation.
|
||||||
|
type GenerationError struct {
|
||||||
|
statusCode int
|
||||||
|
providerCode string
|
||||||
|
providerType string
|
||||||
|
providerMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGenerationError(statusCode int, providerCode, providerType, providerMessage string) *GenerationError {
|
||||||
|
return &GenerationError{
|
||||||
|
statusCode: statusCode,
|
||||||
|
providerCode: providerCode,
|
||||||
|
providerType: providerType,
|
||||||
|
providerMessage: providerMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusCode returns the received provider HTTP status code, or zero for a nil
|
||||||
|
// receiver or zero value.
|
||||||
|
func (e *GenerationError) StatusCode() int {
|
||||||
|
if e == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return e.statusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderCode returns the normalized provider error code, if present. Its
|
||||||
|
// value is untrusted and may contain sensitive data.
|
||||||
|
func (e *GenerationError) ProviderCode() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.providerCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderType returns the normalized provider error type, if present. Its
|
||||||
|
// value is untrusted and may contain sensitive data.
|
||||||
|
func (e *GenerationError) ProviderType() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.providerType
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderMessage returns the bounded normalized provider diagnostic, if
|
||||||
|
// present. Its value is untrusted and may contain sensitive data.
|
||||||
|
func (e *GenerationError) ProviderMessage() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.providerMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns a redacted diagnostic that is not a parsing contract.
|
||||||
|
func (e *GenerationError) Error() string {
|
||||||
|
if e == nil || e.statusCode == 0 {
|
||||||
|
return ErrLLMGenerate.Error()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s: provider returned HTTP status %d", ErrLLMGenerate, e.statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoString returns the same redacted diagnostic as Error.
|
||||||
|
func (e *GenerationError) GoString() string {
|
||||||
|
return e.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap returns ErrLLMGenerate. It is safe to call on a nil receiver or zero
|
||||||
|
// value.
|
||||||
|
func (e *GenerationError) Unwrap() error {
|
||||||
|
return ErrLLMGenerate
|
||||||
|
}
|
||||||
235
generation_error_contract_test.go
Normal file
235
generation_error_contract_test.go
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
package promptkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuiltInGenerationError(t *testing.T) {
|
||||||
|
const (
|
||||||
|
codeMarker = "provider-code-marker"
|
||||||
|
typeMarker = "provider-type-marker"
|
||||||
|
messageMarker = "provider-message-marker"
|
||||||
|
)
|
||||||
|
engine := newBuiltInGenerationErrorEngine(t, http.StatusUnprocessableEntity,
|
||||||
|
`{"error":{"code":"`+codeMarker+`","type":"`+typeMarker+`","message":"`+messageMarker+`"}}`)
|
||||||
|
|
||||||
|
result, err := engine.Run(context.Background(), generationErrorRunRequest())
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("Run result = %#v, want nil", result)
|
||||||
|
}
|
||||||
|
assertGenerationError(t, err, http.StatusUnprocessableEntity, codeMarker, typeMarker, messageMarker)
|
||||||
|
|
||||||
|
preparedEngine := newBuiltInGenerationErrorEngine(t, http.StatusServiceUnavailable, `{"error":{}}`)
|
||||||
|
prepared, err := preparedEngine.PrepareExecution(context.Background(), generationErrorRunRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PrepareExecution: %v", err)
|
||||||
|
}
|
||||||
|
result, err = preparedEngine.RunPrepared(context.Background(), prepared)
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("RunPrepared result = %#v, want nil", result)
|
||||||
|
}
|
||||||
|
assertGenerationError(t, err, http.StatusServiceUnavailable, "", "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltInGenerationErrorWithAppendedMessages(t *testing.T) {
|
||||||
|
const messageMarker = "combined-request-provider-marker"
|
||||||
|
engine := newBuiltInGenerationErrorEngine(t, http.StatusUnprocessableEntity,
|
||||||
|
`{"error":{"message":"`+messageMarker+`"}}`)
|
||||||
|
request := generationErrorRunRequest()
|
||||||
|
request.AppendedMessages = []promptkit.RenderedMessage{
|
||||||
|
{Role: promptkit.RoleAssistant, Content: "previous response"},
|
||||||
|
{Role: promptkit.RoleUser, Content: "consumer correction"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := engine.Run(context.Background(), request)
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("Run result = %#v, want nil", result)
|
||||||
|
}
|
||||||
|
assertGenerationError(t, err, http.StatusUnprocessableEntity, "", "", messageMarker)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltInRepairGenerationError(t *testing.T) {
|
||||||
|
const (
|
||||||
|
codeMarker = "repair-code-marker"
|
||||||
|
typeMarker = "repair-type-marker"
|
||||||
|
messageMarker = "repair-message-marker"
|
||||||
|
)
|
||||||
|
calls := 0
|
||||||
|
config := contractConfig(frameworkSchemaDir)
|
||||||
|
config.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
calls++
|
||||||
|
if calls == 1 {
|
||||||
|
body := `{"choices":[{"message":{"content":"not-json"}}]}`
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, ContentLength: int64(len(body)), Body: io.NopCloser(strings.NewReader(body))}, nil
|
||||||
|
}
|
||||||
|
body := `{"error":{"code":"` + codeMarker + `","type":"` + typeMarker + `","message":"` + messageMarker + `"}}`
|
||||||
|
return &http.Response{StatusCode: http.StatusUnprocessableEntity, ContentLength: int64(len(body)), Body: io.NopCloser(strings.NewReader(body))}, nil
|
||||||
|
})}
|
||||||
|
engine, err := promptkit.NewEngine(config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewEngine: %v", err)
|
||||||
|
}
|
||||||
|
req := generationErrorRunRequest()
|
||||||
|
req.Validation = &promptkit.OutputContract{
|
||||||
|
Format: promptkit.FormatJSON,
|
||||||
|
ValidationMode: promptkit.ValidationJSON,
|
||||||
|
RepairAttempts: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := engine.Run(context.Background(), req)
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("Run result = %#v, want nil", result)
|
||||||
|
}
|
||||||
|
if calls != 2 {
|
||||||
|
t.Fatalf("provider calls = %d, want 2", calls)
|
||||||
|
}
|
||||||
|
assertGenerationError(t, err, http.StatusUnprocessableEntity, codeMarker, typeMarker, messageMarker)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltInGenerationFailureCategories(t *testing.T) {
|
||||||
|
transportCause := errors.New("private transport diagnostic")
|
||||||
|
config := contractConfig(frameworkSchemaDir)
|
||||||
|
config.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
return nil, transportCause
|
||||||
|
})}
|
||||||
|
engine, err := promptkit.NewEngine(config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := engine.Run(context.Background(), generationErrorRunRequest())
|
||||||
|
if result != nil || !errors.Is(err, promptkit.ErrLLMGenerate) || !errors.Is(err, promptkit.ErrTransport) || !errors.Is(err, transportCause) {
|
||||||
|
t.Fatalf("transport result=%#v, error=%v", result, err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, promptkit.ErrMalformedResponse) || strings.Contains(fmt.Sprintf("%#v", err), transportCause.Error()) {
|
||||||
|
t.Fatalf("transport category or formatting = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{name: "invalid framing", body: `{"choices":[]}{}`},
|
||||||
|
{name: "missing choices", body: `{"choices":[]}`},
|
||||||
|
{name: "missing content", body: `{"choices":[{"message":{}}]}`},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
engine := newBuiltInGenerationErrorEngine(t, http.StatusOK, tc.body)
|
||||||
|
result, err := engine.Run(context.Background(), generationErrorRunRequest())
|
||||||
|
if result != nil || !errors.Is(err, promptkit.ErrLLMGenerate) || !errors.Is(err, promptkit.ErrMalformedResponse) || errors.Is(err, promptkit.ErrTransport) {
|
||||||
|
t.Fatalf("malformed result=%#v, error=%v", result, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltInGenerationPreservesContextIdentity(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
cause error
|
||||||
|
}{
|
||||||
|
{name: "canceled", cause: context.Canceled},
|
||||||
|
{name: "deadline", cause: context.DeadlineExceeded},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
config := contractConfig(frameworkSchemaDir)
|
||||||
|
config.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
return nil, tc.cause
|
||||||
|
})}
|
||||||
|
engine, err := promptkit.NewEngine(config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := engine.Run(context.Background(), generationErrorRunRequest())
|
||||||
|
if result != nil || !errors.Is(err, tc.cause) || !errors.Is(err, promptkit.ErrLLMGenerate) || !errors.Is(err, promptkit.ErrTransport) {
|
||||||
|
t.Fatalf("context result=%#v, error=%v", result, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInjectedGenerationFailureCategories(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
client *fakeLLMClient
|
||||||
|
malformed bool
|
||||||
|
}{
|
||||||
|
{name: "nil response", client: &fakeLLMClient{}, malformed: true},
|
||||||
|
{name: "arbitrary error", client: &fakeLLMClient{err: errors.New("injected failure")}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
engine := newContractEngineWithOptions(t, frameworkSchemaDir, promptkit.WithLLMClient(tc.client))
|
||||||
|
result, err := engine.Run(context.Background(), generationErrorRunRequest())
|
||||||
|
if result != nil || !errors.Is(err, promptkit.ErrLLMGenerate) || errors.Is(err, promptkit.ErrTransport) || errors.Is(err, promptkit.ErrMalformedResponse) != tc.malformed {
|
||||||
|
t.Fatalf("injected result=%#v, error=%v", result, err)
|
||||||
|
}
|
||||||
|
if tc.client.err != nil && !errors.Is(err, tc.client.err) {
|
||||||
|
t.Fatalf("injected cause lost: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertGenerationError(t *testing.T, err error, statusCode int, code, providerType, message string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if !errors.Is(err, promptkit.ErrLLMGenerate) {
|
||||||
|
t.Fatalf("errors.Is(%v, ErrLLMGenerate) = false", err)
|
||||||
|
}
|
||||||
|
var generationErr *promptkit.GenerationError
|
||||||
|
if !errors.As(err, &generationErr) || generationErr == nil {
|
||||||
|
t.Fatalf("error = %T, want *GenerationError", err)
|
||||||
|
}
|
||||||
|
if generationErr.StatusCode() != statusCode || generationErr.ProviderCode() != code || generationErr.ProviderType() != providerType || generationErr.ProviderMessage() != message {
|
||||||
|
t.Fatalf("GenerationError = %#v", generationErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantFormatted := fmt.Sprintf("failed to generate output: provider returned HTTP status %d", statusCode)
|
||||||
|
for _, rendered := range []string{fmt.Sprintf("%v", generationErr), fmt.Sprintf("%+v", generationErr), fmt.Sprintf("%#v", generationErr)} {
|
||||||
|
if rendered != wantFormatted {
|
||||||
|
t.Fatalf("formatted error = %q, want %q", rendered, wantFormatted)
|
||||||
|
}
|
||||||
|
for _, marker := range []string{code, providerType, message} {
|
||||||
|
if marker != "" && strings.Contains(rendered, marker) {
|
||||||
|
t.Fatalf("formatted error exposed provider marker %q: %q", marker, rendered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBuiltInGenerationErrorEngine(t *testing.T, statusCode int, body string) *promptkit.Engine {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
config := contractConfig(frameworkSchemaDir)
|
||||||
|
noRetries := 0
|
||||||
|
config.TransientRetries = &noRetries
|
||||||
|
config.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: statusCode,
|
||||||
|
ContentLength: int64(len(body)),
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
}, nil
|
||||||
|
})}
|
||||||
|
engine, err := promptkit.NewEngine(config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewEngine: %v", err)
|
||||||
|
}
|
||||||
|
return engine
|
||||||
|
}
|
||||||
|
|
||||||
|
func generationErrorRunRequest() promptkit.RunRequest {
|
||||||
|
return promptkit.RunRequest{
|
||||||
|
PromptID: frameworkMarkdownSummaryPromptID,
|
||||||
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
||||||
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
32
generation_error_internal_test.go
Normal file
32
generation_error_internal_test.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerationErrorNilAndZeroValue(t *testing.T) {
|
||||||
|
var nilError *GenerationError
|
||||||
|
zeroError := &GenerationError{}
|
||||||
|
|
||||||
|
for name, err := range map[string]*GenerationError{
|
||||||
|
"nil": nilError,
|
||||||
|
"zero": zeroError,
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if err.StatusCode() != 0 || err.ProviderCode() != "" || err.ProviderType() != "" || err.ProviderMessage() != "" {
|
||||||
|
t.Fatalf("accessors returned provider details: %#v", err)
|
||||||
|
}
|
||||||
|
if err.Error() != "failed to generate output" || err.GoString() != "failed to generate output" {
|
||||||
|
t.Fatalf("redacted formatting = (%q, %q)", err.Error(), err.GoString())
|
||||||
|
}
|
||||||
|
if fmt.Sprintf("%v", err) != "failed to generate output" || fmt.Sprintf("%#v", err) != "failed to generate output" {
|
||||||
|
t.Fatalf("formatted error = (%q, %q)", fmt.Sprintf("%v", err), fmt.Sprintf("%#v", err))
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrLLMGenerate) {
|
||||||
|
t.Fatalf("errors.Is(%v, ErrLLMGenerate) = false", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
2
go.mod
2
go.mod
@@ -3,6 +3,8 @@ module gitea.maximumdirect.net/eric/promptkit
|
|||||||
go 1.25.5
|
go 1.25.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2 v2.20260926.0
|
||||||
|
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260926.0
|
||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -1,3 +1,7 @@
|
|||||||
|
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2 v2.20260926.0 h1:wdab+R+lgMZxzYwhbAuCrW7zdSQ1cW9ovBR6UzLmZR4=
|
||||||
|
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2 v2.20260926.0/go.mod h1:a3VBbh9oaGXD6ruxhtwHj+AWW6DY/SSyby1QtMmYSUo=
|
||||||
|
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260926.0 h1:baCCIB6Ee5t0S4Zc++NTBIPcJEv5UPS6DlSrpZfykwg=
|
||||||
|
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260926.0/go.mod h1:mtgD1E0SGm0e/I8Ffhz+AYX7GN7RKUWJXehNFIKHs4Y=
|
||||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
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/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 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||||
|
|||||||
@@ -18,12 +18,11 @@ const (
|
|||||||
// OpenRouterID is the reserved ID of Promptkit's built-in OpenRouter
|
// OpenRouterID is the reserved ID of Promptkit's built-in OpenRouter
|
||||||
// backend.
|
// backend.
|
||||||
OpenRouterID = "openrouter"
|
OpenRouterID = "openrouter"
|
||||||
|
// RakestrawHomeID is the reserved ID of Promptkit's built-in Rakestrawhome
|
||||||
|
// backend.
|
||||||
|
RakestrawHomeID = "rakestrawhome"
|
||||||
|
|
||||||
openRouterEndpoint = "https://openrouter.ai/api/v1"
|
defaultQueueCapacity = 1024
|
||||||
openRouterAPIKeyEnv = "OPENROUTER_API_KEY"
|
|
||||||
|
|
||||||
openRouterConcurrencyLimit = 16
|
|
||||||
defaultQueueCapacity = 1024
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrBackendNotFound identifies a registry lookup for an unknown backend ID.
|
// ErrBackendNotFound identifies a registry lookup for an unknown backend ID.
|
||||||
@@ -36,20 +35,15 @@ type Registry struct {
|
|||||||
backends map[string]domain.Backend
|
backends map[string]domain.Backend
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRegistry constructs a registry containing the built-in OpenRouter
|
// NewRegistry constructs a registry containing maintained definitions followed
|
||||||
// definition followed by the supplied additions. Every ID must be unique.
|
// by consumer additions. Every ID must be unique across both groups.
|
||||||
func NewRegistry(additions []domain.Backend) (*Registry, error) {
|
func NewRegistry(maintained, additions []domain.Backend) (*Registry, error) {
|
||||||
registry := &Registry{
|
registry := &Registry{
|
||||||
backends: make(map[string]domain.Backend, len(additions)+1),
|
backends: make(map[string]domain.Backend, len(maintained)+len(additions)),
|
||||||
}
|
}
|
||||||
|
|
||||||
definitions := make([]domain.Backend, 0, len(additions)+1)
|
definitions := make([]domain.Backend, 0, len(maintained)+len(additions))
|
||||||
definitions = append(definitions, domain.Backend{
|
definitions = append(definitions, maintained...)
|
||||||
ID: OpenRouterID,
|
|
||||||
Endpoint: openRouterEndpoint,
|
|
||||||
APIKeyEnv: openRouterAPIKeyEnv,
|
|
||||||
ConcurrencyLimit: openRouterConcurrencyLimit,
|
|
||||||
})
|
|
||||||
definitions = append(definitions, additions...)
|
definitions = append(definitions, additions...)
|
||||||
|
|
||||||
for _, definition := range definitions {
|
for _, definition := range definitions {
|
||||||
@@ -61,7 +55,7 @@ func NewRegistry(additions []domain.Backend) (*Registry, error) {
|
|||||||
return nil, fmt.Errorf("backend ID %q is already registered", definition.ID)
|
return nil, fmt.Errorf("backend ID %q is already registered", definition.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
normalized, err := normalizeBackend(definition)
|
normalized, err := NormalizeDefinition(definition)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -107,7 +101,8 @@ func (r *Registry) CapacityPolicies() map[string]domain.BackendCapacityPolicy {
|
|||||||
return policies
|
return policies
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeBackend(definition domain.Backend) (domain.Backend, error) {
|
// NormalizeDefinition validates and defensively copies one backend definition.
|
||||||
|
func NormalizeDefinition(definition domain.Backend) (domain.Backend, error) {
|
||||||
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(definition.Endpoint)
|
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(definition.Endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Backend{}, fmt.Errorf("backend %q endpoint: %w", definition.ID, err)
|
return domain.Backend{}, fmt.Errorf("backend %q endpoint: %w", definition.ID, err)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package backend_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -11,32 +12,39 @@ import (
|
|||||||
|
|
||||||
const validEndpoint = "https://backend.example/v1"
|
const validEndpoint = "https://backend.example/v1"
|
||||||
|
|
||||||
func TestRegistryIncludesExactOpenRouterDefinition(t *testing.T) {
|
func TestRegistryIncludesMaintainedDefinitions(t *testing.T) {
|
||||||
registry, err := backend.NewRegistry(nil)
|
maintained := []domain.Backend{
|
||||||
|
{ID: backend.OpenRouterID, Endpoint: validEndpoint, ConcurrencyLimit: 2, QueueCapacity: 3, QueueCapacitySet: true},
|
||||||
|
{ID: backend.RakestrawHomeID, Endpoint: "https://second.example/v1", ConcurrencyLimit: 4, QueueCapacity: 5, QueueCapacitySet: true},
|
||||||
|
}
|
||||||
|
registry, err := backend.NewRegistry(maintained, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("construct registry: %v", err)
|
t.Fatalf("construct registry: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
definition, err := registry.GetBackend(backend.OpenRouterID)
|
for _, expected := range maintained {
|
||||||
if err != nil {
|
t.Run(expected.ID, func(t *testing.T) {
|
||||||
t.Fatalf("look up OpenRouter: %v", err)
|
definition, err := registry.GetBackend(expected.ID)
|
||||||
}
|
if err != nil {
|
||||||
if definition.ID != "openrouter" ||
|
t.Fatalf("look up maintained definition: %v", err)
|
||||||
definition.Endpoint != "https://openrouter.ai/api/v1" ||
|
}
|
||||||
definition.APIKeyEnv != "OPENROUTER_API_KEY" ||
|
if !reflect.DeepEqual(definition, expected) {
|
||||||
definition.ConcurrencyLimit != 16 ||
|
t.Fatalf("unexpected maintained definition: %#v", definition)
|
||||||
definition.QueueCapacity != 1024 ||
|
}
|
||||||
!definition.QueueCapacitySet ||
|
})
|
||||||
definition.ExtraParams != nil {
|
|
||||||
t.Fatalf("unexpected OpenRouter definition: %#v", definition)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
policies := registry.CapacityPolicies()
|
policies := registry.CapacityPolicies()
|
||||||
if len(policies) != 1 ||
|
if len(policies) != 2 ||
|
||||||
policies["openrouter"] != (domain.BackendCapacityPolicy{
|
policies[backend.OpenRouterID] != (domain.BackendCapacityPolicy{
|
||||||
ConcurrencyLimit: 16,
|
ConcurrencyLimit: 2,
|
||||||
QueueCapacity: 1024,
|
QueueCapacity: 3,
|
||||||
|
}) ||
|
||||||
|
policies[backend.RakestrawHomeID] != (domain.BackendCapacityPolicy{
|
||||||
|
ConcurrencyLimit: 4,
|
||||||
|
QueueCapacity: 5,
|
||||||
}) {
|
}) {
|
||||||
t.Fatalf("unexpected OpenRouter capacity policies: %#v", policies)
|
t.Fatalf("unexpected built-in capacity policies: %#v", policies)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +54,7 @@ func TestRegistryNormalizesUniqueAdditionsAndIsolatesMutations(t *testing.T) {
|
|||||||
"count": int64(7),
|
"count": int64(7),
|
||||||
"nested": nested,
|
"nested": nested,
|
||||||
}
|
}
|
||||||
registry, err := backend.NewRegistry([]domain.Backend{
|
registry, err := backend.NewRegistry(nil, []domain.Backend{
|
||||||
{
|
{
|
||||||
ID: " custom ",
|
ID: " custom ",
|
||||||
Endpoint: " https://custom.example/openai/v1 ",
|
Endpoint: " https://custom.example/openai/v1 ",
|
||||||
@@ -109,11 +117,11 @@ func TestRegistryNormalizesUniqueAdditionsAndIsolatesMutations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
policies := registry.CapacityPolicies()
|
policies := registry.CapacityPolicies()
|
||||||
if len(policies) != 2 {
|
if len(policies) != 1 {
|
||||||
t.Fatalf("unexpected capacity policy count: %#v", policies)
|
t.Fatalf("unexpected capacity policy count: %#v", policies)
|
||||||
}
|
}
|
||||||
policies["custom"] = domain.BackendCapacityPolicy{}
|
policies["custom"] = domain.BackendCapacityPolicy{}
|
||||||
delete(policies, backend.OpenRouterID)
|
delete(policies, "custom")
|
||||||
againPolicies := registry.CapacityPolicies()
|
againPolicies := registry.CapacityPolicies()
|
||||||
if againPolicies["custom"] != (domain.BackendCapacityPolicy{
|
if againPolicies["custom"] != (domain.BackendCapacityPolicy{
|
||||||
ConcurrencyLimit: 3,
|
ConcurrencyLimit: 3,
|
||||||
@@ -121,9 +129,6 @@ func TestRegistryNormalizesUniqueAdditionsAndIsolatesMutations(t *testing.T) {
|
|||||||
}) {
|
}) {
|
||||||
t.Fatalf("capacity policy map mutated registry state: %#v", againPolicies)
|
t.Fatalf("capacity policy map mutated registry state: %#v", againPolicies)
|
||||||
}
|
}
|
||||||
if _, ok := againPolicies[backend.OpenRouterID]; !ok {
|
|
||||||
t.Fatalf("capacity policy deletion mutated registry state: %#v", againPolicies)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewRegistryNormalizesCapacityPolicy(t *testing.T) {
|
func TestNewRegistryNormalizesCapacityPolicy(t *testing.T) {
|
||||||
@@ -199,7 +204,7 @@ func TestNewRegistryNormalizesCapacityPolicy(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
tc.definition.ID = "custom"
|
tc.definition.ID = "custom"
|
||||||
tc.definition.Endpoint = validEndpoint
|
tc.definition.Endpoint = validEndpoint
|
||||||
registry, err := backend.NewRegistry([]domain.Backend{tc.definition})
|
registry, err := backend.NewRegistry(nil, []domain.Backend{tc.definition})
|
||||||
if tc.wantError {
|
if tc.wantError {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected invalid capacity policy error")
|
t.Fatal("expected invalid capacity policy error")
|
||||||
@@ -242,11 +247,14 @@ func TestNewRegistryRejectsDuplicateIDs(t *testing.T) {
|
|||||||
wantID string
|
wantID string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "built-in collision after normalization",
|
name: "OpenRouter collision after normalization",
|
||||||
additions: []domain.Backend{{
|
additions: []domain.Backend{{ID: " openrouter "}},
|
||||||
ID: " openrouter ",
|
wantID: backend.OpenRouterID,
|
||||||
}},
|
},
|
||||||
wantID: "openrouter",
|
{
|
||||||
|
name: "Rakestrawhome collision after normalization",
|
||||||
|
additions: []domain.Backend{{ID: " rakestrawhome "}},
|
||||||
|
wantID: backend.RakestrawHomeID,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "consumer collision after normalization",
|
name: "consumer collision after normalization",
|
||||||
@@ -260,7 +268,7 @@ func TestNewRegistryRejectsDuplicateIDs(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
_, err := backend.NewRegistry(tc.additions)
|
_, err := backend.NewRegistry(nil, tc.additions)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected duplicate ID error")
|
t.Fatal("expected duplicate ID error")
|
||||||
}
|
}
|
||||||
@@ -274,7 +282,7 @@ func TestNewRegistryRejectsDuplicateIDs(t *testing.T) {
|
|||||||
func TestNewRegistryValidatesIDs(t *testing.T) {
|
func TestNewRegistryValidatesIDs(t *testing.T) {
|
||||||
for _, id := range []string{"", " \t\n "} {
|
for _, id := range []string{"", " \t\n "} {
|
||||||
t.Run(id, func(t *testing.T) {
|
t.Run(id, func(t *testing.T) {
|
||||||
_, err := backend.NewRegistry([]domain.Backend{{
|
_, err := backend.NewRegistry(nil, []domain.Backend{{
|
||||||
ID: id,
|
ID: id,
|
||||||
Endpoint: validEndpoint,
|
Endpoint: validEndpoint,
|
||||||
}})
|
}})
|
||||||
@@ -303,7 +311,7 @@ func TestNewRegistryValidatesEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
_, err := backend.NewRegistry([]domain.Backend{{
|
_, err := backend.NewRegistry(nil, []domain.Backend{{
|
||||||
ID: "custom",
|
ID: "custom",
|
||||||
Endpoint: tc.endpoint,
|
Endpoint: tc.endpoint,
|
||||||
}})
|
}})
|
||||||
@@ -317,7 +325,7 @@ func TestNewRegistryValidatesEndpoints(t *testing.T) {
|
|||||||
func TestNewRegistryValidatesEnvironmentVariableNames(t *testing.T) {
|
func TestNewRegistryValidatesEnvironmentVariableNames(t *testing.T) {
|
||||||
for _, name := range []string{"1API_KEY", "API-KEY", "API KEY", "ÅPI_KEY"} {
|
for _, name := range []string{"1API_KEY", "API-KEY", "API KEY", "ÅPI_KEY"} {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
_, err := backend.NewRegistry([]domain.Backend{{
|
_, err := backend.NewRegistry(nil, []domain.Backend{{
|
||||||
ID: "custom",
|
ID: "custom",
|
||||||
Endpoint: validEndpoint,
|
Endpoint: validEndpoint,
|
||||||
APIKeyEnv: name,
|
APIKeyEnv: name,
|
||||||
@@ -340,7 +348,7 @@ func TestNewRegistryRejectsInvalidAndReservedExtraParameters(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
_, err := backend.NewRegistry([]domain.Backend{{
|
_, err := backend.NewRegistry(nil, []domain.Backend{{
|
||||||
ID: "custom",
|
ID: "custom",
|
||||||
Endpoint: validEndpoint,
|
Endpoint: validEndpoint,
|
||||||
ExtraParams: tc.extraParams,
|
ExtraParams: tc.extraParams,
|
||||||
@@ -353,7 +361,7 @@ func TestNewRegistryRejectsInvalidAndReservedExtraParameters(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRegistryLookupReportsNotFound(t *testing.T) {
|
func TestRegistryLookupReportsNotFound(t *testing.T) {
|
||||||
registry, err := backend.NewRegistry(nil)
|
registry, err := backend.NewRegistry(nil, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("construct registry: %v", err)
|
t.Fatalf("construct registry: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ func (c *client) Generate(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req domain.GenerateRequest,
|
req domain.GenerateRequest,
|
||||||
) (*domain.GenerateResponse, error) {
|
) (*domain.GenerateResponse, error) {
|
||||||
pool := c.manager.getPool(req.Target.BackendID)
|
if c.manager.getPool(req.Target.BackendID) == nil {
|
||||||
if pool == nil {
|
|
||||||
return c.next.Generate(ctx, req)
|
return c.next.Generate(ctx, req)
|
||||||
}
|
}
|
||||||
if err := pool.acquire(ctx); err != nil {
|
release, err := c.manager.Acquire(ctx, req.Target.BackendID)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer pool.releaseActive()
|
defer release()
|
||||||
return c.next.Generate(ctx, req)
|
return c.next.Generate(ctx, req)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,26 @@ func (m *Manager) getPool(backendID string) *pool {
|
|||||||
return m.pools[backendID]
|
return m.pools[backendID]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Limited reports whether a backend uses an active-generation pool.
|
||||||
|
func (m *Manager) Limited(backendID string) bool { return m.getPool(backendID) != nil }
|
||||||
|
|
||||||
|
// Acquire obtains one active-generation permit for a backend. Its release
|
||||||
|
// function is idempotent. Backends without a configured pool are unlimited.
|
||||||
|
func (m *Manager) Acquire(ctx context.Context, backendID string) (func(), error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pool := m.getPool(backendID)
|
||||||
|
if pool == nil {
|
||||||
|
return releaseNothing, nil
|
||||||
|
}
|
||||||
|
if err := pool.acquire(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var once sync.Once
|
||||||
|
return func() { once.Do(pool.releaseActive) }, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *pool) acquire(ctx context.Context) error {
|
func (p *pool) acquire(ctx context.Context) error {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
|
|||||||
@@ -60,6 +60,26 @@ func TestNewManagerRejectsInvalidPolicies(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestManagerAcquireReleaseIsIdempotent(t *testing.T) {
|
||||||
|
manager, err := NewManager(map[string]domain.BackendCapacityPolicy{
|
||||||
|
"limited": {ConcurrencyLimit: 1},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
release, err := manager.Acquire(context.Background(), "limited")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
release()
|
||||||
|
release()
|
||||||
|
other, err := manager.Acquire(context.Background(), "limited")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
other()
|
||||||
|
}
|
||||||
|
|
||||||
func TestManagerAdmissionIsBoundedAndReleaseIsIdempotent(t *testing.T) {
|
func TestManagerAdmissionIsBoundedAndReleaseIsIdempotent(t *testing.T) {
|
||||||
policies := map[string]domain.BackendCapacityPolicy{
|
policies := map[string]domain.BackendCapacityPolicy{
|
||||||
"limited": {
|
"limited": {
|
||||||
|
|||||||
249
internal/catalog/catalog.go
Normal file
249
internal/catalog/catalog.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
// Package catalog validates immutable maintained backend catalog assets.
|
||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Source identifies one immutable backend catalog asset tree.
|
||||||
|
type Source struct {
|
||||||
|
Name string
|
||||||
|
ExpectedBackendID string
|
||||||
|
FS fs.FS
|
||||||
|
Root string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set is the validated maintained backend and raw profile catalog.
|
||||||
|
type Set struct {
|
||||||
|
Backends []domain.Backend
|
||||||
|
Profiles profile.Repository
|
||||||
|
profileIDs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load validates and combines immutable catalog sources in source order.
|
||||||
|
func Load(sources ...Source) (Set, error) {
|
||||||
|
if len(sources) == 0 {
|
||||||
|
return Set{}, errors.New("at least one catalog source is required")
|
||||||
|
}
|
||||||
|
loaded := Set{Backends: make([]domain.Backend, 0, len(sources))}
|
||||||
|
names := map[string]bool{}
|
||||||
|
backendIDs := map[string]bool{}
|
||||||
|
profileIDs := map[string]bool{}
|
||||||
|
for _, source := range sources {
|
||||||
|
if err := validateSource(source, names); err != nil {
|
||||||
|
return Set{}, err
|
||||||
|
}
|
||||||
|
names[source.Name] = true
|
||||||
|
definition, err := loadBackend(source)
|
||||||
|
if err != nil {
|
||||||
|
return Set{}, err
|
||||||
|
}
|
||||||
|
if backendIDs[definition.ID] {
|
||||||
|
return Set{}, fmt.Errorf("catalog %s: backend ID duplicates an earlier catalog", source.Name)
|
||||||
|
}
|
||||||
|
backendIDs[definition.ID] = true
|
||||||
|
repository, metadata, err := profile.LoadFSRepository(context.Background(), source.FS, path.Join(source.Root, "profiles"))
|
||||||
|
if err != nil {
|
||||||
|
return Set{}, fmt.Errorf("catalog %s: %w", source.Name, err)
|
||||||
|
}
|
||||||
|
if len(metadata) == 0 {
|
||||||
|
return Set{}, fmt.Errorf("catalog %s: profiles must not be empty", source.Name)
|
||||||
|
}
|
||||||
|
resolvingRepository := profile.NewResolvingRepository(repository)
|
||||||
|
for _, entry := range metadata {
|
||||||
|
if strings.HasPrefix(entry.ID, "promptkit-") {
|
||||||
|
return Set{}, fmt.Errorf("catalog %s: %s uses the reserved promptkit- profile ID prefix", source.Name, entry.Path)
|
||||||
|
}
|
||||||
|
if profileIDs[entry.ID] {
|
||||||
|
return Set{}, fmt.Errorf("catalog %s: %s duplicates an earlier profile ID", source.Name, entry.Path)
|
||||||
|
}
|
||||||
|
if containsField(entry.ExplicitFields, "endpoint") || containsField(entry.ExplicitFields, "api_key_env") {
|
||||||
|
return Set{}, fmt.Errorf("catalog %s: %s contains connection metadata", source.Name, entry.Path)
|
||||||
|
}
|
||||||
|
value, err := repository.GetProfile(context.Background(), entry.ID)
|
||||||
|
if err != nil {
|
||||||
|
return Set{}, fmt.Errorf("catalog %s: %s: %w", source.Name, entry.Path, err)
|
||||||
|
}
|
||||||
|
if err := rejectSecretKeys(value.ExtraParams); err != nil {
|
||||||
|
return Set{}, fmt.Errorf("catalog %s: %s: prohibited extra parameter key", source.Name, entry.Path)
|
||||||
|
}
|
||||||
|
resolved, err := resolvingRepository.GetProfile(context.Background(), entry.ID)
|
||||||
|
if err != nil {
|
||||||
|
return Set{}, fmt.Errorf("catalog %s: %s has invalid profile inheritance", source.Name, entry.Path)
|
||||||
|
}
|
||||||
|
if resolved.BackendID != definition.ID {
|
||||||
|
return Set{}, fmt.Errorf("catalog %s: %s selects a different backend", source.Name, entry.Path)
|
||||||
|
}
|
||||||
|
profileIDs[entry.ID] = true
|
||||||
|
loaded.profileIDs = append(loaded.profileIDs, entry.ID)
|
||||||
|
}
|
||||||
|
if loaded.Profiles == nil {
|
||||||
|
loaded.Profiles = repository
|
||||||
|
} else {
|
||||||
|
loaded.Profiles = profile.NewOverlayRepository(loaded.Profiles, repository)
|
||||||
|
}
|
||||||
|
loaded.Backends = append(loaded.Backends, definition)
|
||||||
|
}
|
||||||
|
sort.Strings(loaded.profileIDs)
|
||||||
|
return loaded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSource(source Source, names map[string]bool) error {
|
||||||
|
if strings.TrimSpace(source.Name) == "" || source.Name != strings.TrimSpace(source.Name) {
|
||||||
|
return errors.New("catalog source name must not be blank")
|
||||||
|
}
|
||||||
|
if names[source.Name] {
|
||||||
|
return fmt.Errorf("duplicate catalog source name %q", source.Name)
|
||||||
|
}
|
||||||
|
if source.FS == nil {
|
||||||
|
return fmt.Errorf("catalog %s: filesystem is nil", source.Name)
|
||||||
|
}
|
||||||
|
if source.Root == "." || source.Root == "" || !fs.ValidPath(source.Root) {
|
||||||
|
return fmt.Errorf("catalog %s: asset root is invalid", source.Name)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(source.ExpectedBackendID) == "" {
|
||||||
|
return fmt.Errorf("catalog %s: expected backend ID is blank", source.Name)
|
||||||
|
}
|
||||||
|
return validateLayout(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateLayout(source Source) error {
|
||||||
|
manifestPath := path.Join(source.Root, "backend.json")
|
||||||
|
profilesRoot := path.Join(source.Root, "profiles")
|
||||||
|
return fs.WalkDir(source.FS, source.Root, func(assetPath string, entry fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if assetPath == source.Root {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
return fmt.Errorf("catalog %s: invalid asset path %s", source.Name, assetPath)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if entry.IsDir() {
|
||||||
|
if assetPath == profilesRoot || strings.HasPrefix(assetPath, profilesRoot+"/") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("catalog %s: invalid asset path %s", source.Name, assetPath)
|
||||||
|
}
|
||||||
|
if assetPath == manifestPath && entry.Type().IsRegular() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(assetPath, profilesRoot+"/") && entry.Type().IsRegular() && strings.HasSuffix(assetPath, ".yml") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("catalog %s: invalid asset path %s", source.Name, assetPath)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadBackend(source Source) (domain.Backend, error) {
|
||||||
|
data, err := fs.ReadFile(source.FS, path.Join(source.Root, "backend.json"))
|
||||||
|
if err != nil {
|
||||||
|
return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: %w", source.Name, err)
|
||||||
|
}
|
||||||
|
type manifest struct {
|
||||||
|
SchemaVersion *int `json:"schema_version"`
|
||||||
|
ID *string `json:"id"`
|
||||||
|
Endpoint *string `json:"endpoint"`
|
||||||
|
APIKeyEnv *string `json:"api_key_env"`
|
||||||
|
ConcurrencyLimit *int `json:"concurrency_limit"`
|
||||||
|
QueueCapacity *int `json:"queue_capacity"`
|
||||||
|
ExtraParams json.RawMessage `json:"extra_params"`
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
var value manifest
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: invalid manifest", source.Name)
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||||
|
return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: invalid manifest", source.Name)
|
||||||
|
}
|
||||||
|
if value.SchemaVersion == nil || *value.SchemaVersion != 1 || value.ID == nil || value.Endpoint == nil || value.APIKeyEnv == nil || value.ConcurrencyLimit == nil || value.QueueCapacity == nil || value.ExtraParams == nil {
|
||||||
|
return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: required field is missing or unsupported", source.Name)
|
||||||
|
}
|
||||||
|
if *value.ID != source.ExpectedBackendID {
|
||||||
|
return domain.Backend{}, fmt.Errorf("catalog %s: backend ID does not match expected ID", source.Name)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(*value.APIKeyEnv) == "" {
|
||||||
|
return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: api key environment variable must not be blank", source.Name)
|
||||||
|
}
|
||||||
|
extraParams, err := decodeExtraParams(value.ExtraParams)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: invalid extra parameters", source.Name)
|
||||||
|
}
|
||||||
|
if err := rejectSecretKeys(extraParams); err != nil {
|
||||||
|
return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: prohibited extra parameter key", source.Name)
|
||||||
|
}
|
||||||
|
normalized, err := backend.NormalizeDefinition(domain.Backend{ID: *value.ID, Endpoint: *value.Endpoint, APIKeyEnv: *value.APIKeyEnv, ExtraParams: extraParams, ConcurrencyLimit: *value.ConcurrencyLimit, QueueCapacity: *value.QueueCapacity, QueueCapacitySet: true})
|
||||||
|
if err != nil {
|
||||||
|
return domain.Backend{}, fmt.Errorf("catalog %s: backend.json: invalid backend definition", source.Name)
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeExtraParams(data []byte) (map[string]any, error) {
|
||||||
|
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||||
|
decoder.UseNumber()
|
||||||
|
var value map[string]any
|
||||||
|
if err := decoder.Decode(&value); err != nil || value == nil {
|
||||||
|
return nil, errors.New("extra parameters must be an object or null")
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||||
|
return nil, errors.New("extra parameters must contain exactly one JSON value")
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsField(fields []string, target string) bool {
|
||||||
|
for _, field := range fields {
|
||||||
|
if field == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectSecretKeys(value map[string]any) error {
|
||||||
|
return rejectSecretValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectSecretValue(value any) error {
|
||||||
|
switch value := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for key, child := range value {
|
||||||
|
switch strings.ToLower(key) {
|
||||||
|
case "api_key", "apikey", "authorization", "credential", "credentials", "password", "secret", "token", "access_token":
|
||||||
|
return errors.New("prohibited key")
|
||||||
|
}
|
||||||
|
if err := rejectSecretValue(child); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range value {
|
||||||
|
if err := rejectSecretValue(child); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
356
internal/catalog/catalog_test.go
Normal file
356
internal/catalog/catalog_test.go
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
package catalog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
openrouter "gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2"
|
||||||
|
rakestrawhome "gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadPublishedCatalogs(t *testing.T) {
|
||||||
|
loaded, err := Load(
|
||||||
|
Source{Name: "OpenRouter", ExpectedBackendID: "openrouter", FS: openrouter.FS(), Root: openrouter.Root},
|
||||||
|
Source{Name: "Rakestrawhome", ExpectedBackendID: "rakestrawhome", FS: rakestrawhome.FS(), Root: rakestrawhome.Root},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load published catalogs: %v", err)
|
||||||
|
}
|
||||||
|
if loaded.Profiles == nil || len(loaded.profileIDs) == 0 {
|
||||||
|
t.Fatalf("published catalogs have no usable profiles: %#v", loaded)
|
||||||
|
}
|
||||||
|
if _, err := loaded.Profiles.GetProfile(context.Background(), "gemini-flash-latest"); err != nil {
|
||||||
|
t.Fatalf("load maintained standard-profile base: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedBackends := map[string]struct {
|
||||||
|
endpoint string
|
||||||
|
apiKeyEnv string
|
||||||
|
concurrencyLimit int
|
||||||
|
queueCapacity int
|
||||||
|
}{
|
||||||
|
"openrouter": {
|
||||||
|
endpoint: "https://openrouter.ai/api/v1",
|
||||||
|
apiKeyEnv: "OPENROUTER_API_KEY",
|
||||||
|
concurrencyLimit: 16,
|
||||||
|
queueCapacity: 1024,
|
||||||
|
},
|
||||||
|
"rakestrawhome": {
|
||||||
|
endpoint: "https://inference.ai.rakestrawhome.com/v1",
|
||||||
|
apiKeyEnv: "RAKESTRAWHOME_INFERENCE_API_KEY",
|
||||||
|
concurrencyLimit: 4,
|
||||||
|
queueCapacity: 1024,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if len(loaded.Backends) != len(expectedBackends) {
|
||||||
|
t.Fatalf("published backend count = %d, want %d", len(loaded.Backends), len(expectedBackends))
|
||||||
|
}
|
||||||
|
for _, definition := range loaded.Backends {
|
||||||
|
expected, found := expectedBackends[definition.ID]
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("unexpected published backend %q", definition.ID)
|
||||||
|
}
|
||||||
|
if definition.Endpoint != expected.endpoint ||
|
||||||
|
definition.APIKeyEnv != expected.apiKeyEnv ||
|
||||||
|
definition.ConcurrencyLimit != expected.concurrencyLimit ||
|
||||||
|
definition.QueueCapacity != expected.queueCapacity ||
|
||||||
|
!definition.QueueCapacitySet ||
|
||||||
|
definition.ExtraParams != nil {
|
||||||
|
t.Fatalf("unexpected published backend definition: %#v", definition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsInvalidSources(t *testing.T) {
|
||||||
|
for name, sources := range map[string][]Source{
|
||||||
|
"none": nil,
|
||||||
|
"blank name": {{Name: " ", ExpectedBackendID: "openrouter", FS: openrouter.FS(), Root: openrouter.Root}},
|
||||||
|
"duplicate name": {
|
||||||
|
{Name: "same", ExpectedBackendID: "openrouter", FS: openrouter.FS(), Root: openrouter.Root},
|
||||||
|
{Name: "same", ExpectedBackendID: "rakestrawhome", FS: rakestrawhome.FS(), Root: rakestrawhome.Root},
|
||||||
|
},
|
||||||
|
"nil filesystem": {{Name: "missing", ExpectedBackendID: "openrouter", Root: openrouter.Root}},
|
||||||
|
"invalid root": {{Name: "invalid-root", ExpectedBackendID: "openrouter", FS: openrouter.FS(), Root: "."}},
|
||||||
|
"blank expected backend": {{Name: "blank-backend", ExpectedBackendID: " ", FS: openrouter.FS(), Root: openrouter.Root}},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if _, err := Load(sources...); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsInvalidLayouts(t *testing.T) {
|
||||||
|
tests := map[string]func(fstest.MapFS){
|
||||||
|
"unexpected file": func(fsys fstest.MapFS) {
|
||||||
|
fsys["catalog/notes.txt"] = &fstest.MapFile{Data: []byte("unexpected")}
|
||||||
|
},
|
||||||
|
"unexpected directory": func(fsys fstest.MapFS) {
|
||||||
|
fsys["catalog/unexpected"] = &fstest.MapFile{Mode: fs.ModeDir}
|
||||||
|
},
|
||||||
|
"nonregular manifest": func(fsys fstest.MapFS) {
|
||||||
|
fsys["catalog/backend.json"].Mode = fs.ModeSymlink
|
||||||
|
},
|
||||||
|
"wrong profile extension": func(fsys fstest.MapFS) {
|
||||||
|
fsys["catalog/profiles/extra.yaml"] = &fstest.MapFile{Data: []byte(validProfile("extra", "one"))}
|
||||||
|
},
|
||||||
|
"nonregular profile": func(fsys fstest.MapFS) {
|
||||||
|
fsys["catalog/profiles/one-profile.yml"].Mode = fs.ModeSymlink
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for name, mutate := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
fsys := validCatalogFS("one")
|
||||||
|
mutate(fsys)
|
||||||
|
if _, err := Load(testSource("one", fsys)); err == nil {
|
||||||
|
t.Fatal("expected invalid layout error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsInvalidManifests(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
"malformed": `{`,
|
||||||
|
"trailing value": validManifest("one", "TEST_API_KEY", "null") + `{}`,
|
||||||
|
"missing fields": `{"schema_version":1,"id":"one"}`,
|
||||||
|
"unsupported version": strings.Replace(validManifest("one", "TEST_API_KEY", "null"), `"schema_version":1`, `"schema_version":2`, 1),
|
||||||
|
"unknown field": strings.Replace(validManifest("one", "TEST_API_KEY", "null"), `"extra_params":null`, `"extra_params":null,"unknown":true`, 1),
|
||||||
|
"blank API key env": validManifest("one", " ", "null"),
|
||||||
|
"invalid API key env": validManifest("one", "LEAK-MARKER", "null"),
|
||||||
|
"invalid endpoint": strings.Replace(validManifest("one", "TEST_API_KEY", "null"), `https://one.example/v1`, `ftp://leak-marker.invalid/v1`, 1),
|
||||||
|
"zero concurrency": strings.Replace(validManifest("one", "TEST_API_KEY", "null"), `"concurrency_limit":2`, `"concurrency_limit":0`, 1),
|
||||||
|
"non-object parameters": validManifest("one", "TEST_API_KEY", `[]`),
|
||||||
|
"secret parameter": validManifest("one", "TEST_API_KEY", `{"nested":{"token":"leak-marker"}}`),
|
||||||
|
}
|
||||||
|
for name, manifest := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
fsys := validCatalogFS("one")
|
||||||
|
fsys["catalog/backend.json"].Data = []byte(manifest)
|
||||||
|
_, err := Load(testSource("one", fsys))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected invalid manifest error")
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(err.Error()), "leak-marker") {
|
||||||
|
t.Fatalf("catalog error exposed manifest content: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadPreservesManifestJSONNumbers(t *testing.T) {
|
||||||
|
fsys := validCatalogFS("one")
|
||||||
|
fsys["catalog/backend.json"].Data = []byte(validManifest(
|
||||||
|
"one",
|
||||||
|
"TEST_API_KEY",
|
||||||
|
`{"large":9007199254740993,"nested":[1.25]}`,
|
||||||
|
))
|
||||||
|
loaded, err := Load(testSource("one", fsys))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load catalog: %v", err)
|
||||||
|
}
|
||||||
|
if got := loaded.Backends[0].ExtraParams["large"]; got != json.Number("9007199254740993") {
|
||||||
|
t.Fatalf("large JSON integer = %#v, want preserved json.Number", got)
|
||||||
|
}
|
||||||
|
nested := loaded.Backends[0].ExtraParams["nested"].([]any)
|
||||||
|
if nested[0] != json.Number("1.25") {
|
||||||
|
t.Fatalf("nested JSON number = %#v, want preserved json.Number", nested[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsInvalidCatalogProfiles(t *testing.T) {
|
||||||
|
tests := map[string]func(fstest.MapFS){
|
||||||
|
"empty": func(fsys fstest.MapFS) {
|
||||||
|
delete(fsys, "catalog/profiles/one-profile.yml")
|
||||||
|
fsys["catalog/profiles"] = &fstest.MapFile{Mode: fs.ModeDir}
|
||||||
|
},
|
||||||
|
"malformed": func(fsys fstest.MapFS) {
|
||||||
|
fsys["catalog/profiles/one-profile.yml"].Data = []byte("id: [")
|
||||||
|
},
|
||||||
|
"raw API key": func(fsys fstest.MapFS) {
|
||||||
|
fsys["catalog/profiles/one-profile.yml"].Data = []byte(validProfile("one-profile", "one") + "api_key: leak-marker\n")
|
||||||
|
},
|
||||||
|
"endpoint field": func(fsys fstest.MapFS) {
|
||||||
|
fsys["catalog/profiles/one-profile.yml"].Data = []byte(validProfile("one-profile", "one") + "endpoint: ''\n")
|
||||||
|
},
|
||||||
|
"API key environment field": func(fsys fstest.MapFS) {
|
||||||
|
fsys["catalog/profiles/one-profile.yml"].Data = []byte(validProfile("one-profile", "one") + "api_key_env: ''\n")
|
||||||
|
},
|
||||||
|
"owner mismatch": func(fsys fstest.MapFS) {
|
||||||
|
fsys["catalog/profiles/one-profile.yml"].Data = []byte(validProfile("one-profile", "other"))
|
||||||
|
},
|
||||||
|
"missing base": func(fsys fstest.MapFS) {
|
||||||
|
fsys["catalog/profiles/one-profile.yml"].Data = []byte("id: one-profile\nbase_profile: leak-marker\n")
|
||||||
|
},
|
||||||
|
"cyclic base": func(fsys fstest.MapFS) {
|
||||||
|
fsys["catalog/profiles/one-profile.yml"].Data = []byte("id: one-profile\nbase_profile: second\n")
|
||||||
|
fsys["catalog/profiles/second.yml"] = &fstest.MapFile{Data: []byte("id: second\nbase_profile: one-profile\n")}
|
||||||
|
},
|
||||||
|
"secret profile parameter": func(fsys fstest.MapFS) {
|
||||||
|
fsys["catalog/profiles/one-profile.yml"].Data = []byte(validProfile("one-profile", "one") + "extra_params:\n nested:\n password: leak-marker\n")
|
||||||
|
},
|
||||||
|
"unknown field is redacted": func(fsys fstest.MapFS) {
|
||||||
|
fsys["catalog/profiles/one-profile.yml"].Data = []byte(validProfile("one-profile", "one") + "leak_marker: leak-marker\n")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for name, mutate := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
fsys := validCatalogFS("one")
|
||||||
|
mutate(fsys)
|
||||||
|
_, err := Load(testSource("one", fsys))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected invalid profile error")
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(err.Error()), "leak-marker") {
|
||||||
|
t.Fatalf("catalog error exposed profile content: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadReservesPromptkitProfilePrefixForCatalogs(t *testing.T) {
|
||||||
|
tests := map[string]struct {
|
||||||
|
profileID string
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
"maintained name": {
|
||||||
|
profileID: "promptkit-openrouter-standard",
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
"other reserved name": {
|
||||||
|
profileID: "promptkit-example",
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
"ordinary name": {
|
||||||
|
profileID: "ordinary-profile",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for name, test := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
fsys := validCatalogFS("one")
|
||||||
|
fsys["catalog/profiles/one-profile.yml"].Data = []byte(validProfile(test.profileID, "one"))
|
||||||
|
|
||||||
|
_, err := Load(testSource("one", fsys))
|
||||||
|
if (err != nil) != test.wantError {
|
||||||
|
t.Fatalf("Load() error = %v, want error %t", err, test.wantError)
|
||||||
|
}
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "catalog one: one-profile.yml") {
|
||||||
|
t.Fatalf("Load() error = %v, want catalog source and path context", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsCrossCatalogConflicts(t *testing.T) {
|
||||||
|
t.Run("duplicate backend", func(t *testing.T) {
|
||||||
|
second := catalogFS("one", map[string]string{
|
||||||
|
"catalog/profiles/second.yml": validProfile("second", "one"),
|
||||||
|
}, "null")
|
||||||
|
_, err := Load(
|
||||||
|
Source{Name: "first", ExpectedBackendID: "one", FS: validCatalogFS("one"), Root: "catalog"},
|
||||||
|
Source{Name: "second", ExpectedBackendID: "one", FS: second, Root: "catalog"},
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected duplicate backend error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("duplicate profile", func(t *testing.T) {
|
||||||
|
first := catalogFS("one", map[string]string{
|
||||||
|
"catalog/profiles/shared.yml": validProfile("shared", "one"),
|
||||||
|
}, "null")
|
||||||
|
second := catalogFS("two", map[string]string{
|
||||||
|
"catalog/profiles/shared.yml": validProfile("shared", "two"),
|
||||||
|
}, "null")
|
||||||
|
_, err := Load(testSource("one", first), testSource("two", second))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected duplicate profile error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cross-catalog base", func(t *testing.T) {
|
||||||
|
first := catalogFS("one", map[string]string{
|
||||||
|
"catalog/profiles/base.yml": validProfile("base", "one"),
|
||||||
|
}, "null")
|
||||||
|
second := catalogFS("two", map[string]string{
|
||||||
|
"catalog/profiles/child.yml": "id: child\nbase_profile: base\n",
|
||||||
|
}, "null")
|
||||||
|
_, err := Load(testSource("one", first), testSource("two", second))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected cross-catalog base error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadReturnsDefensiveCatalogValues(t *testing.T) {
|
||||||
|
fsys := catalogFS("one", map[string]string{
|
||||||
|
"catalog/profiles/one-profile.yml": validProfile("one-profile", "one") + "extra_params:\n nested:\n value: profile\n",
|
||||||
|
}, `{"nested":{"value":"backend"}}`)
|
||||||
|
loaded, err := Load(testSource("one", fsys))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load catalog: %v", err)
|
||||||
|
}
|
||||||
|
loaded.Backends[0].ExtraParams["nested"].(map[string]any)["value"] = "changed"
|
||||||
|
profileValue, err := loaded.Profiles.GetProfile(context.Background(), "one-profile")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load profile: %v", err)
|
||||||
|
}
|
||||||
|
profileValue.ExtraParams["nested"].(map[string]any)["value"] = "changed"
|
||||||
|
|
||||||
|
again, err := Load(testSource("one", fsys))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload catalog: %v", err)
|
||||||
|
}
|
||||||
|
if got := again.Backends[0].ExtraParams["nested"].(map[string]any)["value"]; got != "backend" {
|
||||||
|
t.Fatalf("backend mutation escaped returned set: %#v", got)
|
||||||
|
}
|
||||||
|
againProfile, err := loaded.Profiles.GetProfile(context.Background(), "one-profile")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload profile: %v", err)
|
||||||
|
}
|
||||||
|
if got := againProfile.ExtraParams["nested"].(map[string]any)["value"]; got != "profile" {
|
||||||
|
t.Fatalf("profile mutation escaped returned value: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSource(id string, fsys fs.FS) Source {
|
||||||
|
return Source{Name: id, ExpectedBackendID: id, FS: fsys, Root: "catalog"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCatalogFS(id string) fstest.MapFS {
|
||||||
|
return catalogFS(id, map[string]string{
|
||||||
|
"catalog/profiles/" + id + "-profile.yml": validProfile(id+"-profile", id),
|
||||||
|
}, "null")
|
||||||
|
}
|
||||||
|
|
||||||
|
func catalogFS(id string, profiles map[string]string, extraParams string) fstest.MapFS {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"catalog/backend.json": &fstest.MapFile{Data: []byte(validManifest(id, "TEST_API_KEY", extraParams))},
|
||||||
|
}
|
||||||
|
for name, content := range profiles {
|
||||||
|
fsys[name] = &fstest.MapFile{Data: []byte(content)}
|
||||||
|
}
|
||||||
|
return fsys
|
||||||
|
}
|
||||||
|
|
||||||
|
func validManifest(id, apiKeyEnv, extraParams string) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
`{"schema_version":1,"id":%q,"endpoint":%q,"api_key_env":%q,"concurrency_limit":2,"queue_capacity":3,"extra_params":%s}`,
|
||||||
|
id,
|
||||||
|
"https://"+id+".example/v1",
|
||||||
|
apiKeyEnv,
|
||||||
|
extraParams,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validProfile(id, backendID string) string {
|
||||||
|
return fmt.Sprintf("id: %s\nbackend: %s\nmodel: test-model\n", id, backendID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ fs.FS = openrouter.FS()
|
||||||
@@ -60,15 +60,18 @@ type CacheControl struct {
|
|||||||
|
|
||||||
// RunRequest represents a request to generate a single artifact.
|
// RunRequest represents a request to generate a single artifact.
|
||||||
type RunRequest struct {
|
type RunRequest struct {
|
||||||
PromptID string
|
PromptID string
|
||||||
PromptVersion string
|
PromptVersion string
|
||||||
ProfileID string
|
ProfileID string
|
||||||
SessionID string
|
SessionID string
|
||||||
APIKey string `json:"-" yaml:"-"`
|
APIKey string `json:"-" yaml:"-"`
|
||||||
Inputs map[string]ArtifactRef
|
Inputs map[string]ArtifactRef
|
||||||
Vars map[string]string
|
Vars map[string]string
|
||||||
Execution *ExecutionTargetOverride
|
Execution *ExecutionTargetOverride
|
||||||
Validation *OutputContract
|
Validation *OutputContract
|
||||||
|
AppendedMessages []RenderedMessage
|
||||||
|
TransientRetries *int
|
||||||
|
Observer ExecutionObserver `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunResult represents the complete result of a prompt execution run.
|
// RunResult represents the complete result of a prompt execution run.
|
||||||
@@ -192,6 +195,7 @@ type BackendCapacityPolicy struct {
|
|||||||
// ExecutionProfile describes how and where to execute a model.
|
// ExecutionProfile describes how and where to execute a model.
|
||||||
type ExecutionProfile struct {
|
type ExecutionProfile struct {
|
||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
|
BaseProfileID string `yaml:"base_profile"`
|
||||||
BackendID string `yaml:"backend"`
|
BackendID string `yaml:"backend"`
|
||||||
Endpoint string `yaml:"endpoint"`
|
Endpoint string `yaml:"endpoint"`
|
||||||
Model string `yaml:"model"`
|
Model string `yaml:"model"`
|
||||||
@@ -280,6 +284,8 @@ type GenerateRequest struct {
|
|||||||
Target ExecutionTarget
|
Target ExecutionTarget
|
||||||
TargetPresence ExecutionTargetPresence
|
TargetPresence ExecutionTargetPresence
|
||||||
StructuredOutput *StructuredOutputSpec
|
StructuredOutput *StructuredOutputSpec
|
||||||
|
TransientRetries uint64
|
||||||
|
Generation uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
// StructuredOutputType indicates which provider-level output mode is requested.
|
// StructuredOutputType indicates which provider-level output mode is requested.
|
||||||
@@ -304,8 +310,18 @@ type StructuredOutputJSONSpec struct {
|
|||||||
|
|
||||||
// GenerateResponse is the response received from the LLM client.
|
// GenerateResponse is the response received from the LLM client.
|
||||||
type GenerateResponse struct {
|
type GenerateResponse struct {
|
||||||
Content string
|
Content string
|
||||||
Usage TokenUsage
|
Usage TokenUsage
|
||||||
|
UsagePresence *TokenUsagePresence
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenUsagePresence records which token fields a provider reported.
|
||||||
|
type TokenUsagePresence struct {
|
||||||
|
PromptTokens bool
|
||||||
|
CompletionTokens bool
|
||||||
|
TotalTokens bool
|
||||||
|
CachedTokens bool
|
||||||
|
CacheWriteTokens bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// TokenUsage tracks token consumption.
|
// TokenUsage tracks token consumption.
|
||||||
|
|||||||
82
internal/domain/message.go
Normal file
82
internal/domain/message.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleDeveloper = "developer"
|
||||||
|
RoleSystem = "system"
|
||||||
|
RoleUser = "user"
|
||||||
|
RoleAssistant = "assistant"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NormalizeMessageRole validates and canonicalizes a provider-bound chat role.
|
||||||
|
func NormalizeMessageRole(role string) (string, error) {
|
||||||
|
if !utf8.ValidString(role) {
|
||||||
|
return "", errors.New("message role must be valid UTF-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(role))
|
||||||
|
switch normalized {
|
||||||
|
case RoleDeveloper, RoleSystem, RoleUser, RoleAssistant:
|
||||||
|
return normalized, nil
|
||||||
|
default:
|
||||||
|
return "", errors.New("message role must be developer, system, user, or assistant")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeCacheControl validates, canonicalizes, and copies cache metadata.
|
||||||
|
func NormalizeCacheControl(control *CacheControl) (*CacheControl, error) {
|
||||||
|
if control == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(string(control.Type)) {
|
||||||
|
return nil, errors.New("cache control type must be valid UTF-8")
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(control.TTL) {
|
||||||
|
return nil, errors.New("cache control ttl must be valid UTF-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheType := strings.TrimSpace(string(control.Type))
|
||||||
|
if cacheType == "" {
|
||||||
|
return nil, errors.New("cache control type is required")
|
||||||
|
}
|
||||||
|
if CacheControlType(cacheType) != CacheControlEphemeral {
|
||||||
|
return nil, errors.New("unsupported type")
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl := strings.TrimSpace(control.TTL)
|
||||||
|
if ttl != "" && ttl != "1h" {
|
||||||
|
return nil, errors.New("unsupported ttl")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CacheControl{Type: CacheControlType(cacheType), TTL: ttl}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloneRenderedMessages returns a deep copy of rendered messages.
|
||||||
|
func CloneRenderedMessages(messages []RenderedMessage) []RenderedMessage {
|
||||||
|
cloned := make([]RenderedMessage, len(messages))
|
||||||
|
copyRenderedMessages(cloned, messages)
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConcatRenderedMessages returns an independently owned concatenation of messages.
|
||||||
|
func ConcatRenderedMessages(prefix, suffix []RenderedMessage) []RenderedMessage {
|
||||||
|
messages := make([]RenderedMessage, len(prefix)+len(suffix))
|
||||||
|
copyRenderedMessages(messages, prefix)
|
||||||
|
copyRenderedMessages(messages[len(prefix):], suffix)
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyRenderedMessages(destination, source []RenderedMessage) {
|
||||||
|
for index, message := range source {
|
||||||
|
destination[index] = message
|
||||||
|
if message.CacheControl != nil {
|
||||||
|
cacheControl := *message.CacheControl
|
||||||
|
destination[index].CacheControl = &cacheControl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
121
internal/domain/message_test.go
Normal file
121
internal/domain/message_test.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeMessageRole(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "developer", input: RoleDeveloper, want: RoleDeveloper},
|
||||||
|
{name: "system", input: RoleSystem, want: RoleSystem},
|
||||||
|
{name: "user", input: RoleUser, want: RoleUser},
|
||||||
|
{name: "assistant", input: RoleAssistant, want: RoleAssistant},
|
||||||
|
{name: "surrounding whitespace and mixed case", input: " \u2003UsEr\u2003 ", want: RoleUser},
|
||||||
|
{name: "blank", input: " \t\n ", wantErr: true},
|
||||||
|
{name: "tool", input: "tool", wantErr: true},
|
||||||
|
{name: "function", input: "function", wantErr: true},
|
||||||
|
{name: "custom", input: "custom-role", wantErr: true},
|
||||||
|
{name: "invalid UTF-8", input: string([]byte{0xff}), wantErr: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got, err := NormalizeMessageRole(test.input)
|
||||||
|
if test.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), test.input) {
|
||||||
|
t.Fatalf("error exposed the input: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NormalizeMessageRole() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != test.want {
|
||||||
|
t.Fatalf("NormalizeMessageRole() = %q, want %q", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeCacheControl(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input *CacheControl
|
||||||
|
want *CacheControl
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "nil", input: nil, want: nil},
|
||||||
|
{
|
||||||
|
name: "canonical values",
|
||||||
|
input: &CacheControl{Type: CacheControlEphemeral, TTL: "1h"},
|
||||||
|
want: &CacheControl{Type: CacheControlEphemeral, TTL: "1h"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trims values",
|
||||||
|
input: &CacheControl{Type: " ephemeral ", TTL: " 1h\t"},
|
||||||
|
want: &CacheControl{Type: CacheControlEphemeral, TTL: "1h"},
|
||||||
|
},
|
||||||
|
{name: "empty type", input: &CacheControl{}, wantErr: true},
|
||||||
|
{name: "unsupported type", input: &CacheControl{Type: "persistent"}, wantErr: true},
|
||||||
|
{name: "unsupported ttl", input: &CacheControl{Type: CacheControlEphemeral, TTL: "5m"}, wantErr: true},
|
||||||
|
{name: "invalid type UTF-8", input: &CacheControl{Type: CacheControlType(string([]byte{0xff}))}, wantErr: true},
|
||||||
|
{name: "invalid ttl UTF-8", input: &CacheControl{Type: CacheControlEphemeral, TTL: string([]byte{0xff})}, wantErr: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got, err := NormalizeCacheControl(test.input)
|
||||||
|
if test.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NormalizeCacheControl() error = %v", err)
|
||||||
|
}
|
||||||
|
if got == nil || test.want == nil {
|
||||||
|
if got != test.want {
|
||||||
|
t.Fatalf("NormalizeCacheControl() = %#v, want %#v", got, test.want)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if *got != *test.want {
|
||||||
|
t.Fatalf("NormalizeCacheControl() = %#v, want %#v", got, test.want)
|
||||||
|
}
|
||||||
|
if got == test.input {
|
||||||
|
t.Fatal("normalized cache control aliases its input")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderedMessageCloning(t *testing.T) {
|
||||||
|
prefix := []RenderedMessage{{Role: RoleSystem, Content: "prefix", CacheControl: &CacheControl{Type: CacheControlEphemeral, TTL: "1h"}}}
|
||||||
|
suffix := []RenderedMessage{{Role: RoleUser, Content: " suffix "}, {Role: RoleAssistant, Content: "", CacheControl: &CacheControl{Type: CacheControlEphemeral}}}
|
||||||
|
|
||||||
|
cloned := CloneRenderedMessages(prefix)
|
||||||
|
combined := ConcatRenderedMessages(prefix, suffix)
|
||||||
|
if len(combined) != 3 || combined[0].Content != "prefix" || combined[1].Content != " suffix " || combined[2].Content != "" {
|
||||||
|
t.Fatalf("unexpected combined messages: %#v", combined)
|
||||||
|
}
|
||||||
|
if cloned[0].CacheControl == prefix[0].CacheControl || combined[0].CacheControl == prefix[0].CacheControl || combined[2].CacheControl == suffix[1].CacheControl {
|
||||||
|
t.Fatal("cloned cache controls alias their inputs")
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix[0].Content = "changed"
|
||||||
|
prefix[0].CacheControl.TTL = ""
|
||||||
|
suffix[1].CacheControl.Type = "changed"
|
||||||
|
if cloned[0].Content != "prefix" || cloned[0].CacheControl.TTL != "1h" || combined[0].Content != "prefix" || combined[0].CacheControl.TTL != "1h" || combined[2].CacheControl.Type != CacheControlEphemeral {
|
||||||
|
t.Fatalf("cloned messages changed with their inputs: cloned=%#v combined=%#v", cloned, combined)
|
||||||
|
}
|
||||||
|
}
|
||||||
90
internal/domain/observation.go
Normal file
90
internal/domain/observation.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ExecutionObserver receives one immutable metadata event synchronously.
|
||||||
|
type ExecutionObserver func(ExecutionEvent)
|
||||||
|
|
||||||
|
type ExecutionEventKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ExecutionStarted ExecutionEventKind = "started"
|
||||||
|
ExecutionAttempt ExecutionEventKind = "attempt"
|
||||||
|
ExecutionRetryDelay ExecutionEventKind = "retry_delay"
|
||||||
|
ExecutionFinished ExecutionEventKind = "finished"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExecutionOutcome string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ExecutionOutcomeSuccess ExecutionOutcome = "success"
|
||||||
|
ExecutionOutcomeInvalidOutput ExecutionOutcome = "invalid_output"
|
||||||
|
ExecutionOutcomeError ExecutionOutcome = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExecutionFailureKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ExecutionFailureNone ExecutionFailureKind = ""
|
||||||
|
ExecutionFailureCanceled ExecutionFailureKind = "canceled"
|
||||||
|
ExecutionFailureDeadline ExecutionFailureKind = "deadline"
|
||||||
|
ExecutionFailureTransport ExecutionFailureKind = "transport"
|
||||||
|
ExecutionFailureProviderStatus ExecutionFailureKind = "provider_status"
|
||||||
|
ExecutionFailureMalformedResponse ExecutionFailureKind = "malformed_response"
|
||||||
|
ExecutionFailureCapacity ExecutionFailureKind = "capacity"
|
||||||
|
ExecutionFailureInvalidConfig ExecutionFailureKind = "invalid_config"
|
||||||
|
ExecutionFailureInvalidRequest ExecutionFailureKind = "invalid_request"
|
||||||
|
ExecutionFailurePromptNotFound ExecutionFailureKind = "prompt_not_found"
|
||||||
|
ExecutionFailureProfileNotFound ExecutionFailureKind = "profile_not_found"
|
||||||
|
ExecutionFailurePromptLoad ExecutionFailureKind = "prompt_load"
|
||||||
|
ExecutionFailureProfileLoad ExecutionFailureKind = "profile_load"
|
||||||
|
ExecutionFailureArtifactLoad ExecutionFailureKind = "artifact_load"
|
||||||
|
ExecutionFailurePromptRender ExecutionFailureKind = "prompt_render"
|
||||||
|
ExecutionFailureValidation ExecutionFailureKind = "validation"
|
||||||
|
ExecutionFailureGeneration ExecutionFailureKind = "generation"
|
||||||
|
ExecutionFailureOther ExecutionFailureKind = "other"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ObservedTokenCount distinguishes omitted usage from reported zero.
|
||||||
|
type ObservedTokenCount struct {
|
||||||
|
Value int
|
||||||
|
Known bool
|
||||||
|
Complete bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObservedTokenUsage reports presence-aware token counts.
|
||||||
|
type ObservedTokenUsage struct {
|
||||||
|
PromptTokens ObservedTokenCount
|
||||||
|
CompletionTokens ObservedTokenCount
|
||||||
|
TotalTokens ObservedTokenCount
|
||||||
|
CachedTokens ObservedTokenCount
|
||||||
|
CacheWriteTokens ObservedTokenCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutionEvent contains metadata only; it carries no mutable containers or
|
||||||
|
// error values. Nonterminal events leave aggregate counters at zero.
|
||||||
|
type ExecutionEvent struct {
|
||||||
|
Kind ExecutionEventKind
|
||||||
|
RunID string
|
||||||
|
Time time.Time
|
||||||
|
PromptID string
|
||||||
|
PromptVersion string
|
||||||
|
ProfileID string
|
||||||
|
BackendID string
|
||||||
|
Model string
|
||||||
|
Generation uint64
|
||||||
|
Attempt uint64
|
||||||
|
Dispatched bool
|
||||||
|
CapacityWait time.Duration
|
||||||
|
GenerationDuration time.Duration
|
||||||
|
RetryDelay time.Duration
|
||||||
|
Duration time.Duration
|
||||||
|
Outcome ExecutionOutcome
|
||||||
|
Failure ExecutionFailureKind
|
||||||
|
StatusCode int
|
||||||
|
RetriesExhausted bool
|
||||||
|
Usage ObservedTokenUsage
|
||||||
|
Attempts uint64
|
||||||
|
TransientRetries uint64
|
||||||
|
RepairCalls uint64
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxOutputRepairAttempts = 3
|
||||||
|
|
||||||
// ValidateOutputContract validates source-neutral output-contract invariants.
|
// ValidateOutputContract validates source-neutral output-contract invariants.
|
||||||
func ValidateOutputContract(contract OutputContract) error {
|
func ValidateOutputContract(contract OutputContract) error {
|
||||||
switch contract.Format {
|
switch contract.Format {
|
||||||
@@ -26,5 +28,11 @@ func ValidateOutputContract(contract OutputContract) error {
|
|||||||
if contract.RepairAttempts < 0 {
|
if contract.RepairAttempts < 0 {
|
||||||
return errors.New("repair_attempts must be greater than or equal to 0")
|
return errors.New("repair_attempts must be greater than or equal to 0")
|
||||||
}
|
}
|
||||||
|
if contract.RepairAttempts > maxOutputRepairAttempts {
|
||||||
|
return fmt.Errorf("repair_attempts must be less than or equal to %d", maxOutputRepairAttempts)
|
||||||
|
}
|
||||||
|
if contract.ValidationMode == ValidationNone && contract.RepairAttempts > 0 {
|
||||||
|
return errors.New("repair_attempts requires basic, json, or json_schema validation")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,28 @@ func TestValidateOutputContract(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
{name: "empty validation mode", change: func(c *OutputContract) { c.ValidationMode = "" }, wantErr: "validation mode"},
|
{name: "empty validation mode", change: func(c *OutputContract) { c.ValidationMode = "" }, wantErr: "validation mode"},
|
||||||
{name: "unsupported validation mode", change: func(c *OutputContract) { c.ValidationMode = ValidationMode("unknown") }, wantErr: "validation mode"},
|
{name: "unsupported validation mode", change: func(c *OutputContract) { c.ValidationMode = ValidationMode("unknown") }, wantErr: "validation mode"},
|
||||||
{name: "negative repair attempts", change: func(c *OutputContract) { c.RepairAttempts = -1 }, wantErr: "repair_attempts"},
|
{name: "negative repair attempts", change: func(c *OutputContract) {
|
||||||
|
c.ValidationMode = ValidationBasic
|
||||||
|
c.RepairAttempts = -1
|
||||||
|
}, wantErr: "repair_attempts"},
|
||||||
{name: "zero repair attempts", change: func(c *OutputContract) { c.RepairAttempts = 0 }},
|
{name: "zero repair attempts", change: func(c *OutputContract) { c.RepairAttempts = 0 }},
|
||||||
{name: "positive repair attempts", change: func(c *OutputContract) { c.RepairAttempts = 1 }},
|
{name: "one repair attempt", change: func(c *OutputContract) {
|
||||||
|
c.ValidationMode = ValidationBasic
|
||||||
|
c.RepairAttempts = 1
|
||||||
|
}},
|
||||||
|
{name: "maximum repair attempts", change: func(c *OutputContract) {
|
||||||
|
c.ValidationMode = ValidationJSON
|
||||||
|
c.RepairAttempts = 3
|
||||||
|
}},
|
||||||
|
{name: "too many repair attempts", change: func(c *OutputContract) {
|
||||||
|
c.ValidationMode = ValidationJSONSchema
|
||||||
|
c.SchemaPath = "schema.json"
|
||||||
|
c.RepairAttempts = 4
|
||||||
|
}, wantErr: "repair_attempts"},
|
||||||
|
{name: "none validation with repair attempts", change: func(c *OutputContract) {
|
||||||
|
c.ValidationMode = ValidationNone
|
||||||
|
c.RepairAttempts = 1
|
||||||
|
}, wantErr: "repair_attempts"},
|
||||||
{name: "json schema empty path", change: func(c *OutputContract) {
|
{name: "json schema empty path", change: func(c *OutputContract) {
|
||||||
c.ValidationMode = ValidationJSONSchema
|
c.ValidationMode = ValidationJSONSchema
|
||||||
c.SchemaPath = ""
|
c.SchemaPath = ""
|
||||||
|
|||||||
15
internal/domain/retries.go
Normal file
15
internal/domain/retries.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// ResolveTransientRetries selects a nonnegative retry budget. A nil selection
|
||||||
|
// inherits the supplied default.
|
||||||
|
func ResolveTransientRetries(defaultBudget uint64, selection *int) (uint64, error) {
|
||||||
|
if selection == nil {
|
||||||
|
return defaultBudget, nil
|
||||||
|
}
|
||||||
|
if *selection < 0 {
|
||||||
|
return 0, fmt.Errorf("transient retries must not be negative")
|
||||||
|
}
|
||||||
|
return uint64(*selection), nil
|
||||||
|
}
|
||||||
285
internal/engine/engine.go
Normal file
285
internal/engine/engine.go
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
// Package engine assembles Promptkit's internal execution components.
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
openrouter "gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2"
|
||||||
|
rakestrawhome "gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/artifact"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/catalog"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/defaults"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/generation"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config carries construction settings selected by the public facade.
|
||||||
|
type Config struct {
|
||||||
|
PromptDir string
|
||||||
|
ProfileDir string
|
||||||
|
SchemaDir string
|
||||||
|
Timeout time.Duration
|
||||||
|
HTTPClient *http.Client
|
||||||
|
TransientRetries *int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options collects construction choices from the public facade.
|
||||||
|
type Options struct {
|
||||||
|
llmClient llm.Client
|
||||||
|
artifactReader artifact.Reader
|
||||||
|
promptDefinitions promptdef.Repository
|
||||||
|
profiles profile.Repository
|
||||||
|
fallbackProfiles profile.Repository
|
||||||
|
memoryProfiles profile.Repository
|
||||||
|
backends []domain.Backend
|
||||||
|
validator validate.Validator
|
||||||
|
promptSource bool
|
||||||
|
profileSource bool
|
||||||
|
fallbackProfileSource bool
|
||||||
|
memorySource bool
|
||||||
|
validatorSource bool
|
||||||
|
artifactSource bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLLMClient selects the client collaborator for one engine.
|
||||||
|
func (o *Options) SetLLMClient(client llm.Client) { o.llmClient = client }
|
||||||
|
|
||||||
|
// SetArtifactReader selects the artifact collaborator for one engine.
|
||||||
|
func (o *Options) SetArtifactReader(reader artifact.Reader) {
|
||||||
|
o.artifactReader = reader
|
||||||
|
o.artifactSource = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPromptFS selects a prompt source rooted in fsys.
|
||||||
|
func (o *Options) SetPromptFS(fsys fs.FS, root string) error {
|
||||||
|
if err := validateSourceFS(fsys, root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
o.promptDefinitions = promptdef.NewFSRepository(fsys, root)
|
||||||
|
o.promptSource = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPromptFile selects a single prompt file.
|
||||||
|
func (o *Options) SetPromptFile(path string) error {
|
||||||
|
fsys, root, directory, err := fileSource(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
o.promptDefinitions = promptdef.NewFileRepository(fsys, root, directory)
|
||||||
|
o.promptSource = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetProfileFS selects an ordinary profile source rooted in fsys.
|
||||||
|
func (o *Options) SetProfileFS(fsys fs.FS, root string) error {
|
||||||
|
if err := validateSourceFS(fsys, root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
o.profiles = profile.NewFSRepository(fsys, root)
|
||||||
|
o.profileSource = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetProfileFile selects a single ordinary profile file.
|
||||||
|
func (o *Options) SetProfileFile(path string) error {
|
||||||
|
fsys, root, _, err := fileSource(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
o.profiles = profile.NewFSRepository(fsys, root)
|
||||||
|
o.profileSource = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFallbackProfileFS selects a fallback profile source rooted in fsys.
|
||||||
|
func (o *Options) SetFallbackProfileFS(fsys fs.FS, root string) error {
|
||||||
|
if err := validateSourceFS(fsys, root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
o.fallbackProfiles = profile.NewFSRepository(fsys, root)
|
||||||
|
o.fallbackProfileSource = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMemoryProfiles validates and stores application-owned profile definitions.
|
||||||
|
func (o *Options) SetMemoryProfiles(definitions []domain.ExecutionProfile) error {
|
||||||
|
repository, err := profile.NewMemoryRepository(definitions)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
o.memoryProfiles = repository
|
||||||
|
o.memorySource = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddBackend appends one backend registration.
|
||||||
|
func (o *Options) AddBackend(backend domain.Backend) { o.backends = append(o.backends, backend) }
|
||||||
|
|
||||||
|
// SetSchemaFS selects a schema source rooted in fsys.
|
||||||
|
func (o *Options) SetSchemaFS(fsys fs.FS, root string) error {
|
||||||
|
if err := validateSourceFS(fsys, root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
o.validator = validate.NewFSValidator(fsys, root)
|
||||||
|
o.validatorSource = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSchemaFile selects a single schema file.
|
||||||
|
func (o *Options) SetSchemaFile(path string) error {
|
||||||
|
fsys, root, _, err := fileSource(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
o.validator = validate.NewFSValidator(fsys, root)
|
||||||
|
o.validatorSource = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Engine forwards domain operations to the single assembled use-case runner.
|
||||||
|
type Engine struct {
|
||||||
|
runner *usecase.Runner
|
||||||
|
}
|
||||||
|
|
||||||
|
// New assembles an engine from facade-selected configuration and collaborators.
|
||||||
|
func New(cfg Config, options Options) (*Engine, error) {
|
||||||
|
defaultRetries, err := domain.ResolveTransientRetries(2, cfg.TransientRetries)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
promptDefinitions := options.promptDefinitions
|
||||||
|
if !options.promptSource {
|
||||||
|
if strings.TrimSpace(cfg.PromptDir) == "" {
|
||||||
|
return nil, fmt.Errorf("prompt directory is required")
|
||||||
|
}
|
||||||
|
promptDefinitions = promptdef.NewFilesystemRepository(cfg.PromptDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
maintained, err := catalog.Load(
|
||||||
|
catalog.Source{Name: "OpenRouter", ExpectedBackendID: backend.OpenRouterID, FS: openrouter.FS(), Root: openrouter.Root},
|
||||||
|
catalog.Source{Name: "Rakestrawhome", ExpectedBackendID: backend.RakestrawHomeID, FS: rakestrawhome.FS(), Root: rakestrawhome.Root},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load maintained catalogs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
maintainedProfiles, err := newMaintainedProfileRepository(maintained.Profiles, maintained.Backends)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to construct Promptkit-maintained profiles: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
profiles := newProfileRepository(cfg.ProfileDir, options, maintainedProfiles)
|
||||||
|
registry, err := backend.NewRegistry(maintained.Backends, options.backends)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to construct backend registry: %w", err)
|
||||||
|
}
|
||||||
|
manager, err := capacity.NewManager(registry.CapacityPolicies())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to construct backend capacity manager: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
validator := options.validator
|
||||||
|
if !options.validatorSource {
|
||||||
|
schemaDir := cfg.SchemaDir
|
||||||
|
if strings.TrimSpace(schemaDir) == "" {
|
||||||
|
schemaDir = defaults.SchemaDirDefault
|
||||||
|
}
|
||||||
|
validator = validate.NewStandardValidator(schemaDir)
|
||||||
|
}
|
||||||
|
var client llm.Client
|
||||||
|
if options.llmClient == nil {
|
||||||
|
builtIn, newErr := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{Timeout: cfg.Timeout, HTTPClient: cfg.HTTPClient})
|
||||||
|
if newErr != nil {
|
||||||
|
return nil, newErr
|
||||||
|
}
|
||||||
|
client = generation.NewBuiltIn(manager, builtIn)
|
||||||
|
} else {
|
||||||
|
client = generation.NewInjected(manager, options.llmClient)
|
||||||
|
}
|
||||||
|
reader := options.artifactReader
|
||||||
|
if !options.artifactSource {
|
||||||
|
reader = artifact.NewCompositeReader()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Engine{runner: usecase.NewRunnerWithRepairer(promptDefinitions, profiles, registry, reader, prompt.NewGoRenderer(), client, validator, usecase.NewDefaultOutputRepairer(client), manager, defaultRetries)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProfileRepository(profileDir string, options Options, maintained profile.Repository) profile.Repository {
|
||||||
|
repository := maintained
|
||||||
|
if options.fallbackProfileSource {
|
||||||
|
repository = profile.NewOverlayRepository(options.fallbackProfiles, repository)
|
||||||
|
}
|
||||||
|
if options.profileSource {
|
||||||
|
repository = profile.NewOverlayRepository(options.profiles, repository)
|
||||||
|
} else if strings.TrimSpace(profileDir) != "" {
|
||||||
|
repository = profile.NewOverlayRepository(profile.NewFilesystemRepository(profileDir), repository)
|
||||||
|
}
|
||||||
|
if options.memorySource {
|
||||||
|
repository = profile.NewOverlayRepository(options.memoryProfiles, repository)
|
||||||
|
}
|
||||||
|
return profile.NewResolvingRepository(repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSourceFS(fsys fs.FS, root string) error {
|
||||||
|
if fsys == nil {
|
||||||
|
return fmt.Errorf("source filesystem is nil")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(root) == "" {
|
||||||
|
return fmt.Errorf("source root is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileSource(name string) (fs.FS, string, string, error) {
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return nil, "", "", fmt.Errorf("source path is required")
|
||||||
|
}
|
||||||
|
directory := filepath.Dir(name)
|
||||||
|
base := filepath.Base(name)
|
||||||
|
if base == "." || base == string(filepath.Separator) {
|
||||||
|
return nil, "", "", fmt.Errorf("source path must name a file")
|
||||||
|
}
|
||||||
|
info, err := os.Stat(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", fmt.Errorf("failed to access source file %q: %w", name, err)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil, "", "", fmt.Errorf("source path %q must be a file", name)
|
||||||
|
}
|
||||||
|
return os.DirFS(directory), filepath.ToSlash(base), directory, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) InspectPrompt(ctx context.Context, id, version string) (*domain.PromptInspection, error) {
|
||||||
|
return e.runner.InspectPrompt(ctx, id, version)
|
||||||
|
}
|
||||||
|
func (e *Engine) InspectProfile(ctx context.Context, id string) (*domain.ProfileInspection, error) {
|
||||||
|
return e.runner.InspectProfile(ctx, id)
|
||||||
|
}
|
||||||
|
func (e *Engine) Prepare(ctx context.Context, request domain.RunRequest) (*domain.PreparedRun, error) {
|
||||||
|
return e.runner.Prepare(ctx, request)
|
||||||
|
}
|
||||||
|
func (e *Engine) PrepareExecution(ctx context.Context, request domain.RunRequest) (*usecase.PreparedExecution, error) {
|
||||||
|
return e.runner.PrepareExecution(ctx, request)
|
||||||
|
}
|
||||||
|
func (e *Engine) Run(ctx context.Context, request domain.RunRequest) (*domain.RunResult, error) {
|
||||||
|
return e.runner.Run(ctx, request)
|
||||||
|
}
|
||||||
|
func (e *Engine) RunPrepared(ctx context.Context, prepared *usecase.PreparedExecution) (*domain.RunResult, error) {
|
||||||
|
return e.runner.RunPrepared(ctx, prepared)
|
||||||
|
}
|
||||||
50
internal/engine/maintained_profiles.go
Normal file
50
internal/engine/maintained_profiles.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
func maintainedProfileDefinitions() []domain.ExecutionProfile {
|
||||||
|
return []domain.ExecutionProfile{
|
||||||
|
{
|
||||||
|
ID: "promptkit-openrouter-standard",
|
||||||
|
BaseProfileID: "gemini-flash-latest",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "promptkit-openrouter-auto",
|
||||||
|
BackendID: backend.OpenRouterID,
|
||||||
|
Model: "openrouter/auto",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMaintainedProfileRepository(catalogProfiles profile.Repository, maintainedBackends []domain.Backend) (profile.Repository, error) {
|
||||||
|
definitions := maintainedProfileDefinitions()
|
||||||
|
owned, err := profile.NewMemoryRepository(definitions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create Promptkit-maintained profiles: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
combined := profile.NewOverlayRepository(owned, catalogProfiles)
|
||||||
|
resolved := profile.NewResolvingRepository(combined)
|
||||||
|
registry, err := backend.NewRegistry(maintainedBackends, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("construct maintained backend registry: %w", err)
|
||||||
|
}
|
||||||
|
for _, definition := range definitions {
|
||||||
|
effective, err := resolved.GetProfile(context.Background(), definition.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve Promptkit-maintained profile %q: %w", definition.ID, err)
|
||||||
|
}
|
||||||
|
if _, err := registry.GetBackend(effective.BackendID); err != nil {
|
||||||
|
return nil, fmt.Errorf("Promptkit-maintained profile %q selects unavailable backend %q: %w", definition.ID, effective.BackendID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return combined, nil
|
||||||
|
}
|
||||||
78
internal/engine/maintained_profiles_test.go
Normal file
78
internal/engine/maintained_profiles_test.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/backend"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewMaintainedProfileRepository(t *testing.T) {
|
||||||
|
newCatalogProfiles := func(t *testing.T, definitions []domain.ExecutionProfile) profile.Repository {
|
||||||
|
t.Helper()
|
||||||
|
repository, err := profile.NewMemoryRepository(definitions)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create catalog profiles: %v", err)
|
||||||
|
}
|
||||||
|
return repository
|
||||||
|
}
|
||||||
|
newBackend := func(id string) domain.Backend {
|
||||||
|
return domain.Backend{ID: id, Endpoint: "https://" + id + ".example/v1", APIKeyEnv: "TEST_API_KEY"}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("resolves inherited and direct definitions", func(t *testing.T) {
|
||||||
|
repository, err := newMaintainedProfileRepository(
|
||||||
|
newCatalogProfiles(t, []domain.ExecutionProfile{{
|
||||||
|
ID: "gemini-flash-latest",
|
||||||
|
BackendID: backend.OpenRouterID,
|
||||||
|
Model: "catalog-selected-model",
|
||||||
|
}}),
|
||||||
|
[]domain.Backend{newBackend(backend.OpenRouterID)},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create maintained profiles: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved := profile.NewResolvingRepository(repository)
|
||||||
|
standard, err := resolved.GetProfile(context.Background(), "promptkit-openrouter-standard")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve inherited profile: %v", err)
|
||||||
|
}
|
||||||
|
if standard.BackendID != backend.OpenRouterID || standard.Model != "catalog-selected-model" {
|
||||||
|
t.Fatalf("unexpected inherited profile: %#v", standard)
|
||||||
|
}
|
||||||
|
|
||||||
|
auto, err := resolved.GetProfile(context.Background(), "promptkit-openrouter-auto")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve direct profile: %v", err)
|
||||||
|
}
|
||||||
|
definitions := maintainedProfileDefinitions()
|
||||||
|
if auto.BackendID != definitions[1].BackendID || auto.Model != definitions[1].Model {
|
||||||
|
t.Fatalf("unexpected direct profile: %#v", auto)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects a missing inherited base", func(t *testing.T) {
|
||||||
|
if _, err := newMaintainedProfileRepository(
|
||||||
|
newCatalogProfiles(t, nil),
|
||||||
|
[]domain.Backend{newBackend(backend.OpenRouterID)},
|
||||||
|
); err == nil {
|
||||||
|
t.Fatal("expected missing base error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("rejects an unavailable effective backend", func(t *testing.T) {
|
||||||
|
if _, err := newMaintainedProfileRepository(
|
||||||
|
newCatalogProfiles(t, []domain.ExecutionProfile{{
|
||||||
|
ID: "gemini-flash-latest",
|
||||||
|
BackendID: "other",
|
||||||
|
Model: "catalog-selected-model",
|
||||||
|
}}),
|
||||||
|
[]domain.Backend{newBackend("other")},
|
||||||
|
); err == nil {
|
||||||
|
t.Fatal("expected unavailable backend error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
294
internal/generation/coordinator.go
Normal file
294
internal/generation/coordinator.go
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
// Package generation coordinates model attempts, backend permits, and bounded
|
||||||
|
// built-in HTTP retries for one generation request.
|
||||||
|
package generation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/observation"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
initialBackoff = 500 * time.Millisecond
|
||||||
|
maximumBackoff = 8 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Coordinator implements the internal model client using the existing backend
|
||||||
|
// capacity manager. Exactly one of builtIn and injected is selected.
|
||||||
|
type Coordinator struct {
|
||||||
|
manager *capacity.Manager
|
||||||
|
builtIn *llm.OpenAICompatibleClient
|
||||||
|
injected llm.Client
|
||||||
|
now func() time.Time
|
||||||
|
jitter func(time.Duration) time.Duration
|
||||||
|
wait func(context.Context, time.Duration) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBuiltIn selects the built-in HTTP retry path.
|
||||||
|
func NewBuiltIn(manager *capacity.Manager, client *llm.OpenAICompatibleClient) *Coordinator {
|
||||||
|
return &Coordinator{manager: manager, builtIn: client, now: time.Now, jitter: randomJitter, wait: waitContext}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInjected selects one opaque client dispatch with no automatic retry.
|
||||||
|
func NewInjected(manager *capacity.Manager, client llm.Client) *Coordinator {
|
||||||
|
return &Coordinator{manager: manager, injected: client, now: time.Now, jitter: randomJitter, wait: waitContext}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExhaustedError retains the final eligible status failure and the number of
|
||||||
|
// attempts actually dispatched.
|
||||||
|
type ExhaustedError struct {
|
||||||
|
attempts uint64
|
||||||
|
cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ExhaustedError) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return "transient model retries exhausted"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("transient model retries exhausted after %d attempts", e.attempts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ExhaustedError) Unwrap() error {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.cause
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ExhaustedError) Attempts() uint64 {
|
||||||
|
if e == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return e.attempts
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate executes either one opaque client call or a frozen built-in HTTP
|
||||||
|
// sequence with one permit per actual dispatch.
|
||||||
|
func (c *Coordinator) Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
|
||||||
|
if c.injected != nil {
|
||||||
|
return c.generateInjected(ctx, req)
|
||||||
|
}
|
||||||
|
prepared, err := c.builtIn.PrepareGeneration(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var attempts, retriesUsed uint64
|
||||||
|
recorder := observation.FromContext(ctx)
|
||||||
|
generation := req.Generation
|
||||||
|
if generation == 0 {
|
||||||
|
generation = 1
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ordinal := attempts + 1
|
||||||
|
outcome, dispatched, capacityWait, generationDuration := c.execute(ctx, req.Target.BackendID, prepared)
|
||||||
|
if dispatched {
|
||||||
|
attempts++
|
||||||
|
}
|
||||||
|
if recorder != nil {
|
||||||
|
recorder.Attempt(observation.AttemptData{Generation: generation, Attempt: ordinal,
|
||||||
|
Dispatched: dispatched, CapacityWait: capacityWait, GenerationDuration: generationDuration,
|
||||||
|
Outcome: attemptOutcome(outcome.Err), Failure: attemptFailure(outcome.Err),
|
||||||
|
StatusCode: outcome.StatusCode, Usage: outcome.Usage, UsagePresence: outcome.UsagePresence})
|
||||||
|
}
|
||||||
|
if outcome.Err == nil {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return outcome.Response, nil
|
||||||
|
}
|
||||||
|
if !dispatched || !eligibleStatus(outcome) {
|
||||||
|
return nil, outcome.Err
|
||||||
|
}
|
||||||
|
if retriesUsed == req.TransientRetries {
|
||||||
|
if retriesUsed == 0 {
|
||||||
|
return nil, outcome.Err
|
||||||
|
}
|
||||||
|
return nil, &ExhaustedError{attempts: attempts, cause: outcome.Err}
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
retryOrdinal := retriesUsed + 1
|
||||||
|
delay := retryDelay(c.backoff(retryOrdinal), outcome.RetryNotBefore, c.now())
|
||||||
|
waitStart := c.now()
|
||||||
|
waitErr := c.wait(ctx, delay)
|
||||||
|
if recorder != nil {
|
||||||
|
recorder.Delay(observation.DelayData{Generation: generation, Attempt: attempts,
|
||||||
|
Elapsed: elapsed(waitStart, c.now()), Failure: attemptFailure(waitErr)})
|
||||||
|
}
|
||||||
|
if waitErr != nil {
|
||||||
|
return nil, waitErr
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
retriesUsed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Coordinator) generateInjected(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
|
||||||
|
recorder := observation.FromContext(ctx)
|
||||||
|
generation := req.Generation
|
||||||
|
if generation == 0 {
|
||||||
|
generation = 1
|
||||||
|
}
|
||||||
|
start := c.now()
|
||||||
|
release, err := c.manager.Acquire(ctx, req.Target.BackendID)
|
||||||
|
capacityWait := c.capacityWait(req.Target.BackendID, start)
|
||||||
|
if err != nil {
|
||||||
|
if recorder != nil {
|
||||||
|
recorder.Attempt(observation.AttemptData{Generation: generation, Attempt: 1, CapacityWait: capacityWait, Outcome: domain.ExecutionOutcomeError, Failure: attemptFailure(err)})
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
release()
|
||||||
|
if recorder != nil {
|
||||||
|
recorder.Attempt(observation.AttemptData{Generation: generation, Attempt: 1, CapacityWait: capacityWait, Outcome: domain.ExecutionOutcomeError, Failure: attemptFailure(err)})
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dispatchStart := c.now()
|
||||||
|
response, err := c.injected.Generate(ctx, req)
|
||||||
|
duration := elapsed(dispatchStart, c.now())
|
||||||
|
if err == nil && response == nil {
|
||||||
|
err = llm.ErrMalformedResponse
|
||||||
|
}
|
||||||
|
release()
|
||||||
|
if recorder != nil {
|
||||||
|
data := observation.AttemptData{Generation: generation, Attempt: 1, Dispatched: true, CapacityWait: capacityWait, GenerationDuration: duration,
|
||||||
|
Outcome: attemptOutcome(err), Failure: attemptFailure(err)}
|
||||||
|
if err == nil {
|
||||||
|
data.Usage = response.Usage
|
||||||
|
data.UsagePresence = response.UsagePresence
|
||||||
|
}
|
||||||
|
recorder.Attempt(data)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Coordinator) execute(ctx context.Context, backendID string, prepared *llm.PreparedGeneration) (outcome llm.AttemptOutcome, dispatched bool, capacityWait, generationDuration time.Duration) {
|
||||||
|
start := c.now()
|
||||||
|
release, err := c.manager.Acquire(ctx, backendID)
|
||||||
|
capacityWait = c.capacityWait(backendID, start)
|
||||||
|
if err != nil {
|
||||||
|
return llm.AttemptOutcome{Err: err}, false, capacityWait, 0
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return llm.AttemptOutcome{Err: err}, false, capacityWait, 0
|
||||||
|
}
|
||||||
|
dispatchStart := c.now()
|
||||||
|
outcome = prepared.Execute(ctx)
|
||||||
|
return outcome, true, capacityWait, elapsed(dispatchStart, c.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Coordinator) capacityWait(backendID string, start time.Time) time.Duration {
|
||||||
|
if !c.manager.Limited(backendID) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return elapsed(start, c.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func elapsed(start, end time.Time) time.Duration {
|
||||||
|
if d := end.Sub(start); d > 0 {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func attemptOutcome(err error) domain.ExecutionOutcome {
|
||||||
|
if err == nil {
|
||||||
|
return domain.ExecutionOutcomeSuccess
|
||||||
|
}
|
||||||
|
return domain.ExecutionOutcomeError
|
||||||
|
}
|
||||||
|
|
||||||
|
func attemptFailure(err error) domain.ExecutionFailureKind {
|
||||||
|
if err == nil {
|
||||||
|
return domain.ExecutionFailureNone
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return domain.ExecutionFailureCanceled
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return domain.ExecutionFailureDeadline
|
||||||
|
}
|
||||||
|
if errors.Is(err, llm.ErrUnexpectedStatus) {
|
||||||
|
return domain.ExecutionFailureProviderStatus
|
||||||
|
}
|
||||||
|
if errors.Is(err, llm.ErrTransport) {
|
||||||
|
return domain.ExecutionFailureTransport
|
||||||
|
}
|
||||||
|
if errors.Is(err, llm.ErrMalformedResponse) {
|
||||||
|
return domain.ExecutionFailureMalformedResponse
|
||||||
|
}
|
||||||
|
if errors.Is(err, llm.ErrInvalidRequest) {
|
||||||
|
return domain.ExecutionFailureInvalidRequest
|
||||||
|
}
|
||||||
|
return domain.ExecutionFailureGeneration
|
||||||
|
}
|
||||||
|
|
||||||
|
func eligibleStatus(outcome llm.AttemptOutcome) bool {
|
||||||
|
if !errors.Is(outcome.Err, llm.ErrUnexpectedStatus) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch outcome.StatusCode {
|
||||||
|
case 429, 500, 502, 503, 504:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Coordinator) backoff(ordinal uint64) time.Duration {
|
||||||
|
base := initialBackoff
|
||||||
|
for step := uint64(1); step < ordinal && base < maximumBackoff; step++ {
|
||||||
|
base *= 2
|
||||||
|
}
|
||||||
|
half := base / 2
|
||||||
|
return half + c.jitter(half)
|
||||||
|
}
|
||||||
|
|
||||||
|
func retryDelay(local time.Duration, notBefore, now time.Time) time.Duration {
|
||||||
|
if providerDelay := notBefore.Sub(now); providerDelay > local {
|
||||||
|
return providerDelay
|
||||||
|
}
|
||||||
|
return local
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomJitter(max time.Duration) time.Duration {
|
||||||
|
return time.Duration(rand.Int64N(int64(max) + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitContext(ctx context.Context, delay time.Duration) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
278
internal/generation/coordinator_test.go
Normal file
278
internal/generation/coordinator_test.go
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
package generation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type transportFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f transportFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
||||||
|
|
||||||
|
func response(status int, body string) *http.Response {
|
||||||
|
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRequest(retries uint64) domain.GenerateRequest {
|
||||||
|
return domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hello"}}},
|
||||||
|
Target: domain.ExecutionTarget{BackendID: "limited", Endpoint: "https://example.invalid/v1", Model: "model"},
|
||||||
|
TransientRetries: retries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCoordinator(t *testing.T, transport transportFunc) *Coordinator {
|
||||||
|
t.Helper()
|
||||||
|
manager, err := capacity.NewManager(map[string]domain.BackendCapacityPolicy{
|
||||||
|
"limited": {ConcurrencyLimit: 1, QueueCapacity: 1},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client, err := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{HTTPClient: &http.Client{Transport: transport}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
coordinator := NewBuiltIn(manager, client)
|
||||||
|
coordinator.jitter = func(time.Duration) time.Duration { return 0 }
|
||||||
|
coordinator.wait = func(ctx context.Context, _ time.Duration) error { return ctx.Err() }
|
||||||
|
return coordinator
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltInRetryEligibilityAndExhaustion(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
status int
|
||||||
|
eligible bool
|
||||||
|
}{
|
||||||
|
{status: 429, eligible: true}, {status: 500, eligible: true},
|
||||||
|
{status: 502, eligible: true}, {status: 503, eligible: true},
|
||||||
|
{status: 504, eligible: true}, {status: 400}, {status: 501},
|
||||||
|
} {
|
||||||
|
t.Run(http.StatusText(tc.status), func(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
coordinator := testCoordinator(t, func(*http.Request) (*http.Response, error) {
|
||||||
|
calls++
|
||||||
|
if calls == 1 {
|
||||||
|
return response(tc.status, `{"error":{}}`), nil
|
||||||
|
}
|
||||||
|
return response(200, `{"choices":[{"message":{"content":"ok"}}]}`), nil
|
||||||
|
})
|
||||||
|
result, err := coordinator.Generate(context.Background(), testRequest(1))
|
||||||
|
if tc.eligible {
|
||||||
|
if err != nil || result == nil || result.Content != "ok" || calls != 2 {
|
||||||
|
t.Fatalf("result=%+v, error=%v, calls=%d", result, err, calls)
|
||||||
|
}
|
||||||
|
} else if result != nil || err == nil || calls != 1 {
|
||||||
|
t.Fatalf("result=%+v, error=%v, calls=%d", result, err, calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, budget := range []uint64{0, 2} {
|
||||||
|
calls := 0
|
||||||
|
coordinator := testCoordinator(t, func(*http.Request) (*http.Response, error) {
|
||||||
|
calls++
|
||||||
|
return response(503, `{"error":{}}`), nil
|
||||||
|
})
|
||||||
|
_, err := coordinator.Generate(context.Background(), testRequest(budget))
|
||||||
|
var exhausted *ExhaustedError
|
||||||
|
if !errors.Is(err, llm.ErrUnexpectedStatus) || calls != int(budget+1) || errors.As(err, &exhausted) != (budget > 0) {
|
||||||
|
t.Fatalf("budget=%d calls=%d error=%v", budget, calls, err)
|
||||||
|
}
|
||||||
|
if budget > 0 && exhausted.Attempts() != budget+1 {
|
||||||
|
t.Fatalf("attempts=%d", exhausted.Attempts())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
calls := 0
|
||||||
|
coordinator := testCoordinator(t, func(*http.Request) (*http.Response, error) {
|
||||||
|
calls++
|
||||||
|
if calls == 1 {
|
||||||
|
return response(503, `{"error":{}}`), nil
|
||||||
|
}
|
||||||
|
return response(400, `{"error":{}}`), nil
|
||||||
|
})
|
||||||
|
_, err := coordinator.Generate(context.Background(), testRequest(3))
|
||||||
|
var exhausted *ExhaustedError
|
||||||
|
if calls != 2 || !errors.Is(err, llm.ErrUnexpectedStatus) || errors.As(err, &exhausted) {
|
||||||
|
t.Fatalf("later permanent failure: calls=%d, error=%v", calls, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltInRetryWaitHonorsContextAndProviderMinimum(t *testing.T) {
|
||||||
|
when := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)
|
||||||
|
coordinator := testCoordinator(t, func(*http.Request) (*http.Response, error) { return response(503, `{"error":{}}`), nil })
|
||||||
|
if got := coordinator.backoff(1); got != 250*time.Millisecond {
|
||||||
|
t.Fatalf("first backoff=%v", got)
|
||||||
|
}
|
||||||
|
if got := coordinator.backoff(math.MaxUint64); got != 4*time.Second {
|
||||||
|
t.Fatalf("capped backoff=%v", got)
|
||||||
|
}
|
||||||
|
coordinator.jitter = func(max time.Duration) time.Duration { return max }
|
||||||
|
if got := coordinator.backoff(math.MaxUint64); got != 8*time.Second {
|
||||||
|
t.Fatalf("maximum equal jitter=%v", got)
|
||||||
|
}
|
||||||
|
if got := retryDelay(500*time.Millisecond, when.Add(3*time.Second), when); got != 3*time.Second {
|
||||||
|
t.Fatalf("provider minimum=%v", got)
|
||||||
|
}
|
||||||
|
if got := retryDelay(500*time.Millisecond, when.Add(-time.Second), when); got != 500*time.Millisecond {
|
||||||
|
t.Fatalf("past provider minimum=%v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
calls := 0
|
||||||
|
coordinator = testCoordinator(t, func(*http.Request) (*http.Response, error) {
|
||||||
|
calls++
|
||||||
|
return response(503, `{"error":{}}`), nil
|
||||||
|
})
|
||||||
|
coordinator.wait = func(ctx context.Context, delay time.Duration) error {
|
||||||
|
if delay < 250*time.Millisecond {
|
||||||
|
t.Fatalf("delay=%v", delay)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
_, err := coordinator.Generate(ctx, testRequest(2))
|
||||||
|
if !errors.Is(err, context.Canceled) || calls != 1 {
|
||||||
|
t.Fatalf("canceled wait: error=%v calls=%d", err, calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltInRetryUsesSeparateAttemptDeadlines(t *testing.T) {
|
||||||
|
var contexts []context.Context
|
||||||
|
coordinator := testCoordinator(t, func(req *http.Request) (*http.Response, error) {
|
||||||
|
contexts = append(contexts, req.Context())
|
||||||
|
if len(contexts) == 1 {
|
||||||
|
return response(503, `{"error":{}}`), nil
|
||||||
|
}
|
||||||
|
return response(200, `{"choices":[{"message":{"content":"ok"}}]}`), nil
|
||||||
|
})
|
||||||
|
req := testRequest(1)
|
||||||
|
req.Target.TimeoutSeconds = 1
|
||||||
|
if _, err := coordinator.Generate(context.Background(), req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(contexts) != 2 || contexts[0] == contexts[1] {
|
||||||
|
t.Fatalf("attempt contexts=%d", len(contexts))
|
||||||
|
}
|
||||||
|
for _, ctx := range contexts {
|
||||||
|
if _, ok := ctx.Deadline(); !ok || !errors.Is(ctx.Err(), context.Canceled) {
|
||||||
|
t.Fatalf("attempt deadline/error=%t/%v", ok, ctx.Err())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanceledCapacityWaitDispatchesNothing(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
coordinator := testCoordinator(t, func(*http.Request) (*http.Response, error) {
|
||||||
|
calls++
|
||||||
|
return response(200, `{"choices":[{"message":{"content":"ok"}}]}`), nil
|
||||||
|
})
|
||||||
|
release, err := coordinator.manager.Acquire(context.Background(), "limited")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
result := make(chan error, 1)
|
||||||
|
go func() { _, err := coordinator.Generate(ctx, testRequest(2)); result <- err }()
|
||||||
|
cancel()
|
||||||
|
if err := <-result; !errors.Is(err, context.Canceled) || calls != 0 {
|
||||||
|
t.Fatalf("capacity cancellation: error=%v calls=%d", err, calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltInRetryFreesPermitAndFreezesCredential(t *testing.T) {
|
||||||
|
const envName = "PROMPTKIT_GENERATION_RETRY_TEST_KEY"
|
||||||
|
t.Setenv(envName, "first-key")
|
||||||
|
var calls atomic.Int64
|
||||||
|
var firstAuth, retryAuth string
|
||||||
|
coordinator := testCoordinator(t, func(req *http.Request) (*http.Response, error) {
|
||||||
|
switch calls.Add(1) {
|
||||||
|
case 1:
|
||||||
|
firstAuth = req.Header.Get("Authorization")
|
||||||
|
return response(503, `{"error":{}}`), nil
|
||||||
|
case 2:
|
||||||
|
return response(200, `{"choices":[{"message":{"content":"other"}}]}`), nil
|
||||||
|
default:
|
||||||
|
retryAuth = req.Header.Get("Authorization")
|
||||||
|
return response(200, `{"choices":[{"message":{"content":"retry"}}]}`), nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
entered := make(chan struct{})
|
||||||
|
resume := make(chan struct{})
|
||||||
|
coordinator.wait = func(ctx context.Context, _ time.Duration) error {
|
||||||
|
close(entered)
|
||||||
|
select {
|
||||||
|
case <-resume:
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req := testRequest(1)
|
||||||
|
req.Target.APIKeyEnv = envName
|
||||||
|
first := make(chan error, 1)
|
||||||
|
go func() { _, err := coordinator.Generate(context.Background(), req); first <- err }()
|
||||||
|
<-entered
|
||||||
|
t.Setenv(envName, "later-key")
|
||||||
|
other, err := coordinator.Generate(context.Background(), testRequest(0))
|
||||||
|
if err != nil || other == nil || other.Content != "other" {
|
||||||
|
t.Fatalf("other call while backoff blocked = %+v, %v", other, err)
|
||||||
|
}
|
||||||
|
close(resume)
|
||||||
|
if err := <-first; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if calls.Load() != 3 || firstAuth != "Bearer first-key" || retryAuth != firstAuth {
|
||||||
|
t.Fatalf("calls=%d, first auth=%q, retry auth=%q", calls.Load(), firstAuth, retryAuth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInjectedClientDispatchesOnceAndReleasesAfterPanic(t *testing.T) {
|
||||||
|
manager, err := capacity.NewManager(map[string]domain.BackendCapacityPolicy{"limited": {ConcurrencyLimit: 1}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client := &injectedClient{panicFirst: true}
|
||||||
|
coordinator := NewInjected(manager, client)
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Fatal("expected injected panic")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_, _ = coordinator.Generate(context.Background(), testRequest(5))
|
||||||
|
}()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
result, err := coordinator.Generate(ctx, testRequest(5))
|
||||||
|
if err != nil || result == nil || client.calls != 2 {
|
||||||
|
t.Fatalf("after panic result=%+v error=%v calls=%d", result, err, client.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type injectedClient struct {
|
||||||
|
calls int
|
||||||
|
panicFirst bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *injectedClient) Generate(context.Context, domain.GenerateRequest) (*domain.GenerateResponse, error) {
|
||||||
|
c.calls++
|
||||||
|
if c.panicFirst && c.calls == 1 {
|
||||||
|
panic("injected panic")
|
||||||
|
}
|
||||||
|
return &domain.GenerateResponse{Content: "ok"}, nil
|
||||||
|
}
|
||||||
201
internal/generation/observation_test.go
Normal file
201
internal/generation/observation_test.go
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
package generation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/capacity"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/observation"
|
||||||
|
)
|
||||||
|
|
||||||
|
func recordEvents(t *testing.T, ctx context.Context, events *[]domain.ExecutionEvent) context.Context {
|
||||||
|
t.Helper()
|
||||||
|
recorder, err := observation.New(func(event domain.ExecutionEvent) { *events = append(*events, event) })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return observation.WithRecorder(ctx, recorder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObservedRetryAndExhaustion(t *testing.T) {
|
||||||
|
for _, status := range []int{200, 503} {
|
||||||
|
calls := 0
|
||||||
|
coordinator := testCoordinator(t, func(*http.Request) (*http.Response, error) {
|
||||||
|
calls++
|
||||||
|
if calls == 1 || status == 503 {
|
||||||
|
return response(503, `{"error":{}}`), nil
|
||||||
|
}
|
||||||
|
return response(200, `{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":0,"completion_tokens":2}}`), nil
|
||||||
|
})
|
||||||
|
var events []domain.ExecutionEvent
|
||||||
|
ctx := recordEvents(t, context.Background(), &events)
|
||||||
|
_, err := coordinator.Generate(ctx, testRequest(1))
|
||||||
|
if status == 200 && err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status == 503 {
|
||||||
|
var exhausted *ExhaustedError
|
||||||
|
if !errors.As(err, &exhausted) {
|
||||||
|
t.Fatalf("error=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(events) != 3 || events[0].Kind != domain.ExecutionAttempt || events[1].Kind != domain.ExecutionRetryDelay || events[2].Kind != domain.ExecutionAttempt {
|
||||||
|
t.Fatalf("events=%+v", events)
|
||||||
|
}
|
||||||
|
if events[0].Attempt != 1 || events[1].Attempt != 1 || events[2].Attempt != 2 || !events[0].Dispatched || !events[2].Dispatched {
|
||||||
|
t.Fatalf("indexes=%+v", events)
|
||||||
|
}
|
||||||
|
if events[0].Failure != domain.ExecutionFailureProviderStatus || events[1].RetryDelay < 0 {
|
||||||
|
t.Fatalf("first=%+v delay=%+v", events[0], events[1])
|
||||||
|
}
|
||||||
|
if status == 200 && (!events[2].Usage.PromptTokens.Known || events[2].Usage.PromptTokens.Value != 0 || events[2].Usage.TotalTokens.Known) {
|
||||||
|
t.Fatalf("usage=%+v", events[2].Usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObservedInterruptedWaitAndCanceledCapacity(t *testing.T) {
|
||||||
|
coordinator := testCoordinator(t, func(*http.Request) (*http.Response, error) { return response(503, `{"error":{}}`), nil })
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
var events []domain.ExecutionEvent
|
||||||
|
ctx = recordEvents(t, ctx, &events)
|
||||||
|
coordinator.wait = func(ctx context.Context, _ time.Duration) error { cancel(); return ctx.Err() }
|
||||||
|
_, err := coordinator.Generate(ctx, testRequest(1))
|
||||||
|
if !errors.Is(err, context.Canceled) || len(events) != 2 || events[1].Kind != domain.ExecutionRetryDelay || events[1].Failure != domain.ExecutionFailureCanceled {
|
||||||
|
t.Fatalf("error=%v events=%+v", err, events)
|
||||||
|
}
|
||||||
|
|
||||||
|
coordinator = testCoordinator(t, func(*http.Request) (*http.Response, error) { t.Fatal("unexpected dispatch"); return nil, nil })
|
||||||
|
release, err := coordinator.manager.Acquire(context.Background(), "limited")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
ctx, cancel = context.WithCancel(context.Background())
|
||||||
|
events = nil
|
||||||
|
ctx = recordEvents(t, ctx, &events)
|
||||||
|
entered := make(chan struct{})
|
||||||
|
ctx = ¬ifyingContext{Context: ctx, entered: entered}
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { _, err := coordinator.Generate(ctx, testRequest(0)); done <- err }()
|
||||||
|
<-entered
|
||||||
|
cancel()
|
||||||
|
if err := <-done; !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("error=%v", err)
|
||||||
|
}
|
||||||
|
if len(events) != 1 || events[0].Dispatched || events[0].Failure != domain.ExecutionFailureCanceled {
|
||||||
|
t.Fatalf("events=%+v", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type notifyingContext struct {
|
||||||
|
context.Context
|
||||||
|
entered chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *notifyingContext) Done() <-chan struct{} {
|
||||||
|
select {
|
||||||
|
case <-c.entered:
|
||||||
|
default:
|
||||||
|
close(c.entered)
|
||||||
|
}
|
||||||
|
return c.Context.Done()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObservedCallbackReleasesPermitAndCanCancelRetry(t *testing.T) {
|
||||||
|
coordinator := testCoordinator(t, func(*http.Request) (*http.Response, error) { return response(503, `{"error":{}}`), nil })
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
var events []domain.ExecutionEvent
|
||||||
|
recorder, err := observation.New(func(event domain.ExecutionEvent) {
|
||||||
|
events = append(events, event)
|
||||||
|
if event.Kind != domain.ExecutionAttempt {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
permitCtx, stop := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer stop()
|
||||||
|
release, err := coordinator.manager.Acquire(permitCtx, "limited")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("callback could not acquire permit: %v", err)
|
||||||
|
} else {
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = coordinator.Generate(observation.WithRecorder(ctx, recorder), testRequest(2))
|
||||||
|
if !errors.Is(err, context.Canceled) || len(events) != 1 {
|
||||||
|
t.Fatalf("error=%v events=%+v", err, events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fixedClient struct {
|
||||||
|
response *domain.GenerateResponse
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c fixedClient) Generate(context.Context, domain.GenerateRequest) (*domain.GenerateResponse, error) {
|
||||||
|
return c.response, c.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObservedInjectedUsageAndRepairGeneration(t *testing.T) {
|
||||||
|
manager, err := capacity.NewManager(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var events []domain.ExecutionEvent
|
||||||
|
ctx := recordEvents(t, context.Background(), &events)
|
||||||
|
legacy := NewInjected(manager, fixedClient{response: &domain.GenerateResponse{Usage: domain.TokenUsage{PromptTokens: 3}}})
|
||||||
|
if _, err := legacy.Generate(ctx, testRequest(4)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
presence := &domain.TokenUsagePresence{CompletionTokens: true}
|
||||||
|
repair := NewInjected(manager, fixedClient{response: &domain.GenerateResponse{UsagePresence: presence}})
|
||||||
|
req := testRequest(4)
|
||||||
|
req.Generation = 2
|
||||||
|
if _, err := repair.Generate(ctx, req); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(events) != 2 || !events[0].Usage.PromptTokens.Known || events[0].Usage.CompletionTokens.Known || !events[1].Usage.CompletionTokens.Known || events[1].Usage.CompletionTokens.Value != 0 || events[1].Usage.PromptTokens.Known {
|
||||||
|
t.Fatalf("events=%+v", events)
|
||||||
|
}
|
||||||
|
if events[1].Generation != 2 || events[1].Attempt != 1 {
|
||||||
|
t.Fatalf("repair indexes=%+v", events[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestObservedSuccessfulGenerationBeforeRepairFailure(t *testing.T) {
|
||||||
|
manager, err := capacity.NewManager(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var events []domain.ExecutionEvent
|
||||||
|
recorder, err := observation.New(func(event domain.ExecutionEvent) { events = append(events, event) })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := observation.WithRecorder(context.Background(), recorder)
|
||||||
|
initial := NewInjected(manager, fixedClient{response: &domain.GenerateResponse{Usage: domain.TokenUsage{PromptTokens: 5}}})
|
||||||
|
if _, err := initial.Generate(ctx, testRequest(0)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repair := NewInjected(manager, fixedClient{err: errors.New("repair unavailable")})
|
||||||
|
req := testRequest(0)
|
||||||
|
req.Generation = 2
|
||||||
|
if _, err := repair.Generate(ctx, req); err == nil {
|
||||||
|
t.Fatal("expected repair failure")
|
||||||
|
}
|
||||||
|
recorder.Finish(observation.FinishData{Outcome: domain.ExecutionOutcomeError, Failure: domain.ExecutionFailureGeneration})
|
||||||
|
if len(events) != 3 || events[0].Outcome != domain.ExecutionOutcomeSuccess || events[1].Outcome != domain.ExecutionOutcomeError || events[2].Attempts != 2 || events[2].RepairCalls != 1 {
|
||||||
|
t.Fatalf("events=%+v", events)
|
||||||
|
}
|
||||||
|
if got := events[2].Usage.PromptTokens; got.Value != 5 || !got.Known || got.Complete {
|
||||||
|
t.Fatalf("prompt usage=%+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ var (
|
|||||||
ErrInvalidConfig = errors.New("invalid llm client configuration")
|
ErrInvalidConfig = errors.New("invalid llm client configuration")
|
||||||
ErrInvalidRequest = errors.New("invalid generate request")
|
ErrInvalidRequest = errors.New("invalid generate request")
|
||||||
ErrRequestFailed = errors.New("llm request failed")
|
ErrRequestFailed = errors.New("llm request failed")
|
||||||
|
ErrTransport = errors.New("llm transport failed")
|
||||||
ErrUnexpectedStatus = errors.New("llm returned non-success status")
|
ErrUnexpectedStatus = errors.New("llm returned non-success status")
|
||||||
ErrMalformedResponse = errors.New("malformed llm response")
|
ErrMalformedResponse = errors.New("malformed llm response")
|
||||||
)
|
)
|
||||||
@@ -36,7 +39,7 @@ func (e *requestFailedError) Error() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *requestFailedError) Unwrap() []error {
|
func (e *requestFailedError) Unwrap() []error {
|
||||||
return []error{ErrRequestFailed, e.cause}
|
return []error{ErrRequestFailed, ErrTransport, e.cause}
|
||||||
}
|
}
|
||||||
|
|
||||||
type OpenAICompatibleConfig struct {
|
type OpenAICompatibleConfig struct {
|
||||||
@@ -86,6 +89,40 @@ func NewOpenAICompatibleClient(cfg OpenAICompatibleConfig) (*OpenAICompatibleCli
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
|
func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
|
||||||
|
prepared, err := c.PrepareGeneration(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
outcome := prepared.Execute(ctx)
|
||||||
|
if outcome.Err != nil {
|
||||||
|
return nil, outcome.Err
|
||||||
|
}
|
||||||
|
return outcome.Response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreparedGeneration holds one encoded request and resolved authentication for
|
||||||
|
// repeated attempts. Each Execute call creates a fresh HTTP request and context.
|
||||||
|
type PreparedGeneration struct {
|
||||||
|
endpoint string
|
||||||
|
payload []byte
|
||||||
|
apiKey string
|
||||||
|
timeoutSeconds int
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttemptOutcome describes one HTTP execution without exposing response bodies
|
||||||
|
// or headers to the orchestration layer.
|
||||||
|
type AttemptOutcome struct {
|
||||||
|
Response *domain.GenerateResponse
|
||||||
|
Err error
|
||||||
|
StatusCode int
|
||||||
|
Usage domain.TokenUsage
|
||||||
|
UsagePresence *domain.TokenUsagePresence
|
||||||
|
RetryNotBefore time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrepareGeneration validates and freezes one built-in request before dispatch.
|
||||||
|
func (c *OpenAICompatibleClient) PrepareGeneration(req domain.GenerateRequest) (*PreparedGeneration, error) {
|
||||||
if err := domain.ValidateExecutionTargetSettings(req.Target); err != nil {
|
if err := domain.ValidateExecutionTargetSettings(req.Target); err != nil {
|
||||||
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
}
|
}
|
||||||
@@ -117,74 +154,151 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.Genera
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: failed to encode request: %v", ErrRequestFailed, err)
|
return nil, fmt.Errorf("%w: failed to encode request: %v", ErrRequestFailed, err)
|
||||||
}
|
}
|
||||||
|
apiKey := strings.TrimSpace(req.Target.APIKey)
|
||||||
requestContext := ctx
|
envName := strings.TrimSpace(req.Target.APIKeyEnv)
|
||||||
if req.Target.TimeoutSeconds > 0 {
|
if apiKey == "" && envName != "" {
|
||||||
var cancel context.CancelFunc
|
apiKey = strings.TrimSpace(os.Getenv(envName))
|
||||||
requestContext, cancel = context.WithTimeout(
|
|
||||||
ctx,
|
|
||||||
time.Duration(req.Target.TimeoutSeconds)*time.Second,
|
|
||||||
)
|
|
||||||
defer cancel()
|
|
||||||
}
|
}
|
||||||
|
if apiKey == "" && req.Target.APIKeyRequired {
|
||||||
httpReq, err := http.NewRequestWithContext(requestContext, http.MethodPost, endpoint, bytes.NewReader(payload))
|
if envName != "" {
|
||||||
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)
|
return nil, fmt.Errorf("%w: api key environment variable %q is not set", ErrInvalidRequest, envName)
|
||||||
}
|
}
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
return nil, fmt.Errorf("%w: api key is required", ErrInvalidRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
httpClient := c.httpClient
|
httpClient := c.httpClient
|
||||||
if httpClient == nil {
|
if httpClient == nil {
|
||||||
httpClient = &http.Client{Timeout: defaults.LLMRequestTimeoutDefault}
|
httpClient = &http.Client{Timeout: defaults.LLMRequestTimeoutDefault}
|
||||||
}
|
}
|
||||||
|
return &PreparedGeneration{
|
||||||
|
endpoint: endpoint, payload: payload, apiKey: apiKey,
|
||||||
|
timeoutSeconds: req.Target.TimeoutSeconds, httpClient: httpClient,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
httpResp, err := httpClient.Do(httpReq)
|
// Execute dispatches one attempt. The returned response is non-nil only when
|
||||||
if err != nil {
|
// the successful envelope contains a usable first choice.
|
||||||
return nil, &requestFailedError{cause: err}
|
func (p *PreparedGeneration) Execute(ctx context.Context) AttemptOutcome {
|
||||||
|
requestContext := ctx
|
||||||
|
if p.timeoutSeconds > 0 {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
requestContext, cancel = context.WithTimeout(ctx, time.Duration(p.timeoutSeconds)*time.Second)
|
||||||
|
defer cancel()
|
||||||
}
|
}
|
||||||
|
httpReq, err := http.NewRequestWithContext(requestContext, http.MethodPost, p.endpoint, bytes.NewReader(p.payload))
|
||||||
|
if err != nil {
|
||||||
|
return AttemptOutcome{Err: fmt.Errorf("%w: failed to create request: %v", ErrRequestFailed, err)}
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
if p.apiKey != "" {
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpResp, err := p.httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return AttemptOutcome{Err: &requestFailedError{cause: err}}
|
||||||
|
}
|
||||||
|
receivedAt := time.Now()
|
||||||
defer httpResp.Body.Close()
|
defer httpResp.Body.Close()
|
||||||
|
outcome := AttemptOutcome{StatusCode: httpResp.StatusCode}
|
||||||
|
|
||||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||||
_, _ = io.Copy(io.Discard, io.LimitReader(httpResp.Body, 4096))
|
outcome.RetryNotBefore = retryNotBefore(httpResp.Header.Values("Retry-After"), receivedAt)
|
||||||
return nil, fmt.Errorf("%w: status=%d", ErrUnexpectedStatus, httpResp.StatusCode)
|
outcome.Err = providerHTTPErrorFromBody(
|
||||||
|
httpResp.StatusCode,
|
||||||
|
httpResp.ContentLength,
|
||||||
|
httpResp.Body,
|
||||||
|
)
|
||||||
|
if err := observedContextError(requestContext, ctx, outcome.Err); err != nil {
|
||||||
|
outcome.Err = err
|
||||||
|
}
|
||||||
|
return outcome
|
||||||
}
|
}
|
||||||
if httpResp.ContentLength > maxOpenAIChatResponseBytes {
|
if httpResp.ContentLength > maxOpenAIChatResponseBytes {
|
||||||
return nil, openAIChatResponseTooLargeError()
|
outcome.Err = openAIChatResponseTooLargeError()
|
||||||
|
return outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
wireResp, err := decodeOpenAIChatResponse(httpResp.Body)
|
wireResp, err := decodeOpenAIChatResponse(httpResp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
outcome.Err = err
|
||||||
|
if contextErr := observedContextError(requestContext, ctx, err); contextErr != nil {
|
||||||
|
outcome.Err = contextErr
|
||||||
|
}
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
outcome.Usage = domain.TokenUsage{
|
||||||
|
PromptTokens: tokenCount(wireResp.Usage.PromptTokens), CompletionTokens: tokenCount(wireResp.Usage.CompletionTokens),
|
||||||
|
TotalTokens: tokenCount(wireResp.Usage.TotalTokens), CachedTokens: tokenCount(wireResp.Usage.PromptTokensDetails.CachedTokens),
|
||||||
|
CacheWriteTokens: tokenCount(wireResp.Usage.CacheWriteTokens),
|
||||||
|
}
|
||||||
|
outcome.UsagePresence = &domain.TokenUsagePresence{
|
||||||
|
PromptTokens: wireResp.Usage.PromptTokens != nil, CompletionTokens: wireResp.Usage.CompletionTokens != nil,
|
||||||
|
TotalTokens: wireResp.Usage.TotalTokens != nil, CachedTokens: wireResp.Usage.PromptTokensDetails.CachedTokens != nil,
|
||||||
|
CacheWriteTokens: wireResp.Usage.CacheWriteTokens != nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(wireResp.Choices) == 0 {
|
if len(wireResp.Choices) == 0 {
|
||||||
return nil, fmt.Errorf("%w: no choices returned", ErrMalformedResponse)
|
outcome.Err = fmt.Errorf("%w: no choices returned", ErrMalformedResponse)
|
||||||
|
return outcome
|
||||||
}
|
}
|
||||||
content := wireResp.Choices[0].Message.Content
|
content := wireResp.Choices[0].Message.Content
|
||||||
if content == "" {
|
if content == nil {
|
||||||
return nil, fmt.Errorf("%w: first choice has empty message content", ErrMalformedResponse)
|
outcome.Err = fmt.Errorf("%w: first choice has missing message content", ErrMalformedResponse)
|
||||||
|
return outcome
|
||||||
}
|
}
|
||||||
|
outcome.Response = &domain.GenerateResponse{Content: *content, Usage: outcome.Usage, UsagePresence: outcome.UsagePresence}
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
|
||||||
return &domain.GenerateResponse{
|
func observedContextError(attempt, caller context.Context, cause error) error {
|
||||||
Content: content,
|
if err := caller.Err(); err != nil {
|
||||||
Usage: domain.TokenUsage{
|
return &requestFailedError{cause: err}
|
||||||
PromptTokens: wireResp.Usage.PromptTokens,
|
}
|
||||||
CompletionTokens: wireResp.Usage.CompletionTokens,
|
if err := attempt.Err(); err != nil {
|
||||||
TotalTokens: wireResp.Usage.TotalTokens,
|
return &requestFailedError{cause: err}
|
||||||
CachedTokens: wireResp.Usage.PromptTokensDetails.CachedTokens,
|
}
|
||||||
CacheWriteTokens: wireResp.Usage.CacheWriteTokens,
|
if errors.Is(cause, context.Canceled) || errors.Is(cause, context.DeadlineExceeded) {
|
||||||
},
|
return &requestFailedError{cause: cause}
|
||||||
}, nil
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func retryNotBefore(values []string, receivedAt time.Time) time.Time {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
value := strings.TrimSpace(values[0])
|
||||||
|
if value == "" {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
digits := true
|
||||||
|
for _, r := range value {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
digits = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if digits {
|
||||||
|
seconds, err := strconv.ParseUint(value, 10, 64)
|
||||||
|
if err != nil || seconds > uint64(math.MaxInt64/int64(time.Second)) {
|
||||||
|
return receivedAt.Add(time.Duration(math.MaxInt64))
|
||||||
|
}
|
||||||
|
return receivedAt.Add(time.Duration(seconds) * time.Second)
|
||||||
|
}
|
||||||
|
if when, err := http.ParseTime(value); err == nil {
|
||||||
|
if when.Before(receivedAt) {
|
||||||
|
return receivedAt
|
||||||
|
}
|
||||||
|
return when
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenCount(value *int) int {
|
||||||
|
if value == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *value
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeOpenAIChatResponse(body io.Reader) (openAIChatResponse, error) {
|
func decodeOpenAIChatResponse(body io.Reader) (openAIChatResponse, error) {
|
||||||
@@ -196,6 +310,9 @@ func decodeOpenAIChatResponse(body io.Reader) (openAIChatResponse, error) {
|
|||||||
|
|
||||||
var response openAIChatResponse
|
var response openAIChatResponse
|
||||||
if err := decoder.Decode(&response); err != nil {
|
if err := decoder.Decode(&response); err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return openAIChatResponse{}, &requestFailedError{cause: err}
|
||||||
|
}
|
||||||
if limited.N == 0 {
|
if limited.N == 0 {
|
||||||
return openAIChatResponse{}, openAIChatResponseTooLargeError()
|
return openAIChatResponse{}, openAIChatResponseTooLargeError()
|
||||||
}
|
}
|
||||||
@@ -207,6 +324,9 @@ func decodeOpenAIChatResponse(body io.Reader) (openAIChatResponse, error) {
|
|||||||
|
|
||||||
var trailing any
|
var trailing any
|
||||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||||
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return openAIChatResponse{}, &requestFailedError{cause: err}
|
||||||
|
}
|
||||||
if limited.N == 0 {
|
if limited.N == 0 {
|
||||||
return openAIChatResponse{}, openAIChatResponseTooLargeError()
|
return openAIChatResponse{}, openAIChatResponseTooLargeError()
|
||||||
}
|
}
|
||||||
@@ -371,8 +491,8 @@ type openAICacheControl struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type openAIChatResponseMessage struct {
|
type openAIChatResponseMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content *string `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type openAIChatResponse struct {
|
type openAIChatResponse struct {
|
||||||
@@ -380,13 +500,13 @@ type openAIChatResponse struct {
|
|||||||
Message openAIChatResponseMessage `json:"message"`
|
Message openAIChatResponseMessage `json:"message"`
|
||||||
} `json:"choices"`
|
} `json:"choices"`
|
||||||
Usage struct {
|
Usage struct {
|
||||||
PromptTokens int `json:"prompt_tokens"`
|
PromptTokens *int `json:"prompt_tokens"`
|
||||||
CompletionTokens int `json:"completion_tokens"`
|
CompletionTokens *int `json:"completion_tokens"`
|
||||||
TotalTokens int `json:"total_tokens"`
|
TotalTokens *int `json:"total_tokens"`
|
||||||
PromptTokensDetails struct {
|
PromptTokensDetails struct {
|
||||||
CachedTokens int `json:"cached_tokens"`
|
CachedTokens *int `json:"cached_tokens"`
|
||||||
} `json:"prompt_tokens_details"`
|
} `json:"prompt_tokens_details"`
|
||||||
CacheWriteTokens int `json:"cache_write_tokens"`
|
CacheWriteTokens *int `json:"cache_write_tokens"`
|
||||||
} `json:"usage"`
|
} `json:"usage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -363,6 +363,7 @@ func TestOpenAICompatibleClientRequestMapping(t *testing.T) {
|
|||||||
run func(*testing.T)
|
run func(*testing.T)
|
||||||
}{
|
}{
|
||||||
{name: "complete request and response mapping", run: checkCompleteRequestAndResponseMapping},
|
{name: "complete request and response mapping", run: checkCompleteRequestAndResponseMapping},
|
||||||
|
{name: "canonical role and exact content", run: checkCanonicalRoleAndExactContentMapping},
|
||||||
{name: "cache-controlled message", run: checkCacheControlledMessageMapping},
|
{name: "cache-controlled message", run: checkCacheControlledMessageMapping},
|
||||||
{name: "empty cache-control TTL", run: checkEmptyCacheControlTTLOmission},
|
{name: "empty cache-control TTL", run: checkEmptyCacheControlTTLOmission},
|
||||||
{name: "session ID", run: checkSessionIDMapping},
|
{name: "session ID", run: checkSessionIDMapping},
|
||||||
@@ -377,6 +378,34 @@ func TestOpenAICompatibleClientRequestMapping(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkCanonicalRoleAndExactContentMapping(t *testing.T) {
|
||||||
|
provider := newRecordingProvider(t)
|
||||||
|
client := newProviderClient(t, provider, OpenAICompatibleConfig{})
|
||||||
|
const content = " exact content\nwith whitespace "
|
||||||
|
_, err := client.Generate(context.Background(), domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{
|
||||||
|
Role: domain.RoleDeveloper,
|
||||||
|
Content: content,
|
||||||
|
}}},
|
||||||
|
Target: domain.ExecutionTarget{Model: "model"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages []struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
request := provider.lastRequest(t)
|
||||||
|
if !request.decodeField(t, "messages", &messages) || len(messages) != 1 {
|
||||||
|
t.Fatalf("messages = %#v, want one message", messages)
|
||||||
|
}
|
||||||
|
if messages[0].Role != domain.RoleDeveloper || messages[0].Content != content {
|
||||||
|
t.Fatalf("message = %#v, want role %q and exact content %q", messages[0], domain.RoleDeveloper, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func checkCompleteRequestAndResponseMapping(t *testing.T) {
|
func checkCompleteRequestAndResponseMapping(t *testing.T) {
|
||||||
provider := newRecordingProvider(t)
|
provider := newRecordingProvider(t)
|
||||||
provider.respond(http.StatusOK, `{
|
provider.respond(http.StatusOK, `{
|
||||||
@@ -501,36 +530,61 @@ func checkCompleteRequestAndResponseMapping(t *testing.T) {
|
|||||||
|
|
||||||
func TestOpenAICompatibleClientAuthentication(t *testing.T) {
|
func TestOpenAICompatibleClientAuthentication(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
configureEnv func(*testing.T)
|
configureEnv func(*testing.T)
|
||||||
target domain.ExecutionTarget
|
target domain.ExecutionTarget
|
||||||
wantAuth string
|
wantAuthorization string
|
||||||
wantErr error
|
wantError error
|
||||||
wantCallCount int
|
wantCallCount int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "direct key takes precedence over environment",
|
name: "direct key takes precedence over environment",
|
||||||
configureEnv: func(t *testing.T) {
|
configureEnv: func(t *testing.T) {
|
||||||
t.Setenv("PROMPTKIT_TEST_API_KEY", "env-key")
|
t.Setenv("PROMPTKIT_TEST_API_KEY", " env-key ")
|
||||||
},
|
},
|
||||||
target: domain.ExecutionTarget{
|
target: domain.ExecutionTarget{
|
||||||
APIKeyEnv: "PROMPTKIT_TEST_API_KEY",
|
APIKeyEnv: "PROMPTKIT_TEST_API_KEY",
|
||||||
APIKey: "direct-llm-key",
|
APIKey: " direct-llm-key ",
|
||||||
},
|
},
|
||||||
wantAuth: "Bearer direct-llm-key",
|
wantAuthorization: "Bearer direct-llm-key",
|
||||||
wantCallCount: 1,
|
wantCallCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "environment key supplies authorization",
|
||||||
|
configureEnv: func(t *testing.T) {
|
||||||
|
t.Setenv("PROMPTKIT_TEST_API_KEY", " env-key ")
|
||||||
|
},
|
||||||
|
target: domain.ExecutionTarget{APIKeyEnv: "PROMPTKIT_TEST_API_KEY"},
|
||||||
|
wantAuthorization: "Bearer env-key",
|
||||||
|
wantCallCount: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no key omits authorization",
|
name: "no key omits authorization",
|
||||||
wantCallCount: 1,
|
wantCallCount: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing environment key fails before transport",
|
name: "optional missing environment omits authorization",
|
||||||
configureEnv: func(t *testing.T) {
|
configureEnv: func(t *testing.T) {
|
||||||
t.Setenv("PROMPTKIT_MISSING_KEY", "")
|
t.Setenv("PROMPTKIT_MISSING_KEY", "")
|
||||||
},
|
},
|
||||||
target: domain.ExecutionTarget{APIKeyEnv: "PROMPTKIT_MISSING_KEY"},
|
target: domain.ExecutionTarget{APIKeyEnv: "PROMPTKIT_MISSING_KEY"},
|
||||||
wantErr: ErrInvalidRequest,
|
wantCallCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "required missing environment fails before transport",
|
||||||
|
configureEnv: func(t *testing.T) {
|
||||||
|
t.Setenv("PROMPTKIT_MISSING_KEY", "")
|
||||||
|
},
|
||||||
|
target: domain.ExecutionTarget{
|
||||||
|
APIKeyEnv: "PROMPTKIT_MISSING_KEY",
|
||||||
|
APIKeyRequired: true,
|
||||||
|
},
|
||||||
|
wantError: ErrInvalidRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "required target without source fails before transport",
|
||||||
|
target: domain.ExecutionTarget{APIKeyRequired: true},
|
||||||
|
wantError: ErrInvalidRequest,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,9 +599,9 @@ func TestOpenAICompatibleClientAuthentication(t *testing.T) {
|
|||||||
request.Target = tc.target
|
request.Target = tc.target
|
||||||
|
|
||||||
_, err := client.Generate(context.Background(), request)
|
_, err := client.Generate(context.Background(), request)
|
||||||
if tc.wantErr != nil {
|
if tc.wantError != nil {
|
||||||
if !errors.Is(err, tc.wantErr) {
|
if !errors.Is(err, tc.wantError) {
|
||||||
t.Fatalf("error = %v, want %v", err, tc.wantErr)
|
t.Fatalf("error = %v, want %v", err, tc.wantError)
|
||||||
}
|
}
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
t.Fatalf("generate: %v", err)
|
t.Fatalf("generate: %v", err)
|
||||||
@@ -556,8 +610,13 @@ func TestOpenAICompatibleClientAuthentication(t *testing.T) {
|
|||||||
t.Fatalf("provider calls = %d, want %d", got, tc.wantCallCount)
|
t.Fatalf("provider calls = %d, want %d", got, tc.wantCallCount)
|
||||||
}
|
}
|
||||||
if tc.wantCallCount == 1 {
|
if tc.wantCallCount == 1 {
|
||||||
if got := provider.lastRequest(t).header.Get("Authorization"); got != tc.wantAuth {
|
values := provider.lastRequest(t).header.Values("Authorization")
|
||||||
t.Fatalf("Authorization = %q, want %q", got, tc.wantAuth)
|
if tc.wantAuthorization == "" {
|
||||||
|
if len(values) != 0 {
|
||||||
|
t.Fatalf("Authorization values = %q, want absent", values)
|
||||||
|
}
|
||||||
|
} else if len(values) != 1 || values[0] != tc.wantAuthorization {
|
||||||
|
t.Fatalf("Authorization values = %q, want [%q]", values, tc.wantAuthorization)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -735,6 +794,7 @@ func TestOpenAICompatibleClientResponseFraming(t *testing.T) {
|
|||||||
run func(*testing.T)
|
run func(*testing.T)
|
||||||
}{
|
}{
|
||||||
{name: "usage mapping", run: checkCacheUsageMapping},
|
{name: "usage mapping", run: checkCacheUsageMapping},
|
||||||
|
{name: "content presence", run: checkContentPresence},
|
||||||
{name: "common response failures", run: checkCommonResponseFailures},
|
{name: "common response failures", run: checkCommonResponseFailures},
|
||||||
{name: "successful response byte boundary", run: checkSuccessfulResponseByteBoundary},
|
{name: "successful response byte boundary", run: checkSuccessfulResponseByteBoundary},
|
||||||
{name: "continuing oversized response", run: checkContinuingOversizedResponse},
|
{name: "continuing oversized response", run: checkContinuingOversizedResponse},
|
||||||
@@ -746,6 +806,53 @@ func TestOpenAICompatibleClientResponseFraming(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkContentPresence(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
}{
|
||||||
|
{name: "explicit empty string", content: ""},
|
||||||
|
{name: "whitespace string", content: " \n\t "},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
provider := newRecordingProvider(t)
|
||||||
|
provider.respond(http.StatusOK, `{
|
||||||
|
"choices": [{"message": {"content": `+strconv.Quote(tc.content)+`}}],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 10,
|
||||||
|
"completion_tokens": 20,
|
||||||
|
"total_tokens": 30,
|
||||||
|
"prompt_tokens_details": {"cached_tokens": 4},
|
||||||
|
"cache_write_tokens": 5
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
client := newProviderClient(t, provider, OpenAICompatibleConfig{Model: "model"})
|
||||||
|
|
||||||
|
response, err := client.Generate(context.Background(), ordinaryGenerateRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate: %v", err)
|
||||||
|
}
|
||||||
|
if response == nil {
|
||||||
|
t.Fatal("expected response")
|
||||||
|
}
|
||||||
|
if response.Content != tc.content {
|
||||||
|
t.Fatalf("content = %q, want %q", response.Content, tc.content)
|
||||||
|
}
|
||||||
|
if response.Usage != (domain.TokenUsage{
|
||||||
|
PromptTokens: 10,
|
||||||
|
CompletionTokens: 20,
|
||||||
|
TotalTokens: 30,
|
||||||
|
CachedTokens: 4,
|
||||||
|
CacheWriteTokens: 5,
|
||||||
|
}) {
|
||||||
|
t.Fatalf("usage = %+v", response.Usage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func checkCacheUsageMapping(t *testing.T) {
|
func checkCacheUsageMapping(t *testing.T) {
|
||||||
provider := newRecordingProvider(t)
|
provider := newRecordingProvider(t)
|
||||||
provider.respond(http.StatusOK, `{
|
provider.respond(http.StatusOK, `{
|
||||||
@@ -774,6 +881,44 @@ func checkCacheUsageMapping(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompatibleUsagePresence(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
usage string
|
||||||
|
want domain.TokenUsage
|
||||||
|
presence domain.TokenUsagePresence
|
||||||
|
}{
|
||||||
|
{name: "absent"},
|
||||||
|
{name: "null", usage: `"usage":null,`},
|
||||||
|
{name: "partial nested", usage: `"usage":{"prompt_tokens":7,"prompt_tokens_details":{"cached_tokens":0}},`,
|
||||||
|
want: domain.TokenUsage{PromptTokens: 7}, presence: domain.TokenUsagePresence{PromptTokens: true, CachedTokens: true}},
|
||||||
|
{name: "explicit null fields", usage: `"usage":{"prompt_tokens":null,"completion_tokens":0,"prompt_tokens_details":{"cached_tokens":null}},`,
|
||||||
|
presence: domain.TokenUsagePresence{CompletionTokens: true}},
|
||||||
|
{name: "all reported", usage: `"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3,"prompt_tokens_details":{"cached_tokens":4},"cache_write_tokens":5},`,
|
||||||
|
want: domain.TokenUsage{PromptTokens: 1, CompletionTokens: 2, TotalTokens: 3, CachedTokens: 4, CacheWriteTokens: 5},
|
||||||
|
presence: domain.TokenUsagePresence{PromptTokens: true, CompletionTokens: true, TotalTokens: true, CachedTokens: true, CacheWriteTokens: true}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
provider := newRecordingProvider(t)
|
||||||
|
provider.respond(http.StatusOK, `{`+tc.usage+`"choices":[{"message":{"content":"ok"}}]}`)
|
||||||
|
client := newProviderClient(t, provider, OpenAICompatibleConfig{Model: "model"})
|
||||||
|
resp, err := client.Generate(context.Background(), ordinaryGenerateRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.Usage != tc.want || resp.UsagePresence == nil || *resp.UsagePresence != tc.presence {
|
||||||
|
t.Fatalf("usage=%+v, presence=%+v", resp.Usage, resp.UsagePresence)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
provider := newRecordingProvider(t)
|
||||||
|
provider.respond(http.StatusOK, `{"usage":{"prompt_tokens":"seven"},"choices":[{"message":{"content":"ok"}}]}`)
|
||||||
|
client := newProviderClient(t, provider, OpenAICompatibleConfig{Model: "model"})
|
||||||
|
if resp, err := client.Generate(context.Background(), ordinaryGenerateRequest()); resp != nil || !errors.Is(err, ErrMalformedResponse) {
|
||||||
|
t.Fatalf("invalid numeric usage response=%+v, error=%v", resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func checkOptionalResponseFieldOmission(t *testing.T) {
|
func checkOptionalResponseFieldOmission(t *testing.T) {
|
||||||
provider := newRecordingProvider(t)
|
provider := newRecordingProvider(t)
|
||||||
client := newProviderClient(t, provider, OpenAICompatibleConfig{})
|
client := newProviderClient(t, provider, OpenAICompatibleConfig{})
|
||||||
@@ -1099,6 +1244,9 @@ func checkCommonResponseFailures(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{name: "invalid JSON", statusCode: http.StatusOK, body: `{not valid json`, wantErr: ErrMalformedResponse},
|
{name: "invalid JSON", statusCode: http.StatusOK, body: `{not valid json`, wantErr: ErrMalformedResponse},
|
||||||
{name: "missing choices", statusCode: http.StatusOK, body: `{"choices": []}`, wantErr: ErrMalformedResponse},
|
{name: "missing choices", statusCode: http.StatusOK, body: `{"choices": []}`, wantErr: ErrMalformedResponse},
|
||||||
|
{name: "missing content", statusCode: http.StatusOK, body: `{"choices": [{"message": {}}]}`, wantErr: ErrMalformedResponse},
|
||||||
|
{name: "null content", statusCode: http.StatusOK, body: `{"choices": [{"message": {"content": null}}]}`, wantErr: ErrMalformedResponse},
|
||||||
|
{name: "non-string content", statusCode: http.StatusOK, body: `{"choices": [{"message": {"content": 1}}]}`, wantErr: ErrMalformedResponse},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
@@ -1114,6 +1262,15 @@ func checkCommonResponseFailures(t *testing.T) {
|
|||||||
if !errors.Is(err, tc.wantErr) {
|
if !errors.Is(err, tc.wantErr) {
|
||||||
t.Fatalf("error = %v, want %v", err, tc.wantErr)
|
t.Fatalf("error = %v, want %v", err, tc.wantErr)
|
||||||
}
|
}
|
||||||
|
if tc.statusCode < http.StatusOK || tc.statusCode >= http.StatusMultipleChoices {
|
||||||
|
var providerHTTPError *ProviderHTTPError
|
||||||
|
if !errors.As(err, &providerHTTPError) {
|
||||||
|
t.Fatalf("error = %T, want *ProviderHTTPError", err)
|
||||||
|
}
|
||||||
|
if got := providerHTTPError.StatusCode(); got != tc.statusCode {
|
||||||
|
t.Fatalf("provider status = %d, want %d", got, tc.statusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
if tc.wantText != "" && !strings.Contains(err.Error(), tc.wantText) {
|
if tc.wantText != "" && !strings.Contains(err.Error(), tc.wantText) {
|
||||||
t.Fatalf("error %q does not contain %q", err, tc.wantText)
|
t.Fatalf("error %q does not contain %q", err, tc.wantText)
|
||||||
}
|
}
|
||||||
|
|||||||
225
internal/llm/prepared_generation_test.go
Normal file
225
internal/llm/prepared_generation_test.go
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPreparedGenerationFreezesPayloadAndAuthentication(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
initialKey string
|
||||||
|
wantAuth string
|
||||||
|
}{
|
||||||
|
{name: "absent optional key"},
|
||||||
|
{name: "present key", initialKey: "original-key", wantAuth: "Bearer original-key"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
const envName = "PROMPTKIT_PREPARED_GENERATION_TEST_KEY"
|
||||||
|
t.Setenv(envName, tc.initialKey)
|
||||||
|
var bodies, auth, urls []string
|
||||||
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||||
|
BaseURL: "https://example.invalid/v1", Model: "default-model",
|
||||||
|
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
body, err := io.ReadAll(req.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bodies = append(bodies, string(body))
|
||||||
|
auth = append(auth, req.Header.Get("Authorization"))
|
||||||
|
urls = append(urls, req.URL.String())
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"ok"}}]}`))}, nil
|
||||||
|
})},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "original"}}},
|
||||||
|
Target: domain.ExecutionTarget{APIKeyEnv: envName},
|
||||||
|
}
|
||||||
|
prepared, err := client.PrepareGeneration(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Prompt.Messages[0].Content = "changed"
|
||||||
|
t.Setenv(envName, "later-key")
|
||||||
|
for range 2 {
|
||||||
|
outcome := prepared.Execute(context.Background())
|
||||||
|
if outcome.Err != nil || outcome.Response == nil || outcome.Response.Content != "ok" {
|
||||||
|
t.Fatalf("Execute = %+v", outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(bodies) != 2 || bodies[0] != bodies[1] || !strings.Contains(bodies[0], "original") || strings.Contains(bodies[0], "changed") || !reflect.DeepEqual(auth, []string{tc.wantAuth, tc.wantAuth}) || !reflect.DeepEqual(urls, []string{"https://example.invalid/v1/chat/completions", "https://example.invalid/v1/chat/completions"}) {
|
||||||
|
t.Fatalf("bodies=%v auth=%v urls=%v", bodies, auth, urls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparedGenerationAttemptsHaveIndependentContextsAndCloseBodies(t *testing.T) {
|
||||||
|
var contexts []context.Context
|
||||||
|
var closed int
|
||||||
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||||
|
BaseURL: "https://example.invalid/v1", Model: "model",
|
||||||
|
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
if err := req.Context().Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
contexts = append(contexts, req.Context())
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: &countingBody{
|
||||||
|
Reader: strings.NewReader(`{"choices":[{"message":{"content":"ok"}}]}`),
|
||||||
|
closed: &closed,
|
||||||
|
}}, nil
|
||||||
|
})},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
prepared, err := client.PrepareGeneration(domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
||||||
|
Target: domain.ExecutionTarget{TimeoutSeconds: 1},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for range 2 {
|
||||||
|
if outcome := prepared.Execute(context.Background()); outcome.Err != nil {
|
||||||
|
t.Fatal(outcome.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(contexts) != 2 || contexts[0] == contexts[1] || closed != 2 {
|
||||||
|
t.Fatalf("contexts=%d, closed=%d", len(contexts), closed)
|
||||||
|
}
|
||||||
|
for _, ctx := range contexts {
|
||||||
|
if _, ok := ctx.Deadline(); !ok || !errors.Is(ctx.Err(), context.Canceled) {
|
||||||
|
t.Fatalf("attempt context deadline/error = %v/%v", ok, ctx.Err())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if outcome := prepared.Execute(ctx); !errors.Is(outcome.Err, context.Canceled) {
|
||||||
|
t.Fatalf("canceled attempt = %+v", outcome)
|
||||||
|
}
|
||||||
|
if outcome := prepared.Execute(context.Background()); outcome.Err != nil || outcome.Response == nil {
|
||||||
|
t.Fatalf("later attempt = %+v", outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparedGenerationRetainsUsageFromMalformedChoice(t *testing.T) {
|
||||||
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||||
|
BaseURL: "https://example.invalid/v1", Model: "model",
|
||||||
|
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"usage":{"prompt_tokens":0},"choices":[]}`))}, nil
|
||||||
|
})},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
prepared, err := client.PrepareGeneration(domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
outcome := prepared.Execute(context.Background())
|
||||||
|
if !errors.Is(outcome.Err, ErrMalformedResponse) || outcome.Response != nil || outcome.UsagePresence == nil || !outcome.UsagePresence.PromptTokens || outcome.Usage.PromptTokens != 0 {
|
||||||
|
t.Fatalf("malformed choice = %+v", outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparedGenerationDoesNotUseInvalidlyFramedUsage(t *testing.T) {
|
||||||
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||||
|
BaseURL: "https://example.invalid/v1", Model: "model",
|
||||||
|
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"usage":{"prompt_tokens":9},"choices":[]}{}`))}, nil
|
||||||
|
})},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
prepared, err := client.PrepareGeneration(domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
outcome := prepared.Execute(context.Background())
|
||||||
|
if !errors.Is(outcome.Err, ErrMalformedResponse) || outcome.UsagePresence != nil || outcome.Usage != (domain.TokenUsage{}) {
|
||||||
|
t.Fatalf("invalid framing = %+v", outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreparedGenerationRetainsReadCancellation(t *testing.T) {
|
||||||
|
closed := false
|
||||||
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||||
|
BaseURL: "https://example.invalid/v1", Model: "model",
|
||||||
|
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: &readErrorBody{err: context.Canceled, closed: &closed}}, nil
|
||||||
|
})},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
prepared, err := client.PrepareGeneration(domain.GenerateRequest{
|
||||||
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
outcome := prepared.Execute(context.Background())
|
||||||
|
if !errors.Is(outcome.Err, context.Canceled) || !errors.Is(outcome.Err, ErrTransport) || errors.Is(outcome.Err, ErrMalformedResponse) || !closed {
|
||||||
|
t.Fatalf("read cancellation = %+v, closed=%t", outcome, closed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryNotBefore(t *testing.T) {
|
||||||
|
received := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
want time.Time
|
||||||
|
}{
|
||||||
|
{name: "seconds", value: " 12 ", want: received.Add(12 * time.Second)},
|
||||||
|
{name: "date", value: "Wed, 23 Sep 2026 12:00:15 GMT", want: received.Add(15 * time.Second)},
|
||||||
|
{name: "past date", value: "Wed, 23 Sep 2026 11:59:59 GMT", want: received},
|
||||||
|
{name: "negative", value: "-2"},
|
||||||
|
{name: "fractional", value: "1.5"},
|
||||||
|
{name: "malformed", value: "tomorrow"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := retryNotBefore([]string{tc.value, "999"}, received); !got.Equal(tc.want) {
|
||||||
|
t.Fatalf("not before = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if got := retryNotBefore([]string{"999999999999999999999999999999999999"}, received); !got.After(received) {
|
||||||
|
t.Fatalf("large valid delay wrapped: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type countingBody struct {
|
||||||
|
io.Reader
|
||||||
|
closed *int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *countingBody) Close() error {
|
||||||
|
*b.closed++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type readErrorBody struct {
|
||||||
|
err error
|
||||||
|
closed *bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *readErrorBody) Read([]byte) (int, error) { return 0, b.err }
|
||||||
|
func (b *readErrorBody) Close() error { *b.closed = true; return nil }
|
||||||
189
internal/llm/provider_http_error.go
Normal file
189
internal/llm/provider_http_error.go
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxProviderErrorResponseBytes int64 = 64 << 10
|
||||||
|
maxProviderErrorIdentifierRunes = 256
|
||||||
|
maxProviderErrorMessageRunes = 4096
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProviderHTTPError describes a non-success response from an LLM provider.
|
||||||
|
type ProviderHTTPError struct {
|
||||||
|
statusCode int
|
||||||
|
providerCode string
|
||||||
|
providerType string
|
||||||
|
providerMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ProviderHTTPError) StatusCode() int {
|
||||||
|
if e == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return e.statusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ProviderHTTPError) ProviderCode() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.providerCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ProviderHTTPError) ProviderType() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.providerType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ProviderHTTPError) ProviderMessage() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.providerMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ProviderHTTPError) Error() string {
|
||||||
|
if e == nil || e.statusCode == 0 {
|
||||||
|
return ErrUnexpectedStatus.Error()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s: status=%d", ErrUnexpectedStatus, e.statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ProviderHTTPError) GoString() string {
|
||||||
|
return e.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ProviderHTTPError) Unwrap() error {
|
||||||
|
return ErrUnexpectedStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
type providerErrorDetails struct {
|
||||||
|
providerCode string
|
||||||
|
providerType string
|
||||||
|
providerMessage string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProviderHTTPError(statusCode int, details providerErrorDetails) *ProviderHTTPError {
|
||||||
|
return &ProviderHTTPError{
|
||||||
|
statusCode: statusCode,
|
||||||
|
providerCode: details.providerCode,
|
||||||
|
providerType: details.providerType,
|
||||||
|
providerMessage: details.providerMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func providerHTTPErrorFromBody(statusCode int, contentLength int64, body io.Reader) *ProviderHTTPError {
|
||||||
|
if contentLength > maxProviderErrorResponseBytes {
|
||||||
|
return newProviderHTTPError(statusCode, providerErrorDetails{})
|
||||||
|
}
|
||||||
|
|
||||||
|
limited := &io.LimitedReader{
|
||||||
|
R: body,
|
||||||
|
N: maxProviderErrorResponseBytes + 1,
|
||||||
|
}
|
||||||
|
contents, err := io.ReadAll(limited)
|
||||||
|
if err != nil || limited.N == 0 {
|
||||||
|
return newProviderHTTPError(statusCode, providerErrorDetails{})
|
||||||
|
}
|
||||||
|
return newProviderHTTPError(statusCode, parseProviderErrorEnvelope(contents))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseProviderErrorEnvelope(body []byte) providerErrorDetails {
|
||||||
|
decoder := json.NewDecoder(strings.NewReader(string(body)))
|
||||||
|
decoder.UseNumber()
|
||||||
|
|
||||||
|
var envelope map[string]json.RawMessage
|
||||||
|
if err := decoder.Decode(&envelope); err != nil {
|
||||||
|
return providerErrorDetails{}
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||||
|
return providerErrorDetails{}
|
||||||
|
}
|
||||||
|
|
||||||
|
rawError, ok := envelope["error"]
|
||||||
|
if !ok {
|
||||||
|
return providerErrorDetails{}
|
||||||
|
}
|
||||||
|
var providerError map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(rawError, &providerError); err != nil || providerError == nil {
|
||||||
|
return providerErrorDetails{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var details providerErrorDetails
|
||||||
|
if raw, ok := providerError["message"]; ok {
|
||||||
|
var value string
|
||||||
|
if json.Unmarshal(raw, &value) == nil {
|
||||||
|
details.providerMessage = normalizeProviderErrorMessage(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw, ok := providerError["type"]; ok {
|
||||||
|
var value string
|
||||||
|
if json.Unmarshal(raw, &value) == nil {
|
||||||
|
details.providerType = normalizeProviderErrorIdentifier(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if raw, ok := providerError["code"]; ok {
|
||||||
|
var value any
|
||||||
|
fieldDecoder := json.NewDecoder(strings.NewReader(string(raw)))
|
||||||
|
fieldDecoder.UseNumber()
|
||||||
|
if fieldDecoder.Decode(&value) == nil {
|
||||||
|
switch value := value.(type) {
|
||||||
|
case string:
|
||||||
|
details.providerCode = normalizeProviderErrorIdentifier(value)
|
||||||
|
case json.Number:
|
||||||
|
details.providerCode = normalizeProviderErrorIdentifier(value.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeProviderErrorIdentifier(value string) string {
|
||||||
|
normalized := normalizeProviderErrorText(value)
|
||||||
|
if len([]rune(normalized)) > maxProviderErrorIdentifierRunes {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeProviderErrorMessage(value string) string {
|
||||||
|
normalized := normalizeProviderErrorText(value)
|
||||||
|
runes := []rune(normalized)
|
||||||
|
if len(runes) <= maxProviderErrorMessageRunes {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
return string(runes[:maxProviderErrorMessageRunes-1]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeProviderErrorText(value string) string {
|
||||||
|
value = strings.ToValidUTF8(value, "<22>")
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
result.Grow(len(value))
|
||||||
|
separatorPending := false
|
||||||
|
for _, r := range value {
|
||||||
|
if unicode.IsSpace(r) || unicode.IsControl(r) || unicode.In(r, unicode.Cf) {
|
||||||
|
if result.Len() > 0 {
|
||||||
|
separatorPending = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if separatorPending {
|
||||||
|
result.WriteByte(' ')
|
||||||
|
separatorPending = false
|
||||||
|
}
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
255
internal/llm/provider_http_error_test.go
Normal file
255
internal/llm/provider_http_error_test.go
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
type guardedReader struct {
|
||||||
|
reader io.Reader
|
||||||
|
remaining int64
|
||||||
|
bytes int64
|
||||||
|
violated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *guardedReader) Read(buffer []byte) (int, error) {
|
||||||
|
if int64(len(buffer)) > r.remaining {
|
||||||
|
r.violated = true
|
||||||
|
return 0, errors.New("reader was read past its allowed boundary")
|
||||||
|
}
|
||||||
|
n, err := r.reader.Read(buffer)
|
||||||
|
r.bytes += int64(n)
|
||||||
|
r.remaining -= int64(n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type failingReader struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r failingReader) Read([]byte) (int, error) {
|
||||||
|
return 0, r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderHTTPErrorEnvelopeParsing(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
want providerErrorDetails
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "all supported string fields",
|
||||||
|
body: `{"error":{"message":"diagnostic","type":"invalid_request_error","code":"unsupported_parameter"}}`,
|
||||||
|
want: providerErrorDetails{providerMessage: "diagnostic", providerType: "invalid_request_error", providerCode: "unsupported_parameter"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "integer code",
|
||||||
|
body: `{"error":{"code":17}}`,
|
||||||
|
want: providerErrorDetails{providerCode: "17"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fractional code",
|
||||||
|
body: `{"error":{"code":1.25}}`,
|
||||||
|
want: providerErrorDetails{providerCode: "1.25"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exponent code",
|
||||||
|
body: `{"error":{"code":6.02e+23}}`,
|
||||||
|
want: providerErrorDetails{providerCode: "6.02e+23"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid fields do not discard valid fields",
|
||||||
|
body: `{"error":{"message":null,"type":"invalid_request_error","code":false}}`,
|
||||||
|
want: providerErrorDetails{providerType: "invalid_request_error"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown fields are ignored",
|
||||||
|
body: `{"trace":"do not retain","error":{"param":"temperature","metadata":{"secret":"x"}}}`,
|
||||||
|
want: providerErrorDetails{},
|
||||||
|
},
|
||||||
|
{name: "missing error", body: `{}`, want: providerErrorDetails{}},
|
||||||
|
{name: "null error", body: `{"error":null}`, want: providerErrorDetails{}},
|
||||||
|
{name: "scalar error", body: `{"error":"nope"}`, want: providerErrorDetails{}},
|
||||||
|
{name: "empty error", body: `{"error":{}}`, want: providerErrorDetails{}},
|
||||||
|
{name: "malformed", body: `{"error":`, want: providerErrorDetails{}},
|
||||||
|
{name: "truncated", body: `{"error":{"message":"x"`, want: providerErrorDetails{}},
|
||||||
|
{name: "trailing garbage", body: `{"error":{"message":"x"}} garbage`, want: providerErrorDetails{}},
|
||||||
|
{name: "second document", body: `{"error":{"message":"x"}} {}`, want: providerErrorDetails{}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := parseProviderErrorEnvelope([]byte(tc.body)); !reflect.DeepEqual(got, tc.want) {
|
||||||
|
t.Fatalf("parseProviderErrorEnvelope() = %#v, want %#v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderErrorTextNormalizationAndLimits(t *testing.T) {
|
||||||
|
validIdentifier := strings.Repeat("界", maxProviderErrorIdentifierRunes)
|
||||||
|
validMessage := strings.Repeat("界", maxProviderErrorMessageRunes)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
got string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "multibyte text", got: "Grüße 世界", want: "Grüße 世界"},
|
||||||
|
{name: "invalid UTF-8", got: string([]byte{'a', 0xff, 'b'}), want: "a<>b"},
|
||||||
|
{name: "whitespace control and format runs", got: " \n\talpha\x00\u200b\u200bbeta \r ", want: "alpha beta"},
|
||||||
|
{name: "blank normalization", got: "\t\u200b\n", want: ""},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := normalizeProviderErrorText(tc.got); got != tc.want {
|
||||||
|
t.Fatalf("normalizeProviderErrorText() = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := normalizeProviderErrorIdentifier(validIdentifier); got != validIdentifier {
|
||||||
|
t.Fatalf("exact identifier boundary = %q, want retained value", got)
|
||||||
|
}
|
||||||
|
if got := normalizeProviderErrorIdentifier(validIdentifier + "界"); got != "" {
|
||||||
|
t.Fatalf("overlong identifier = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := normalizeProviderErrorMessage(validMessage); got != validMessage {
|
||||||
|
t.Fatalf("exact message boundary = %q, want retained value", got)
|
||||||
|
}
|
||||||
|
wantTruncatedMessage := strings.Repeat("界", maxProviderErrorMessageRunes-1) + "…"
|
||||||
|
if got := normalizeProviderErrorMessage(validMessage + "界"); got != wantTruncatedMessage {
|
||||||
|
t.Fatalf("overlong message length = %d, want %d", utf8.RuneCountInString(got), maxProviderErrorMessageRunes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderHTTPErrorIdentityAndFormatting(t *testing.T) {
|
||||||
|
const marker = "provider-secret-marker"
|
||||||
|
err := newProviderHTTPError(429, providerErrorDetails{
|
||||||
|
providerCode: marker + "-code",
|
||||||
|
providerType: marker + "-type",
|
||||||
|
providerMessage: marker + "-message",
|
||||||
|
})
|
||||||
|
|
||||||
|
if err.StatusCode() != 429 || err.ProviderCode() != marker+"-code" || err.ProviderType() != marker+"-type" || err.ProviderMessage() != marker+"-message" {
|
||||||
|
t.Fatalf("accessors returned unexpected values: %#v", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrUnexpectedStatus) {
|
||||||
|
t.Fatalf("errors.Is(%v, ErrUnexpectedStatus) = false", err)
|
||||||
|
}
|
||||||
|
for _, rendered := range []string{fmt.Sprintf("%v", err), fmt.Sprintf("%+v", err), fmt.Sprintf("%#v", err)} {
|
||||||
|
if rendered != "llm returned non-success status: status=429" {
|
||||||
|
t.Fatalf("formatted error = %q", rendered)
|
||||||
|
}
|
||||||
|
if strings.Contains(rendered, marker) {
|
||||||
|
t.Fatalf("formatted error exposed provider marker: %q", rendered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var nilError *ProviderHTTPError
|
||||||
|
if nilError.StatusCode() != 0 || nilError.ProviderCode() != "" || nilError.ProviderType() != "" || nilError.ProviderMessage() != "" {
|
||||||
|
t.Fatal("nil accessors returned provider values")
|
||||||
|
}
|
||||||
|
if nilError.Error() != "llm returned non-success status" || nilError.GoString() != "llm returned non-success status" || !errors.Is(nilError, ErrUnexpectedStatus) {
|
||||||
|
t.Fatalf("nil error behavior is not safe: %v", nilError)
|
||||||
|
}
|
||||||
|
|
||||||
|
zero := &ProviderHTTPError{}
|
||||||
|
if zero.Error() != "llm returned non-success status" || zero.GoString() != "llm returned non-success status" || !errors.Is(zero, ErrUnexpectedStatus) {
|
||||||
|
t.Fatalf("zero error behavior is not safe: %v", zero)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProviderHTTPErrorBodyBounds(t *testing.T) {
|
||||||
|
const (
|
||||||
|
statusCode = 502
|
||||||
|
marker = "provider-body-marker"
|
||||||
|
)
|
||||||
|
|
||||||
|
ordinaryBody := `{"error":{"message":"` + marker + `"}}`
|
||||||
|
exactLimitBody := ordinaryBody + strings.Repeat(" ", int(maxProviderErrorResponseBytes)-len(ordinaryBody))
|
||||||
|
overLimitBody := ordinaryBody + strings.Repeat(" ", int(maxProviderErrorResponseBytes)+1-len(ordinaryBody))
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
contentLength int64
|
||||||
|
reader io.Reader
|
||||||
|
wantRead int64
|
||||||
|
wantMessage string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "recognized envelope",
|
||||||
|
contentLength: int64(len(ordinaryBody)),
|
||||||
|
reader: strings.NewReader(ordinaryBody),
|
||||||
|
wantRead: int64(len(ordinaryBody)),
|
||||||
|
wantMessage: marker,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact limit",
|
||||||
|
contentLength: maxProviderErrorResponseBytes,
|
||||||
|
reader: strings.NewReader(exactLimitBody),
|
||||||
|
wantRead: maxProviderErrorResponseBytes,
|
||||||
|
wantMessage: marker,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "declared oversize does not read",
|
||||||
|
contentLength: maxProviderErrorResponseBytes + 1,
|
||||||
|
reader: strings.NewReader(ordinaryBody),
|
||||||
|
wantRead: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown length oversize",
|
||||||
|
contentLength: -1,
|
||||||
|
reader: strings.NewReader(overLimitBody),
|
||||||
|
wantRead: maxProviderErrorResponseBytes + 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "underreported oversize",
|
||||||
|
contentLength: maxProviderErrorResponseBytes,
|
||||||
|
reader: strings.NewReader(overLimitBody),
|
||||||
|
wantRead: maxProviderErrorResponseBytes + 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read failure",
|
||||||
|
contentLength: -1,
|
||||||
|
reader: failingReader{err: errors.New("read failure")},
|
||||||
|
wantRead: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty body",
|
||||||
|
contentLength: 0,
|
||||||
|
reader: strings.NewReader(""),
|
||||||
|
wantRead: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
reader := &guardedReader{
|
||||||
|
reader: tc.reader,
|
||||||
|
remaining: maxProviderErrorResponseBytes + 1,
|
||||||
|
}
|
||||||
|
err := providerHTTPErrorFromBody(statusCode, tc.contentLength, reader)
|
||||||
|
if err == nil || err.StatusCode() != statusCode {
|
||||||
|
t.Fatalf("error status = %v, want %d", err, statusCode)
|
||||||
|
}
|
||||||
|
if reader.bytes != tc.wantRead {
|
||||||
|
t.Fatalf("body bytes read = %d, want %d", reader.bytes, tc.wantRead)
|
||||||
|
}
|
||||||
|
if reader.violated {
|
||||||
|
t.Fatal("body reader was asked to read beyond the overflow probe")
|
||||||
|
}
|
||||||
|
if got := err.ProviderMessage(); got != tc.wantMessage {
|
||||||
|
t.Fatalf("provider message = %q, want %q", got, tc.wantMessage)
|
||||||
|
}
|
||||||
|
if tc.wantMessage == "" {
|
||||||
|
if err.ProviderCode() != "" || err.ProviderType() != "" || strings.Contains(err.Error(), marker) {
|
||||||
|
t.Fatalf("discarded details were retained: %#v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
145
internal/llm/provider_http_error_transport_test.go
Normal file
145
internal/llm/provider_http_error_transport_test.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpenAICompatibleClientStructuredNonSuccessResponse(t *testing.T) {
|
||||||
|
body := `{"error":{"message":" provider\nmessage\u200b","type":"invalid\ttype","code":1.5e+4}}`
|
||||||
|
responseBody := &countingReadCloser{reader: strings.NewReader(body)}
|
||||||
|
client := newNonSuccessResponseClient(t, http.StatusBadRequest, int64(len(body)), responseBody)
|
||||||
|
|
||||||
|
response, err := client.Generate(context.Background(), ordinaryGenerateRequest())
|
||||||
|
if response != nil {
|
||||||
|
t.Fatalf("response = %#v, want nil", response)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrUnexpectedStatus) {
|
||||||
|
t.Fatalf("errors.Is(%v, ErrUnexpectedStatus) = false", err)
|
||||||
|
}
|
||||||
|
var providerHTTPError *ProviderHTTPError
|
||||||
|
if !errors.As(err, &providerHTTPError) {
|
||||||
|
t.Fatalf("error = %T, want *ProviderHTTPError", err)
|
||||||
|
}
|
||||||
|
if providerHTTPError.StatusCode() != http.StatusBadRequest || providerHTTPError.ProviderCode() != "1.5e+4" || providerHTTPError.ProviderType() != "invalid type" || providerHTTPError.ProviderMessage() != "provider message" {
|
||||||
|
t.Fatalf("provider error = %#v", providerHTTPError)
|
||||||
|
}
|
||||||
|
if !responseBody.closed {
|
||||||
|
t.Fatal("non-success response body was not closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAICompatibleClientNonSuccessBodyOwnership(t *testing.T) {
|
||||||
|
const marker = "provider-body-marker"
|
||||||
|
normalBody := `{"error":{"message":"` + marker + `"}}`
|
||||||
|
overLimitBody := normalBody + strings.Repeat(" ", int(maxProviderErrorResponseBytes)+1-len(normalBody))
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
contentLength int64
|
||||||
|
reader io.Reader
|
||||||
|
wantRead int64
|
||||||
|
wantMessage string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "normal",
|
||||||
|
contentLength: int64(len(normalBody)),
|
||||||
|
reader: strings.NewReader(normalBody),
|
||||||
|
wantRead: int64(len(normalBody)),
|
||||||
|
wantMessage: marker,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "declared oversize",
|
||||||
|
contentLength: maxProviderErrorResponseBytes + 1,
|
||||||
|
reader: strings.NewReader(normalBody),
|
||||||
|
wantRead: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "streamed oversize",
|
||||||
|
contentLength: -1,
|
||||||
|
reader: &guardedReader{
|
||||||
|
reader: strings.NewReader(overLimitBody),
|
||||||
|
remaining: maxProviderErrorResponseBytes + 1,
|
||||||
|
},
|
||||||
|
wantRead: maxProviderErrorResponseBytes + 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "underreported oversize",
|
||||||
|
contentLength: maxProviderErrorResponseBytes,
|
||||||
|
reader: &guardedReader{
|
||||||
|
reader: strings.NewReader(overLimitBody),
|
||||||
|
remaining: maxProviderErrorResponseBytes + 1,
|
||||||
|
},
|
||||||
|
wantRead: maxProviderErrorResponseBytes + 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed",
|
||||||
|
contentLength: 1,
|
||||||
|
reader: strings.NewReader("{"),
|
||||||
|
wantRead: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read failure",
|
||||||
|
contentLength: -1,
|
||||||
|
reader: failingReader{err: errors.New("response read failed")},
|
||||||
|
wantRead: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
body := &countingReadCloser{reader: tc.reader}
|
||||||
|
client := newNonSuccessResponseClient(t, http.StatusBadGateway, tc.contentLength, body)
|
||||||
|
|
||||||
|
response, err := client.Generate(context.Background(), ordinaryGenerateRequest())
|
||||||
|
if response != nil {
|
||||||
|
t.Fatalf("response = %#v, want nil", response)
|
||||||
|
}
|
||||||
|
var providerHTTPError *ProviderHTTPError
|
||||||
|
if !errors.As(err, &providerHTTPError) {
|
||||||
|
t.Fatalf("error = %T, want *ProviderHTTPError", err)
|
||||||
|
}
|
||||||
|
if !body.closed {
|
||||||
|
t.Fatal("response body was not closed")
|
||||||
|
}
|
||||||
|
if body.bytesRead != tc.wantRead {
|
||||||
|
t.Fatalf("body bytes read = %d, want %d", body.bytesRead, tc.wantRead)
|
||||||
|
}
|
||||||
|
if body.bytesRead > maxProviderErrorResponseBytes+1 {
|
||||||
|
t.Fatalf("body bytes read = %d, exceeds overflow probe", body.bytesRead)
|
||||||
|
}
|
||||||
|
if guarded, ok := tc.reader.(*guardedReader); ok && guarded.violated {
|
||||||
|
t.Fatal("body reader was asked to read beyond the overflow probe")
|
||||||
|
}
|
||||||
|
if got := providerHTTPError.ProviderMessage(); got != tc.wantMessage {
|
||||||
|
t.Fatalf("provider message = %q, want %q", got, tc.wantMessage)
|
||||||
|
}
|
||||||
|
if tc.wantMessage == "" && (providerHTTPError.ProviderCode() != "" || providerHTTPError.ProviderType() != "" || strings.Contains(providerHTTPError.Error(), marker)) {
|
||||||
|
t.Fatalf("discarded details were retained: %#v", providerHTTPError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newNonSuccessResponseClient(t *testing.T, statusCode int, contentLength int64, body io.ReadCloser) *OpenAICompatibleClient {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
||||||
|
BaseURL: "https://provider.example/v1",
|
||||||
|
Model: "m",
|
||||||
|
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: statusCode,
|
||||||
|
ContentLength: contentLength,
|
||||||
|
Body: body,
|
||||||
|
}, nil
|
||||||
|
})},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construct client: %v", err)
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
318
internal/observation/recorder.go
Normal file
318
internal/observation/recorder.go
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
// Package observation owns invocation-local execution identities, metadata
|
||||||
|
// accounting, and synchronous event delivery.
|
||||||
|
package observation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Recorder accumulates one execution's measured attempts and usage. Its owner
|
||||||
|
// calls methods in execution order; it has no shared history or background work.
|
||||||
|
type Recorder struct {
|
||||||
|
runID string
|
||||||
|
startedAt time.Time
|
||||||
|
now func() time.Time
|
||||||
|
observer domain.ExecutionObserver
|
||||||
|
selection Selection
|
||||||
|
attempts uint64
|
||||||
|
retries uint64
|
||||||
|
repairCalls uint64
|
||||||
|
lastRepairGeneration uint64
|
||||||
|
finished bool
|
||||||
|
capacityWait time.Duration
|
||||||
|
generationDuration time.Duration
|
||||||
|
retryDelay time.Duration
|
||||||
|
usage usageTotals
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection contains resolved identities only. Unresolved fields stay empty.
|
||||||
|
type Selection struct {
|
||||||
|
PromptID string
|
||||||
|
PromptVersion string
|
||||||
|
ProfileID string
|
||||||
|
BackendID string
|
||||||
|
Model string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttemptData describes one completed active-permit acquisition and optional
|
||||||
|
// generation dispatch. Non-dispatched attempts contribute no token usage.
|
||||||
|
type AttemptData struct {
|
||||||
|
Generation uint64
|
||||||
|
Attempt uint64
|
||||||
|
Dispatched bool
|
||||||
|
CapacityWait time.Duration
|
||||||
|
GenerationDuration time.Duration
|
||||||
|
Outcome domain.ExecutionOutcome
|
||||||
|
Failure domain.ExecutionFailureKind
|
||||||
|
StatusCode int
|
||||||
|
Usage domain.TokenUsage
|
||||||
|
UsagePresence *domain.TokenUsagePresence
|
||||||
|
}
|
||||||
|
|
||||||
|
// DelayData describes one completed or interrupted retry wait.
|
||||||
|
type DelayData struct {
|
||||||
|
Generation uint64
|
||||||
|
Attempt uint64
|
||||||
|
Elapsed time.Duration
|
||||||
|
Failure domain.ExecutionFailureKind
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishData describes the terminal outcome after resource release.
|
||||||
|
type FinishData struct {
|
||||||
|
Outcome domain.ExecutionOutcome
|
||||||
|
Failure domain.ExecutionFailureKind
|
||||||
|
StatusCode int
|
||||||
|
RetriesExhausted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates an invocation-local recorder and run ID. It returns the recorder
|
||||||
|
// even if ID creation fails, allowing an error terminal event with an empty ID.
|
||||||
|
func New(observer domain.ExecutionObserver) (*Recorder, error) {
|
||||||
|
runID, err := NewRunID()
|
||||||
|
return newRecorder(runID, time.Now, observer), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRecorder(runID string, now func() time.Time, observer domain.ExecutionObserver) *Recorder {
|
||||||
|
return &Recorder{runID: runID, startedAt: now(), now: now, observer: observer,
|
||||||
|
usage: newUsageTotals()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRunID creates the identity shared by observations and successful results.
|
||||||
|
func NewRunID() (string, error) {
|
||||||
|
var b [16]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
b[6] = (b[6] & 0x0f) | 0x40
|
||||||
|
b[8] = (b[8] & 0x3f) | 0x80
|
||||||
|
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunID returns this invocation's identity.
|
||||||
|
func (r *Recorder) RunID() string { return r.runID }
|
||||||
|
|
||||||
|
// Start delivers the opening event with only identities already resolved.
|
||||||
|
func (r *Recorder) Start() {
|
||||||
|
event := r.event(domain.ExecutionStarted)
|
||||||
|
r.deliver(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSelection replaces the known resolved identities for later events.
|
||||||
|
func (r *Recorder) UpdateSelection(selection Selection) { r.selection = selection }
|
||||||
|
|
||||||
|
// Attempt records one completed acquisition and optional dispatch.
|
||||||
|
func (r *Recorder) Attempt(data AttemptData) {
|
||||||
|
r.capacityWait = addDuration(r.capacityWait, data.CapacityWait)
|
||||||
|
if data.Dispatched {
|
||||||
|
r.attempts++
|
||||||
|
if data.Attempt > 1 {
|
||||||
|
r.retries++
|
||||||
|
}
|
||||||
|
if data.Generation > 1 && data.Generation != r.lastRepairGeneration {
|
||||||
|
r.repairCalls++
|
||||||
|
r.lastRepairGeneration = data.Generation
|
||||||
|
}
|
||||||
|
r.generationDuration = addDuration(r.generationDuration, data.GenerationDuration)
|
||||||
|
r.usage.add(data.Usage, data.UsagePresence)
|
||||||
|
}
|
||||||
|
event := r.event(domain.ExecutionAttempt)
|
||||||
|
event.Generation = data.Generation
|
||||||
|
event.Attempt = data.Attempt
|
||||||
|
event.Dispatched = data.Dispatched
|
||||||
|
event.CapacityWait = data.CapacityWait
|
||||||
|
if data.Dispatched {
|
||||||
|
event.GenerationDuration = data.GenerationDuration
|
||||||
|
}
|
||||||
|
event.Outcome = data.Outcome
|
||||||
|
event.Failure = data.Failure
|
||||||
|
event.StatusCode = data.StatusCode
|
||||||
|
if data.Dispatched {
|
||||||
|
event.Usage = observedUsage(data.Usage, data.UsagePresence)
|
||||||
|
}
|
||||||
|
r.deliver(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay records the measured time spent waiting after one failed attempt.
|
||||||
|
func (r *Recorder) Delay(data DelayData) {
|
||||||
|
r.retryDelay = addDuration(r.retryDelay, data.Elapsed)
|
||||||
|
event := r.event(domain.ExecutionRetryDelay)
|
||||||
|
event.Generation = data.Generation
|
||||||
|
event.Attempt = data.Attempt
|
||||||
|
event.RetryDelay = data.Elapsed
|
||||||
|
event.Failure = data.Failure
|
||||||
|
r.deliver(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish delivers terminal totals once on a normal return. Its caller owns the
|
||||||
|
// operation's resource cleanup and failure classification.
|
||||||
|
func (r *Recorder) Finish(data FinishData) {
|
||||||
|
if r.finished {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.finished = true
|
||||||
|
event := r.event(domain.ExecutionFinished)
|
||||||
|
event.Outcome = data.Outcome
|
||||||
|
event.Failure = data.Failure
|
||||||
|
event.StatusCode = data.StatusCode
|
||||||
|
event.RetriesExhausted = data.RetriesExhausted
|
||||||
|
event.Attempts = r.attempts
|
||||||
|
event.TransientRetries = r.retries
|
||||||
|
event.RepairCalls = r.repairCalls
|
||||||
|
event.CapacityWait = r.capacityWait
|
||||||
|
event.GenerationDuration = r.generationDuration
|
||||||
|
event.RetryDelay = r.retryDelay
|
||||||
|
event.Duration = event.Time.Sub(r.startedAt)
|
||||||
|
if event.Duration < 0 {
|
||||||
|
event.Duration = 0
|
||||||
|
}
|
||||||
|
event.Usage = r.usage.snapshot(r.attempts)
|
||||||
|
r.deliver(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Recorder) event(kind domain.ExecutionEventKind) domain.ExecutionEvent {
|
||||||
|
return domain.ExecutionEvent{
|
||||||
|
Kind: kind, RunID: r.runID, Time: r.now(), PromptID: r.selection.PromptID,
|
||||||
|
PromptVersion: r.selection.PromptVersion, ProfileID: r.selection.ProfileID,
|
||||||
|
BackendID: r.selection.BackendID, Model: r.selection.Model,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Recorder) deliver(event domain.ExecutionEvent) {
|
||||||
|
if r.observer != nil {
|
||||||
|
r.observer(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type contextKey struct{}
|
||||||
|
|
||||||
|
// WithoutExecutionState starts a public operation without inheriting another
|
||||||
|
// invocation's recorder or prepared-claim sink. Other context values and all
|
||||||
|
// cancellation/deadline behavior remain with the caller's context.
|
||||||
|
func WithoutExecutionState(ctx context.Context) context.Context {
|
||||||
|
return executionBoundary{Context: ctx}
|
||||||
|
}
|
||||||
|
|
||||||
|
type executionBoundary struct{ context.Context }
|
||||||
|
|
||||||
|
func (c executionBoundary) Value(key any) any {
|
||||||
|
switch key.(type) {
|
||||||
|
case contextKey, claimSinkKey:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return c.Context.Value(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRecorder carries one invocation's recorder through internal client calls.
|
||||||
|
func WithRecorder(ctx context.Context, recorder *Recorder) context.Context {
|
||||||
|
return context.WithValue(ctx, contextKey{}, recorder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromContext returns the invocation-local recorder, if one was installed.
|
||||||
|
func FromContext(ctx context.Context) *Recorder {
|
||||||
|
recorder, _ := ctx.Value(contextKey{}).(*Recorder)
|
||||||
|
return recorder
|
||||||
|
}
|
||||||
|
|
||||||
|
type claimSinkKey struct{}
|
||||||
|
|
||||||
|
// WithClaimSink lets the facade learn which invocation actually claimed a
|
||||||
|
// prepared handle, so it can emit the terminal event after public mapping.
|
||||||
|
func WithClaimSink(ctx context.Context, sink **Recorder) context.Context {
|
||||||
|
return context.WithValue(ctx, claimSinkKey{}, sink)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterClaim records an invocation after its prepared handle is claimed.
|
||||||
|
func RegisterClaim(ctx context.Context, recorder *Recorder) {
|
||||||
|
if sink, ok := ctx.Value(claimSinkKey{}).(**Recorder); ok && sink != nil {
|
||||||
|
*sink = recorder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type usageField struct {
|
||||||
|
value int
|
||||||
|
known, complete bool
|
||||||
|
}
|
||||||
|
type usageTotals struct {
|
||||||
|
prompt, completion, total, cached, cacheWrite usageField
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUsageTotals() usageTotals {
|
||||||
|
return usageTotals{prompt: usageField{complete: true}, completion: usageField{complete: true},
|
||||||
|
total: usageField{complete: true}, cached: usageField{complete: true}, cacheWrite: usageField{complete: true}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *usageTotals) add(usage domain.TokenUsage, presence *domain.TokenUsagePresence) {
|
||||||
|
known := usagePresence(usage, presence)
|
||||||
|
u.prompt.add(usage.PromptTokens, known.PromptTokens)
|
||||||
|
u.completion.add(usage.CompletionTokens, known.CompletionTokens)
|
||||||
|
u.total.add(usage.TotalTokens, known.TotalTokens)
|
||||||
|
u.cached.add(usage.CachedTokens, known.CachedTokens)
|
||||||
|
u.cacheWrite.add(usage.CacheWriteTokens, known.CacheWriteTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u usageTotals) snapshot(attempts uint64) domain.ObservedTokenUsage {
|
||||||
|
return domain.ObservedTokenUsage{
|
||||||
|
PromptTokens: u.prompt.snapshot(attempts), CompletionTokens: u.completion.snapshot(attempts),
|
||||||
|
TotalTokens: u.total.snapshot(attempts), CachedTokens: u.cached.snapshot(attempts),
|
||||||
|
CacheWriteTokens: u.cacheWrite.snapshot(attempts),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *usageField) add(value int, known bool) {
|
||||||
|
if known {
|
||||||
|
f.value += value
|
||||||
|
f.known = true
|
||||||
|
} else {
|
||||||
|
f.complete = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f usageField) snapshot(attempts uint64) domain.ObservedTokenCount {
|
||||||
|
if attempts == 0 {
|
||||||
|
return domain.ObservedTokenCount{Known: true, Complete: true}
|
||||||
|
}
|
||||||
|
return domain.ObservedTokenCount{Value: f.value, Known: f.known, Complete: f.complete}
|
||||||
|
}
|
||||||
|
|
||||||
|
func observedUsage(usage domain.TokenUsage, presence *domain.TokenUsagePresence) domain.ObservedTokenUsage {
|
||||||
|
known := usagePresence(usage, presence)
|
||||||
|
return domain.ObservedTokenUsage{
|
||||||
|
PromptTokens: observedCount(usage.PromptTokens, known.PromptTokens),
|
||||||
|
CompletionTokens: observedCount(usage.CompletionTokens, known.CompletionTokens),
|
||||||
|
TotalTokens: observedCount(usage.TotalTokens, known.TotalTokens),
|
||||||
|
CachedTokens: observedCount(usage.CachedTokens, known.CachedTokens),
|
||||||
|
CacheWriteTokens: observedCount(usage.CacheWriteTokens, known.CacheWriteTokens),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func usagePresence(usage domain.TokenUsage, presence *domain.TokenUsagePresence) domain.TokenUsagePresence {
|
||||||
|
if presence != nil {
|
||||||
|
return *presence
|
||||||
|
}
|
||||||
|
return domain.TokenUsagePresence{
|
||||||
|
PromptTokens: usage.PromptTokens != 0, CompletionTokens: usage.CompletionTokens != 0,
|
||||||
|
TotalTokens: usage.TotalTokens != 0, CachedTokens: usage.CachedTokens != 0,
|
||||||
|
CacheWriteTokens: usage.CacheWriteTokens != 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func observedCount(value int, known bool) domain.ObservedTokenCount {
|
||||||
|
if !known {
|
||||||
|
return domain.ObservedTokenCount{}
|
||||||
|
}
|
||||||
|
return domain.ObservedTokenCount{Value: value, Known: true, Complete: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addDuration(total, next time.Duration) time.Duration {
|
||||||
|
if next > 0 && total > time.Duration(math.MaxInt64)-next {
|
||||||
|
return time.Duration(math.MaxInt64)
|
||||||
|
}
|
||||||
|
return total + next
|
||||||
|
}
|
||||||
159
internal/observation/recorder_test.go
Normal file
159
internal/observation/recorder_test.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package observation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExecutionBoundaryPreservesCallerContext(t *testing.T) {
|
||||||
|
type callerKey struct{}
|
||||||
|
parent, stop := context.WithTimeout(context.WithValue(context.Background(), callerKey{}, "caller"), time.Minute)
|
||||||
|
defer stop()
|
||||||
|
parent, cancel := context.WithCancelCause(parent)
|
||||||
|
defer cancel(nil)
|
||||||
|
recorder := newRecorder("outer", time.Now, nil)
|
||||||
|
sink := recorder
|
||||||
|
parent = WithClaimSink(WithRecorder(parent, recorder), &sink)
|
||||||
|
child := WithoutExecutionState(parent)
|
||||||
|
if FromContext(child) != nil || FromContext(parent) != recorder {
|
||||||
|
t.Fatal("execution boundary did not isolate recorder state")
|
||||||
|
}
|
||||||
|
RegisterClaim(child, nil)
|
||||||
|
if sink != recorder {
|
||||||
|
t.Fatal("execution boundary retained the parent claim sink")
|
||||||
|
}
|
||||||
|
wantDeadline, _ := parent.Deadline()
|
||||||
|
gotDeadline, ok := child.Deadline()
|
||||||
|
if !ok || gotDeadline != wantDeadline || child.Value(callerKey{}) != "caller" || child.Done() != parent.Done() {
|
||||||
|
t.Fatal("execution boundary changed caller context")
|
||||||
|
}
|
||||||
|
cause := errors.New("caller canceled")
|
||||||
|
cancel(cause)
|
||||||
|
if !errors.Is(child.Err(), context.Canceled) || context.Cause(child) != cause {
|
||||||
|
t.Fatal("execution boundary lost cancellation or its cause")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecorderAccountsDispatchedAttemptsAndReportedUsage(t *testing.T) {
|
||||||
|
start := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC)
|
||||||
|
tick := 0
|
||||||
|
now := func() time.Time {
|
||||||
|
value := start.Add(time.Duration(tick) * time.Second)
|
||||||
|
tick++
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
var events []domain.ExecutionEvent
|
||||||
|
recorder := newRecorder("run-1", now, func(event domain.ExecutionEvent) { events = append(events, event) })
|
||||||
|
recorder.Start()
|
||||||
|
recorder.UpdateSelection(Selection{PromptID: "prompt", PromptVersion: "v1", ProfileID: "profile", BackendID: "backend", Model: "model"})
|
||||||
|
recorder.Attempt(AttemptData{
|
||||||
|
Generation: 1, Attempt: 1, Dispatched: true,
|
||||||
|
CapacityWait: 2 * time.Millisecond, GenerationDuration: 10 * time.Millisecond,
|
||||||
|
Outcome: domain.ExecutionOutcomeSuccess,
|
||||||
|
UsagePresence: &domain.TokenUsagePresence{PromptTokens: true},
|
||||||
|
})
|
||||||
|
recorder.Delay(DelayData{Generation: 1, Attempt: 1, Elapsed: 7 * time.Millisecond})
|
||||||
|
recorder.Attempt(AttemptData{
|
||||||
|
Generation: 1, Attempt: 2, Dispatched: true,
|
||||||
|
CapacityWait: 3 * time.Millisecond, GenerationDuration: 20 * time.Millisecond,
|
||||||
|
Outcome: domain.ExecutionOutcomeSuccess,
|
||||||
|
Usage: domain.TokenUsage{PromptTokens: 2, TotalTokens: 5},
|
||||||
|
UsagePresence: &domain.TokenUsagePresence{PromptTokens: true, TotalTokens: true},
|
||||||
|
})
|
||||||
|
recorder.Attempt(AttemptData{
|
||||||
|
Generation: 2, Attempt: 1, Dispatched: false,
|
||||||
|
CapacityWait: 4 * time.Millisecond, GenerationDuration: 99 * time.Millisecond,
|
||||||
|
Outcome: domain.ExecutionOutcomeError, Failure: domain.ExecutionFailureCanceled,
|
||||||
|
Usage: domain.TokenUsage{PromptTokens: 99},
|
||||||
|
})
|
||||||
|
recorder.Attempt(AttemptData{
|
||||||
|
Generation: 2, Attempt: 1, Dispatched: true,
|
||||||
|
CapacityWait: 5 * time.Millisecond, GenerationDuration: 30 * time.Millisecond,
|
||||||
|
Outcome: domain.ExecutionOutcomeError, Failure: domain.ExecutionFailureProviderStatus,
|
||||||
|
StatusCode: 503, Usage: domain.TokenUsage{PromptTokens: 3},
|
||||||
|
})
|
||||||
|
recorder.Finish(FinishData{Outcome: domain.ExecutionOutcomeError, Failure: domain.ExecutionFailureProviderStatus, StatusCode: 503, RetriesExhausted: true})
|
||||||
|
recorder.Finish(FinishData{})
|
||||||
|
|
||||||
|
if len(events) != 7 {
|
||||||
|
t.Fatalf("event count=%d", len(events))
|
||||||
|
}
|
||||||
|
if events[0].PromptID != "" || events[1].PromptID != "prompt" || events[0].RunID != "run-1" || events[1].RunID != "run-1" {
|
||||||
|
t.Fatalf("identity snapshots=%+v, %+v", events[0], events[1])
|
||||||
|
}
|
||||||
|
if events[1].Usage.PromptTokens != (domain.ObservedTokenCount{Known: true, Complete: true}) ||
|
||||||
|
events[4].Dispatched || events[4].GenerationDuration != 0 || events[4].Usage.PromptTokens.Known {
|
||||||
|
t.Fatalf("attempt usage or undispatched event=%+v, %+v", events[1], events[4])
|
||||||
|
}
|
||||||
|
last := events[len(events)-1]
|
||||||
|
if last.Attempts != 3 || last.TransientRetries != 1 || last.RepairCalls != 1 ||
|
||||||
|
last.CapacityWait != 14*time.Millisecond || last.GenerationDuration != 60*time.Millisecond ||
|
||||||
|
last.RetryDelay != 7*time.Millisecond || last.Duration != 7*time.Second {
|
||||||
|
t.Fatalf("terminal counters=%+v", last)
|
||||||
|
}
|
||||||
|
if last.Usage.PromptTokens != (domain.ObservedTokenCount{Value: 5, Known: true, Complete: true}) ||
|
||||||
|
last.Usage.TotalTokens != (domain.ObservedTokenCount{Value: 5, Known: true, Complete: false}) ||
|
||||||
|
last.Usage.CachedTokens != (domain.ObservedTokenCount{}) {
|
||||||
|
t.Fatalf("aggregate usage=%+v", last.Usage)
|
||||||
|
}
|
||||||
|
if last.Failure != domain.ExecutionFailureProviderStatus || !last.RetriesExhausted || last.StatusCode != 503 {
|
||||||
|
t.Fatalf("terminal failure=%+v", last)
|
||||||
|
}
|
||||||
|
if events[1].Attempts != 0 || events[1].TransientRetries != 0 || events[1].RepairCalls != 0 {
|
||||||
|
t.Fatalf("attempt event carried terminal totals=%+v", events[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecorderZeroAttemptsAndExplicitUnknownUsage(t *testing.T) {
|
||||||
|
var events []domain.ExecutionEvent
|
||||||
|
recorder := newRecorder("", time.Now, func(event domain.ExecutionEvent) { events = append(events, event) })
|
||||||
|
recorder.Finish(FinishData{Outcome: domain.ExecutionOutcomeError, Failure: domain.ExecutionFailureOther})
|
||||||
|
if len(events) != 1 || events[0].RunID != "" || events[0].Attempts != 0 ||
|
||||||
|
events[0].Usage.PromptTokens != (domain.ObservedTokenCount{Known: true, Complete: true}) ||
|
||||||
|
events[0].Usage.CacheWriteTokens != (domain.ObservedTokenCount{Known: true, Complete: true}) {
|
||||||
|
t.Fatalf("zero-attempt terminal=%+v", events)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder = newRecorder("run-2", time.Now, func(event domain.ExecutionEvent) { events = append(events, event) })
|
||||||
|
recorder.Attempt(AttemptData{Dispatched: true, Generation: 1, Attempt: 1,
|
||||||
|
Usage: domain.TokenUsage{PromptTokens: 7}, UsagePresence: &domain.TokenUsagePresence{}})
|
||||||
|
recorder.Finish(FinishData{Outcome: domain.ExecutionOutcomeSuccess})
|
||||||
|
last := events[len(events)-1]
|
||||||
|
if last.Usage.PromptTokens != (domain.ObservedTokenCount{}) {
|
||||||
|
t.Fatalf("explicitly unknown nonzero field=%+v", last.Usage.PromptTokens)
|
||||||
|
}
|
||||||
|
_ = newRecorder("run-3", time.Now, nil) // A nil callback still permits recording.
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecorderOrderContextAndCallbackPanic(t *testing.T) {
|
||||||
|
var kinds []domain.ExecutionEventKind
|
||||||
|
recorder := newRecorder("run", time.Now, func(event domain.ExecutionEvent) { kinds = append(kinds, event.Kind) })
|
||||||
|
ctx := WithRecorder(context.Background(), recorder)
|
||||||
|
if FromContext(ctx) != recorder || FromContext(context.Background()) != nil {
|
||||||
|
t.Fatal("recorder context propagation failed")
|
||||||
|
}
|
||||||
|
recorder.Start()
|
||||||
|
recorder.Attempt(AttemptData{Generation: 1, Attempt: 1})
|
||||||
|
recorder.Delay(DelayData{Generation: 1, Attempt: 1})
|
||||||
|
recorder.Finish(FinishData{Outcome: domain.ExecutionOutcomeSuccess})
|
||||||
|
if !reflect.DeepEqual(kinds, []domain.ExecutionEventKind{
|
||||||
|
domain.ExecutionStarted, domain.ExecutionAttempt, domain.ExecutionRetryDelay, domain.ExecutionFinished,
|
||||||
|
}) {
|
||||||
|
t.Fatalf("callback order=%v", kinds)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder = newRecorder("panic", time.Now, func(domain.ExecutionEvent) { panic("observer panic") })
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Fatal("observer panic was swallowed")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
recorder.Start()
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
id: aion-2
|
|
||||||
backend: openrouter
|
|
||||||
model: aion-labs/aion-2.0
|
|
||||||
temperature: 0.72
|
|
||||||
reasoning_effort: high
|
|
||||||
top_p: 0.95
|
|
||||||
timeout_seconds: 180
|
|
||||||
service_tier: flex
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
id: claude-fable-latest
|
|
||||||
backend: openrouter
|
|
||||||
model: "~anthropic/claude-fable-latest"
|
|
||||||
reasoning_effort: high
|
|
||||||
timeout_seconds: 600
|
|
||||||
service_tier: flex
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
id: claude-haiku-latest
|
|
||||||
backend: openrouter
|
|
||||||
model: "~anthropic/claude-haiku-latest"
|
|
||||||
reasoning_effort: medium
|
|
||||||
timeout_seconds: 240
|
|
||||||
service_tier: flex
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
id: claude-opus-latest
|
|
||||||
backend: openrouter
|
|
||||||
model: "~anthropic/claude-opus-latest"
|
|
||||||
reasoning_effort: high
|
|
||||||
timeout_seconds: 240
|
|
||||||
service_tier: flex
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
id: claude-sonnet-latest
|
|
||||||
backend: openrouter
|
|
||||||
model: "~anthropic/claude-sonnet-latest"
|
|
||||||
reasoning_effort: high
|
|
||||||
timeout_seconds: 240
|
|
||||||
service_tier: flex
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
id: deepseek-3-2
|
|
||||||
backend: openrouter
|
|
||||||
model: deepseek/deepseek-v3.2
|
|
||||||
reasoning_effort: high
|
|
||||||
timeout_seconds: 180
|
|
||||||
service_tier: flex
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
id: deepseek-4-flash
|
|
||||||
backend: openrouter
|
|
||||||
model: deepseek/deepseek-v4-flash
|
|
||||||
#reasoning_effort: medium
|
|
||||||
timeout_seconds: 180
|
|
||||||
service_tier: flex
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
id: deepseek-4-pro
|
|
||||||
backend: openrouter
|
|
||||||
model: deepseek/deepseek-v4-pro
|
|
||||||
reasoning_effort: high
|
|
||||||
timeout_seconds: 180
|
|
||||||
service_tier: flex
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
id: gemini-2-flash-lite
|
|
||||||
backend: openrouter
|
|
||||||
model: "google/gemini-2.5-flash-lite"
|
|
||||||
#temperature: 0.15
|
|
||||||
reasoning_effort: high
|
|
||||||
#top_p: 0.98
|
|
||||||
timeout_seconds: 240
|
|
||||||
service_tier: flex
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
id: gemini-2-flash
|
|
||||||
backend: openrouter
|
|
||||||
model: "google/gemini-2.5-flash"
|
|
||||||
#temperature: 0.15
|
|
||||||
reasoning_effort: high
|
|
||||||
#top_p: 0.98
|
|
||||||
timeout_seconds: 240
|
|
||||||
service_tier: flex
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
id: gemini-2-pro
|
|
||||||
backend: openrouter
|
|
||||||
model: "google/gemini-2.5-pro"
|
|
||||||
#temperature: 0.15
|
|
||||||
reasoning_effort: high
|
|
||||||
#top_p: 0.98
|
|
||||||
timeout_seconds: 240
|
|
||||||
service_tier: flex
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
id: gemini-3-flash-lite
|
|
||||||
backend: openrouter
|
|
||||||
model: "google/gemini-3.1-flash-lite"
|
|
||||||
#temperature: 0.15
|
|
||||||
reasoning_effort: high
|
|
||||||
#top_p: 0.98
|
|
||||||
timeout_seconds: 240
|
|
||||||
service_tier: flex
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
id: gemini-flash-latest
|
|
||||||
backend: openrouter
|
|
||||||
model: "~google/gemini-flash-latest"
|
|
||||||
#temperature: 0.15
|
|
||||||
reasoning_effort: high
|
|
||||||
#top_p: 0.98
|
|
||||||
timeout_seconds: 240
|
|
||||||
service_tier: flex
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
id: gemini-pro-latest
|
|
||||||
backend: openrouter
|
|
||||||
model: "~google/gemini-pro-latest"
|
|
||||||
#temperature: 0.15
|
|
||||||
reasoning_effort: high
|
|
||||||
#top_p: 0.98
|
|
||||||
timeout_seconds: 240
|
|
||||||
service_tier: flex
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
id: gemma-4-31b
|
|
||||||
backend: openrouter
|
|
||||||
model: google/gemma-4-31b-it:exacto
|
|
||||||
temperature: 0.15
|
|
||||||
reasoning_effort: high
|
|
||||||
top_p: 0.98
|
|
||||||
timeout_seconds: 240
|
|
||||||
service_tier: flex
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user