Compare commits
161 Commits
v0.2.0
...
1cd0c611f2
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 4ca3be2c14 | |||
| 227fb35f99 | |||
| e291b8bfe9 | |||
| 2b6a7f83c4 | |||
| 3a43550f70 | |||
| c281f721bc | |||
| 350b0e76d9 | |||
| e43350fd0d | |||
| 20d3e3b5ee | |||
| a93b799236 | |||
| e83a3ce179 | |||
| a04a3bbc5f | |||
| 731b66cff5 | |||
| 70e0ea0cf0 | |||
| d45c474c1e | |||
| 25f1ba0b30 | |||
| a718762da1 | |||
| 58ac3ce298 | |||
| 57f2ce1ce4 | |||
| c8b6d5c490 | |||
| abeb50b525 | |||
| 1cb07c7d91 | |||
| 8cfc71c351 | |||
| 5ccfa4a345 | |||
| 14e03f19d0 | |||
| 7c562a9374 | |||
| ef97d85ac9 | |||
| 805e48c873 | |||
| e9e126dcba | |||
| 32e7a3557c | |||
| 1d1b04e2e0 | |||
| 9748897751 | |||
| 9d020039d5 | |||
| 5247ce0b73 | |||
| c434aa1dae | |||
| ac9b3f3d80 | |||
| 4f12a89a1b | |||
| df31e7f58e | |||
| 0678d242b9 | |||
| 3b4ea21208 | |||
| 1430e85147 | |||
| 34d7a19da5 | |||
| ebf1602635 | |||
| 31f2ce3a09 | |||
| fd06e4ca6b | |||
| e63b8de1e9 | |||
| 9354d2b373 | |||
| 01ca5430bd | |||
| ae2179d103 | |||
| a248433d0f | |||
| bd6cffc9d0 | |||
| e40c4f182b | |||
| 7428e50c2c | |||
| 63c67a4520 | |||
| 25a7052a3d | |||
| fc3255967e | |||
| e920168b30 | |||
| 272b6a4bc1 | |||
| dde48a31fc | |||
| 242eace4a7 | |||
| 0bf5f88136 | |||
| 369ab5392d | |||
| 2ba0146e5d | |||
| 6112c2af0c | |||
| f5e12c00f5 | |||
| 49fe402dd2 | |||
| c301eb8d55 | |||
| c13e9710d9 | |||
| 87b5ec3d75 | |||
| cb4028a637 | |||
| 5a1bff4529 | |||
| 805a7f965d | |||
| 147f5e5ff5 |
@@ -1,2 +1,5 @@
|
|||||||
Please review `docs/development.md` for initial orientation in this repository
|
Please review `docs/development.md` for initial orientation in this repository
|
||||||
and follow its task-specific reading guide.
|
and follow its task-specific reading guide.
|
||||||
|
|
||||||
|
Review and follow `docs/policy/agents.md` before recommending, planning, or
|
||||||
|
implementing any changes to this repository.
|
||||||
|
|||||||
19
COPYRIGHT
Normal file
19
COPYRIGHT
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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.
|
||||||
|
|
||||||
|
Third-party code and assets are subject to their respective license terms.
|
||||||
44
README.md
44
README.md
@@ -33,6 +33,34 @@ boundary and constraints that framework work must preserve.
|
|||||||
|
|
||||||
## Release Guidance
|
## Release Guidance
|
||||||
|
|
||||||
|
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
|
||||||
|
[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).
|
||||||
|
|
||||||
|
Consumers upgrading from `v0.3.0` to `v0.4.0` should read the
|
||||||
|
[v0.4.0 changelog and adoption guide](docs/releases/v0.4.0.md).
|
||||||
|
|
||||||
|
Consumers upgrading from `v0.2.0` to `v0.3.0` should read the
|
||||||
|
[v0.3.0 changelog](docs/releases/v0.3.0.md).
|
||||||
|
|
||||||
Consumers moving from `v0.1.0` to `v0.2.0` should read the
|
Consumers moving from `v0.1.0` to `v0.2.0` should read the
|
||||||
[v0.2.0 changelog and migration guide](docs/releases/v0.2.0.md).
|
[v0.2.0 changelog and migration guide](docs/releases/v0.2.0.md).
|
||||||
|
|
||||||
@@ -41,4 +69,18 @@ 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 [GNU Lesser General Public License version 3
|
||||||
|
or later](LICENSE) (`LGPL-3.0-or-later`). The accompanying [GPLv3 text](COPYING)
|
||||||
|
is incorporated by LGPLv3. This maintainer-authorized change applies from this
|
||||||
|
revision forward; existing release tags and their license notices are unchanged.
|
||||||
|
Dependencies, including the backend catalogs, retain their own licenses.
|
||||||
|
|
||||||
|
Applications may use their own licenses subject to the LGPL's combined-work
|
||||||
|
conditions. Distributors of statically linked Go binaries must account for the
|
||||||
|
applicable notices, license copies, library source, and materials and permissions
|
||||||
|
needed to recombine or relink with a modified library; installation information
|
||||||
|
may also be required. Providing library source alone is not necessarily enough.
|
||||||
|
See section 4 of the [LGPL text](LICENSE) for the governing requirements, rather
|
||||||
|
than treating this summary as a complete distribution checklist.
|
||||||
|
|||||||
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
67
backends.go
67
backends.go
@@ -9,50 +9,83 @@ 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].
|
||||||
|
// It is not a built-in or reserved backend and must be registered with
|
||||||
|
// [WithBackend].
|
||||||
|
const BackendLocal = "local"
|
||||||
|
|
||||||
// Backend configures one engine-scoped OpenAI-compatible backend.
|
// Backend configures one engine-scoped OpenAI-compatible backend.
|
||||||
//
|
//
|
||||||
// Backend has no stable JSON representation. Use keyed literals so additions
|
// Backend has no stable JSON representation. Use keyed literals so additions
|
||||||
// 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
|
||||||
// must not be model, session_id, messages, temperature, max_tokens, top_p,
|
// must not be model, session_id, messages, temperature, max_tokens, top_p,
|
||||||
// service_tier, reasoning_effort, or response_format. An empty map supplies
|
// service_tier, reasoning_effort, or response_format. An empty map supplies
|
||||||
// no defaults. NewEngine deeply copies the map.
|
// no defaults. NewEngine deeply copies the map and rejects excessively deep
|
||||||
|
// or large values for safety.
|
||||||
ExtraParams map[string]any
|
ExtraParams map[string]any
|
||||||
// ConcurrencyLimit is the maximum number of simultaneous model-generation
|
// ConcurrencyLimit is the maximum number of simultaneous model-generation
|
||||||
// calls allowed for this backend within one Engine. Zero leaves the backend
|
// calls allowed for this backend within one Engine. Zero leaves the backend
|
||||||
// unlimited. A negative value makes NewEngine fail with ErrInvalidConfig.
|
// unlimited. A negative value makes NewEngine fail with ErrInvalidConfig.
|
||||||
ConcurrencyLimit int
|
ConcurrencyLimit int
|
||||||
// QueueCapacity controls how many additional Run calls may be admitted
|
// QueueCapacity controls how many additional Run or RunPrepared calls may
|
||||||
// beyond ConcurrencyLimit. Nil uses 1024 when ConcurrencyLimit is positive;
|
// be admitted beyond ConcurrencyLimit. Nil uses 1024 when ConcurrencyLimit
|
||||||
// a pointer uses its exact value, including zero. The pointed-to value must
|
// is positive; a pointer uses its exact value, including zero. The pointed-to
|
||||||
// be non-negative, and QueueCapacity must be nil when ConcurrencyLimit is
|
// value must be non-negative, and QueueCapacity must be nil when
|
||||||
// zero. Their sum must fit in an int. WithBackend copies the value and does
|
// ConcurrencyLimit is zero. Their sum must fit in an int. WithBackend copies
|
||||||
// not retain the pointer.
|
// the value and does not retain the pointer.
|
||||||
QueueCapacity *int
|
QueueCapacity *int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LocalBackend returns a caller-owned Backend for a conventional local
|
||||||
|
// OpenAI-compatible endpoint. It sets ID to BackendLocal and copies endpoint
|
||||||
|
// and concurrencyLimit into Endpoint and ConcurrencyLimit without
|
||||||
|
// normalization or validation. APIKeyEnv, ExtraParams, and QueueCapacity keep
|
||||||
|
// their zero values.
|
||||||
|
//
|
||||||
|
// LocalBackend does not read environment variables, register the value, or
|
||||||
|
// mutate engine or package state. Supply the returned value through
|
||||||
|
// [WithBackend]; [NewEngine] then applies the ordinary backend validation and
|
||||||
|
// concurrency semantics, including default queue capacity for a positive
|
||||||
|
// limit, unlimited behavior for zero, and ErrInvalidConfig for a negative
|
||||||
|
// limit.
|
||||||
|
func LocalBackend(endpoint string, concurrencyLimit int) Backend {
|
||||||
|
return Backend{
|
||||||
|
ID: BackendLocal,
|
||||||
|
Endpoint: endpoint,
|
||||||
|
ConcurrencyLimit: concurrencyLimit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WithBackend adds one Backend registration to the constructed Engine.
|
// WithBackend adds one Backend registration to the constructed Engine.
|
||||||
//
|
//
|
||||||
// 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
|
||||||
@@ -60,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,
|
||||||
|
|||||||
@@ -60,7 +60,18 @@ func TestEngineRejectsRunBeforeCompletionWhenAdmissionIsFull(t *testing.T) {
|
|||||||
|
|
||||||
awaitCapacitySignal(t, reader.entered, "first artifact read")
|
awaitCapacitySignal(t, reader.entered, "first artifact read")
|
||||||
|
|
||||||
result, err := engine.Run(context.Background(), capacityInputRequest("http://second.example/v1"))
|
canceledContext, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
result, err := engine.Run(canceledContext, capacityInputRequest("http://canceled.example/v1"))
|
||||||
|
if result != nil || !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("canceled capacity admission=(%+v, %v), want context cancellation", result, err)
|
||||||
|
}
|
||||||
|
var canceledCapacityErr *promptkit.CapacityError
|
||||||
|
if errors.Is(err, promptkit.ErrCapacityExceeded) || errors.As(err, &canceledCapacityErr) {
|
||||||
|
t.Fatalf("canceled admission exposed capacity rejection: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err = engine.Run(context.Background(), capacityInputRequest("http://second.example/v1"))
|
||||||
if result != nil {
|
if result != nil {
|
||||||
t.Fatalf("capacity rejection returned partial result: %+v", result)
|
t.Fatalf("capacity rejection returned partial result: %+v", result)
|
||||||
}
|
}
|
||||||
@@ -70,6 +81,21 @@ func TestEngineRejectsRunBeforeCompletionWhenAdmissionIsFull(t *testing.T) {
|
|||||||
if errors.Is(err, promptkit.ErrInvalidRequest) || errors.Is(err, promptkit.ErrLLMGenerate) {
|
if errors.Is(err, promptkit.ErrInvalidRequest) || errors.Is(err, promptkit.ErrLLMGenerate) {
|
||||||
t.Fatalf("capacity rejection had an unrelated category: %v", err)
|
t.Fatalf("capacity rejection had an unrelated category: %v", err)
|
||||||
}
|
}
|
||||||
|
var capacityErr *promptkit.CapacityError
|
||||||
|
if !errors.As(err, &capacityErr) || capacityErr == nil {
|
||||||
|
t.Fatalf("capacity rejection=%v, want CapacityError", err)
|
||||||
|
}
|
||||||
|
if capacityErr.BackendID != "limited" {
|
||||||
|
t.Fatalf("capacity backend ID=%q, want limited", capacityErr.BackendID)
|
||||||
|
}
|
||||||
|
capacityErr.BackendID = "changed"
|
||||||
|
|
||||||
|
result, err = engine.Run(context.Background(), capacityInputRequest("http://third.example/v1"))
|
||||||
|
var subsequentCapacityErr *promptkit.CapacityError
|
||||||
|
if result != nil || !errors.As(err, &subsequentCapacityErr) ||
|
||||||
|
subsequentCapacityErr == nil || subsequentCapacityErr.BackendID != "limited" {
|
||||||
|
t.Fatalf("subsequent capacity rejection=(%+v, %v), want independent limited CapacityError", result, err)
|
||||||
|
}
|
||||||
if calls := reader.callCount(); calls != 1 {
|
if calls := reader.callCount(); calls != 1 {
|
||||||
t.Fatalf("artifact calls=%d, want only the admitted run", calls)
|
t.Fatalf("artifact calls=%d, want only the admitted run", calls)
|
||||||
}
|
}
|
||||||
@@ -174,6 +200,19 @@ func TestCapacityExceededSentinelContract(t *testing.T) {
|
|||||||
if promptkit.ErrCapacityExceeded == nil {
|
if promptkit.ErrCapacityExceeded == nil {
|
||||||
t.Fatal("ErrCapacityExceeded is nil")
|
t.Fatal("ErrCapacityExceeded is nil")
|
||||||
}
|
}
|
||||||
|
var nilCapacityErr *promptkit.CapacityError
|
||||||
|
zeroCapacityErr := &promptkit.CapacityError{}
|
||||||
|
populatedCapacityErr := &promptkit.CapacityError{BackendID: "limited"}
|
||||||
|
for _, capacityErr := range []error{nilCapacityErr, zeroCapacityErr} {
|
||||||
|
if !errors.Is(capacityErr, promptkit.ErrCapacityExceeded) {
|
||||||
|
t.Fatalf("capacity error=%v, want ErrCapacityExceeded", capacityErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var discoveredCapacityErr *promptkit.CapacityError
|
||||||
|
if !errors.As(populatedCapacityErr, &discoveredCapacityErr) || discoveredCapacityErr != populatedCapacityErr {
|
||||||
|
t.Fatalf("populated capacity error is not discoverable: %v", populatedCapacityErr)
|
||||||
|
}
|
||||||
|
|
||||||
for _, unrelated := range []error{
|
for _, unrelated := range []error{
|
||||||
promptkit.ErrInvalidConfig,
|
promptkit.ErrInvalidConfig,
|
||||||
promptkit.ErrInvalidRequest,
|
promptkit.ErrInvalidRequest,
|
||||||
@@ -181,7 +220,8 @@ func TestCapacityExceededSentinelContract(t *testing.T) {
|
|||||||
promptkit.ErrValidation,
|
promptkit.ErrValidation,
|
||||||
} {
|
} {
|
||||||
if errors.Is(promptkit.ErrCapacityExceeded, unrelated) ||
|
if errors.Is(promptkit.ErrCapacityExceeded, unrelated) ||
|
||||||
errors.Is(unrelated, promptkit.ErrCapacityExceeded) {
|
errors.Is(unrelated, promptkit.ErrCapacityExceeded) ||
|
||||||
|
errors.Is(populatedCapacityErr, unrelated) {
|
||||||
t.Fatalf("ErrCapacityExceeded aliases unrelated sentinel %v", unrelated)
|
t.Fatalf("ErrCapacityExceeded aliases unrelated sentinel %v", unrelated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
capacity_error.go
Normal file
40
capacity_error.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package promptkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CapacityError reports bounded admission rejected for a selected backend.
|
||||||
|
//
|
||||||
|
// Engine-produced values identify only rejection at Promptkit's bounded
|
||||||
|
// [Engine.Run] or [Engine.RunPrepared] admission boundary. BackendID is the
|
||||||
|
// normalized registered backend ID used for routing and capacity; endpoint
|
||||||
|
// overrides do not change it. Every engine-produced value is nonnil and has a
|
||||||
|
// nonblank BackendID. Provider errors, active-generation waiting, and caller
|
||||||
|
// cancellation are not represented by this type.
|
||||||
|
//
|
||||||
|
// Callers own returned values and may mutate BackendID without affecting engine
|
||||||
|
// state or another error. CapacityError and its default Go encoding have no
|
||||||
|
// stable JSON contract. Consumer-constructed values do not establish that an
|
||||||
|
// engine rejected work.
|
||||||
|
type CapacityError struct {
|
||||||
|
// BackendID is the normalized registered backend ID whose admission was
|
||||||
|
// rejected.
|
||||||
|
BackendID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns diagnostic wording that is not a parsing contract. It is safe
|
||||||
|
// to call on a nil receiver or a value with a blank BackendID.
|
||||||
|
func (e *CapacityError) Error() string {
|
||||||
|
if e == nil || strings.TrimSpace(e.BackendID) == "" {
|
||||||
|
return ErrCapacityExceeded.Error()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("backend %q admission: %v", e.BackendID, ErrCapacityExceeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap returns ErrCapacityExceeded so errors.Is and errors.As can be used
|
||||||
|
// together. It is safe to call on a nil receiver or a zero value.
|
||||||
|
func (e *CapacityError) Unwrap() error {
|
||||||
|
return ErrCapacityExceeded
|
||||||
|
}
|
||||||
122
convert.go
122
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +226,40 @@ func fromDomainExecutionTarget(target domain.ExecutionTarget) ExecutionTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fromDomainProfileInspection(inspection *domain.ProfileInspection) *ProfileInspection {
|
||||||
|
if inspection == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &ProfileInspection{
|
||||||
|
ProfileID: inspection.ProfileID,
|
||||||
|
EffectiveModelParams: fromDomainExecutionTarget(inspection.EffectiveModelParams),
|
||||||
|
APIKeyRequired: inspection.APIKeyRequired,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromDomainPromptInspection(inspection *domain.PromptInspection) *PromptInspection {
|
||||||
|
if inspection == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
inputs := make([]PromptInputDefinition, len(inspection.Inputs))
|
||||||
|
for i, input := range inspection.Inputs {
|
||||||
|
inputs[i] = PromptInputDefinition{
|
||||||
|
Name: input.Name,
|
||||||
|
Required: input.Required,
|
||||||
|
ContentType: input.ContentType,
|
||||||
|
Description: input.Description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &PromptInspection{
|
||||||
|
PromptID: inspection.PromptID,
|
||||||
|
PromptVersion: inspection.PromptVersion,
|
||||||
|
PromptHash: inspection.PromptHash,
|
||||||
|
DefaultProfileID: inspection.DefaultProfileID,
|
||||||
|
Inputs: inputs,
|
||||||
|
OutputContract: fromDomainOutputContract(inspection.OutputContract),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func fromDomainExecutionTargetPresence(presence domain.ExecutionTargetPresence) ExecutionTargetPresence {
|
func fromDomainExecutionTargetPresence(presence domain.ExecutionTargetPresence) ExecutionTargetPresence {
|
||||||
return ExecutionTargetPresence{
|
return ExecutionTargetPresence{
|
||||||
Temperature: presence.Temperature,
|
Temperature: presence.Temperature,
|
||||||
@@ -251,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
48
doc.go
48
doc.go
@@ -3,23 +3,29 @@
|
|||||||
//
|
//
|
||||||
// Applications construct an [Engine] with [NewEngine], select filesystem or
|
// Applications construct an [Engine] with [NewEngine], select filesystem or
|
||||||
// in-memory sources and optional engine-scoped [Backend] registrations, and
|
// in-memory sources and optional engine-scoped [Backend] registrations, and
|
||||||
// call [Engine.Prepare] or [Engine.Run]. Concrete registries, repositories,
|
// call [Engine.InspectPrompt], [Engine.InspectProfile], [Engine.Prepare],
|
||||||
// validators, and the built-in OpenAI-compatible client remain internal
|
// [Engine.PrepareExecution], [Engine.Run], or [Engine.RunPrepared]. Concrete
|
||||||
// implementation details.
|
// registries, repositories, validators, and the built-in OpenAI-compatible
|
||||||
|
// client remain internal implementation details.
|
||||||
//
|
//
|
||||||
// # Concurrency and ownership
|
// # Concurrency and ownership
|
||||||
//
|
//
|
||||||
// An Engine supports concurrent Prepare and Run calls. Engine-local backend
|
// An Engine supports concurrent InspectPrompt, InspectProfile, Prepare,
|
||||||
// policies bound admitted Run calls and model generations where configured,
|
// PrepareExecution, Run, and RunPrepared calls. Engine-local backend policies
|
||||||
// while different backend pools and unlimited backends continue independently.
|
// bound admitted Run and RunPrepared calls and model generations where
|
||||||
// An injected [LLMClient] or [ArtifactReader] can therefore still receive
|
// configured, while different backend pools and unlimited backends continue
|
||||||
// concurrent calls and must be safe for that use.
|
// independently. An injected [LLMClient] or [ArtifactReader] can therefore
|
||||||
|
// still receive concurrent calls and must be safe for that use.
|
||||||
//
|
//
|
||||||
// NewEngine copies in-memory profiles and backend definitions. Prepare and Run
|
// NewEngine copies in-memory profiles and backend definitions. Prepare,
|
||||||
// copy request maps, slices, pointer values, and JSON-compatible extra
|
// PrepareExecution, and Run copy request maps, slices, pointer values, and
|
||||||
// parameters before using them. Returned values and values passed to extension
|
// JSON-compatible extra parameters before using them. InspectPrompt and
|
||||||
// interfaces are likewise isolated from engine state. Callers own those copies
|
// InspectProfile return copied inspection values. Returned values and values
|
||||||
// and may mutate them after the call that supplied or returned them.
|
// passed to extension interfaces are likewise isolated from engine state.
|
||||||
|
// Callers own those copies and may mutate them after the call that supplied or
|
||||||
|
// returned them. [CapacityError] values are caller-owned and may be mutated
|
||||||
|
// 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
|
||||||
//
|
//
|
||||||
@@ -45,10 +51,18 @@
|
|||||||
// [GenerateResponse], [ExecutionTargetPresence], and the string value types
|
// [GenerateResponse], [ExecutionTargetPresence], and the string value types
|
||||||
// used by those values.
|
// used by those values.
|
||||||
//
|
//
|
||||||
// Construction values, including [Config], [Backend], [RunRequest],
|
// Construction, inspection, handle, and error values, including [Config],
|
||||||
// [ArtifactRef], [ExecutionTargetOverride], [Profile], and
|
// [Backend], [RunRequest], [ArtifactRef], [ExecutionTargetOverride], [Profile],
|
||||||
// [OpenAICompatibleProfileConfig], do not have stable JSON representations.
|
// [OpenAICompatibleProfileConfig], [ProfileInspection],
|
||||||
// Direct API keys are nevertheless excluded from JSON for every public value.
|
// [PromptInputDefinition], [PromptInspection], [PreparedExecution],
|
||||||
|
// [CapacityError], [GenerationError], [RetryExhaustedError], and
|
||||||
|
// [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
|
||||||
|
|||||||
@@ -40,11 +40,67 @@ validation, and default transport behavior. Source discovery, format
|
|||||||
validation, and profile precedence are defined by the
|
validation, and profile precedence are defined by the
|
||||||
[framework format reference](../formats.md).
|
[framework format reference](../formats.md).
|
||||||
|
|
||||||
|
## Supply Embedded Application Defaults
|
||||||
|
|
||||||
|
Use `WithFallbackProfileFS` when an application packages profile definitions
|
||||||
|
that should apply unless an operator provides an ordinary configured profile
|
||||||
|
with the same ID. For example, an application can embed its defaults while
|
||||||
|
continuing to use `ProfileDir` for operator overrides:
|
||||||
|
|
||||||
|
```go
|
||||||
|
//go:embed profiles/*.yaml
|
||||||
|
var applicationProfiles embed.FS
|
||||||
|
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
||||||
|
PromptDir: "prompts",
|
||||||
|
ProfileDir: operatorProfileDir,
|
||||||
|
},
|
||||||
|
promptkit.WithFallbackProfileFS(applicationProfiles, "profiles"),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep application-owned profile IDs and definitions in the embedded source.
|
||||||
|
Use the ordinary configured profile source for operator overrides. Leave
|
||||||
|
`operatorProfileDir` empty when the operator did not configure an override
|
||||||
|
directory; a non-empty path names an authoritative higher-precedence source,
|
||||||
|
so an unavailable or unreadable directory is an error rather than a reason to
|
||||||
|
fall back. The
|
||||||
|
[framework format reference](../formats.md#source-and-profile-precedence)
|
||||||
|
owns the exact profile format and lookup order; the
|
||||||
|
[`WithFallbackProfileFS` GoDoc](../../engine.go) owns its option contract and
|
||||||
|
validation rules.
|
||||||
|
|
||||||
|
## Inspect A Prompt Before Preparation
|
||||||
|
|
||||||
|
Use [`Engine.InspectPrompt`](../../engine.go) to check one configured prompt's
|
||||||
|
declared inputs and output workflow without creating placeholder inputs or
|
||||||
|
resolving a profile:
|
||||||
|
|
||||||
|
```go
|
||||||
|
inspection, err := engine.InspectPrompt(ctx, "meeting.summary", "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, input := range inspection.Inputs {
|
||||||
|
// Compare the declared input with application configuration.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this configuration-time boundary when the application needs only the
|
||||||
|
declared prompt interface. Use `InspectProfile` separately when it must also
|
||||||
|
check a configured profile. Use `Prepare` when it needs inputs, schemas, or
|
||||||
|
rendered messages, and use prepared execution when that work must remain tied
|
||||||
|
to later execution. The method's [GoDoc](../../engine.go) owns exact fields,
|
||||||
|
hash, ownership, and error semantics.
|
||||||
|
|
||||||
## Prepare Without Model Execution
|
## Prepare Without Model Execution
|
||||||
|
|
||||||
[`Engine.Prepare`](../../engine.go) resolves the selected prompt and profile,
|
[`Engine.Prepare`](../../engine.go) resolves the selected prompt and profile,
|
||||||
loads inputs and any structured-output schema, and renders messages without
|
loads inputs and any structured-output schema, and renders messages without
|
||||||
calling a model client:
|
calling a model client. Choose it when the prepared value is the final
|
||||||
|
inspection or persistence result and no later execution must be tied to that
|
||||||
|
exact snapshot:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
prepared, err := engine.Prepare(ctx, promptkit.RunRequest{
|
prepared, err := engine.Prepare(ctx, promptkit.RunRequest{
|
||||||
@@ -61,12 +117,56 @@ shows a complete runnable setup with a prompt file, in-memory profile, and
|
|||||||
inline input. Exact request requirements and prepared-result fields belong to
|
inline input. Exact request requirements and prepared-result fields belong to
|
||||||
the [`RunRequest` and `PreparedRun` GoDoc](../../types.go).
|
the [`RunRequest` and `PreparedRun` GoDoc](../../types.go).
|
||||||
|
|
||||||
|
## Prepare Now And Execute The Same Snapshot Later
|
||||||
|
|
||||||
|
Use [`Engine.PrepareExecution`](../../engine.go) when an application must
|
||||||
|
inspect or persist preflight details before deciding whether to start model
|
||||||
|
work, while ensuring that later execution uses those exact rendered messages,
|
||||||
|
inputs, target settings, and validation resources:
|
||||||
|
|
||||||
|
```go
|
||||||
|
preparedExecution, err := engine.PrepareExecution(ctx, promptkit.RunRequest{
|
||||||
|
PromptID: "meeting.summary",
|
||||||
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
|
"note": promptkit.Inline("Synthetic meeting notes"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer preparedExecution.Discard()
|
||||||
|
|
||||||
|
details := preparedExecution.Details()
|
||||||
|
// Inspect or persist an application-selected safe subset of details.
|
||||||
|
|
||||||
|
result, err := engine.RunPrepared(ctx, preparedExecution)
|
||||||
|
```
|
||||||
|
|
||||||
|
Preparation does not call the model or reserve backend capacity.
|
||||||
|
`RunPrepared` executes from the retained snapshot rather than reloading
|
||||||
|
consumer sources. The handle is opaque in-process state, while `Details`
|
||||||
|
contains rendered content and remains subject to the application's data
|
||||||
|
handling policy. The
|
||||||
|
[`PreparedExecution` and method GoDoc](../../prepared_execution.go) and
|
||||||
|
[engine operation GoDoc](../../engine.go) own exact lifecycle, engine-binding,
|
||||||
|
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
|
||||||
configured model client, classifies the generated artifact, and validates the
|
configured model client, classifies the generated artifact, and validates the
|
||||||
content. A completed content check may return `ValidationFailed` in the result;
|
content in one call. Choose it when the application does not need a preflight
|
||||||
an operational inability to validate returns an error.
|
boundary tied to the eventual execution. A completed content check may return
|
||||||
|
`ValidationFailed` in the result; an operational inability to validate returns
|
||||||
|
an error.
|
||||||
|
|
||||||
The maintained
|
The maintained
|
||||||
[offline execution example](../../examples/go-library/run/main.go) injects a
|
[offline execution example](../../examples/go-library/run/main.go) injects a
|
||||||
@@ -81,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
|
||||||
@@ -90,13 +282,115 @@ replace execution settings or the complete output contract.
|
|||||||
The [public value GoDoc](../../types.go) defines nil, empty, zero, replacement,
|
The [public value GoDoc](../../types.go) defines nil, empty, zero, replacement,
|
||||||
copy, and credential behavior. The
|
copy, and credential behavior. The
|
||||||
[framework format reference](../formats.md) defines how those request values
|
[framework format reference](../formats.md) defines how those request values
|
||||||
interact with prompt definitions, file-backed profiles, built-ins, schemas,
|
interact with prompt definitions, file-backed and application fallback
|
||||||
and framework defaults.
|
profiles, built-ins, schemas, and framework defaults.
|
||||||
|
|
||||||
For programmatic profiles,
|
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
|
||||||
|
|
||||||
|
Use [`Engine.InspectProfile`](../../engine.go) to validate one configured
|
||||||
|
profile without constructing a synthetic prompt or placeholder inputs. It
|
||||||
|
resolves the profile's effective target but does not prepare or execute a
|
||||||
|
prompt:
|
||||||
|
|
||||||
|
```go
|
||||||
|
inspection, err := engine.InspectProfile(ctx, profileID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
target := inspection.EffectiveModelParams
|
||||||
|
if target.APIKeyEnv != "" {
|
||||||
|
// This is a configured optional environment lookup source.
|
||||||
|
} else if inspection.APIKeyRequired {
|
||||||
|
// Arrange a direct credential before later execution.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this configuration-time boundary when only the profile and its target need
|
||||||
|
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
|
||||||
|
later execution. A reported `APIKeyEnv` is a configured optional source, while
|
||||||
|
`APIKeyRequired` is the explicit local requirement. The
|
||||||
|
[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
|
||||||
|
|
||||||
Supply a direct session ID when one prompt should be correlated with a
|
Supply a direct session ID when one prompt should be correlated with a
|
||||||
@@ -124,35 +418,86 @@ providers. The
|
|||||||
[`RunRequest` and `ExecutionTargetOverride` GoDoc](../../types.go) owns the
|
[`RunRequest` and `ExecutionTargetOverride` GoDoc](../../types.go) owns the
|
||||||
exact normalization, precedence, error, copying, and exposure contract.
|
exact normalization, precedence, error, copying, and exposure contract.
|
||||||
|
|
||||||
### Register A Custom Backend
|
### Configure A Local OpenAI-Compatible Endpoint
|
||||||
|
|
||||||
Register a reusable OpenAI-compatible connection once, then select it from a
|
Choose the smallest configuration that fits how the endpoint will be reused.
|
||||||
profile. This local backend limits model generation to two simultaneous calls;
|
|
||||||
because `QueueCapacity` is omitted, the engine admits up to 1024 additional
|
#### Use An Endpoint-Only Profile
|
||||||
calls waiting behind them:
|
|
||||||
|
Put the endpoint directly on an in-memory profile when only that profile needs
|
||||||
|
it and shared backend identity or capacity policy is unnecessary:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
||||||
PromptDir: "prompts",
|
PromptDir: "prompts",
|
||||||
},
|
},
|
||||||
promptkit.WithBackend(promptkit.Backend{
|
promptkit.WithProfiles(promptkit.Profile{
|
||||||
ID: "local",
|
ID: "local-summary",
|
||||||
Endpoint: "http://localhost:8000/v1",
|
Endpoint: "http://localhost:8000/v1",
|
||||||
APIKeyEnv: "LOCAL_LLM_API_KEY",
|
Model: "example-model",
|
||||||
ConcurrencyLimit: 2,
|
|
||||||
}),
|
}),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoint-only profiles have an empty backend ID and remain unrestricted by
|
||||||
|
backend capacity policy.
|
||||||
|
|
||||||
|
#### Use The Conventional Local Backend
|
||||||
|
|
||||||
|
Use `LocalBackend` when profiles should share the conventional `local`
|
||||||
|
identity, endpoint, and concurrency limit:
|
||||||
|
|
||||||
|
```go
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
||||||
|
PromptDir: "prompts",
|
||||||
|
},
|
||||||
|
promptkit.WithBackend(
|
||||||
|
promptkit.LocalBackend("http://localhost:8000/v1", 2),
|
||||||
|
),
|
||||||
promptkit.WithProfiles(promptkit.Profile{
|
promptkit.WithProfiles(promptkit.Profile{
|
||||||
ID: "local-summary",
|
ID: "local-summary",
|
||||||
BackendID: "local",
|
BackendID: promptkit.BackendLocal,
|
||||||
Model: "example-model",
|
Model: "example-model",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The helper is explicit: it does not pre-register a backend or read environment
|
||||||
|
variables. Supplying a positive limit leaves queue capacity omitted, so normal
|
||||||
|
backend registration selects the existing default waiting capacity of 1024.
|
||||||
|
The returned value still enters the engine through `WithBackend`.
|
||||||
|
|
||||||
|
#### Configure A Complete Backend
|
||||||
|
|
||||||
|
Use a keyed `Backend` value for authentication, extra request parameters, an
|
||||||
|
explicit queue capacity, a custom ID, or multiple local endpoints:
|
||||||
|
|
||||||
|
```go
|
||||||
|
noWaiting := 0
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
||||||
|
PromptDir: "prompts",
|
||||||
|
},
|
||||||
|
promptkit.WithBackend(promptkit.Backend{
|
||||||
|
ID: "local-gpu",
|
||||||
|
Endpoint: "http://gpu-host:8000/v1",
|
||||||
|
APIKeyEnv: "LOCAL_GPU_API_KEY",
|
||||||
|
ExtraParams: map[string]any{"provider_option": "enabled"},
|
||||||
|
ConcurrencyLimit: 2,
|
||||||
|
QueueCapacity: &noWaiting,
|
||||||
|
}),
|
||||||
|
promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: "gpu-summary",
|
||||||
|
BackendID: "local-gpu",
|
||||||
|
Model: "example-model",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use distinct custom IDs when registering multiple local endpoints.
|
||||||
Registrations belong to one engine and custom IDs cannot replace built-ins.
|
Registrations belong to one engine and custom IDs cannot replace built-ins.
|
||||||
The [`Backend` and `WithBackend` GoDoc](../../backends.go) defines validation,
|
The [`Backend`, `LocalBackend`, and `WithBackend` GoDoc](../../backends.go)
|
||||||
copying, uniqueness, exact concurrency-field semantics, and request-default
|
defines exact construction, validation, copying, uniqueness, concurrency, and
|
||||||
behavior.
|
request-default behavior.
|
||||||
|
|
||||||
Both file-backed and in-memory profiles select a registration through
|
Both file-backed and in-memory profiles select a registration through
|
||||||
`backend` or `Profile.BackendID`. Profile and request endpoint overrides retain
|
`backend` or `Profile.BackendID`. Profile and request endpoint overrides retain
|
||||||
@@ -173,8 +518,8 @@ zero:
|
|||||||
```go
|
```go
|
||||||
noWaiting := 0
|
noWaiting := 0
|
||||||
backend := promptkit.Backend{
|
backend := promptkit.Backend{
|
||||||
ID: "local",
|
ID: "local-gpu",
|
||||||
Endpoint: "http://localhost:8000/v1",
|
Endpoint: "http://gpu-host:8000/v1",
|
||||||
ConcurrencyLimit: 2,
|
ConcurrencyLimit: 2,
|
||||||
QueueCapacity: &noWaiting,
|
QueueCapacity: &noWaiting,
|
||||||
}
|
}
|
||||||
@@ -236,6 +581,11 @@ When a limited backend has admitted all active and waiting calls, handle
|
|||||||
```go
|
```go
|
||||||
result, err := engine.Run(ctx, request)
|
result, err := engine.Run(ctx, request)
|
||||||
if errors.Is(err, promptkit.ErrCapacityExceeded) {
|
if errors.Is(err, promptkit.ErrCapacityExceeded) {
|
||||||
|
var capacityErr *promptkit.CapacityError
|
||||||
|
if errors.As(err, &capacityErr) {
|
||||||
|
// Record capacityErr.BackendID using application-owned diagnostics.
|
||||||
|
}
|
||||||
|
|
||||||
// Apply application policy: shed work, report overload, or retry later.
|
// Apply application policy: shed work, report overload, or retry later.
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -243,8 +593,41 @@ if errors.Is(err, promptkit.ErrCapacityExceeded) {
|
|||||||
A rejected call returns no partial result and does not invoke the model
|
A rejected call returns no partial result and does not invoke the model
|
||||||
client. Promptkit does not prescribe retries or map this error to an HTTP
|
client. Promptkit does not prescribe retries or map this error to an HTTP
|
||||||
status; those choices remain with the consuming application. The
|
status; those choices remain with the consuming application. The
|
||||||
[`Engine.Run` and error GoDoc](../../engine.go) owns exact error and
|
[`CapacityError` GoDoc](../../capacity_error.go) owns the exact typed-error
|
||||||
cancellation identities.
|
contract, while the [`Engine.Run` and error GoDoc](../../engine.go) owns broad
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
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. All
|
||||||
contributors must read the
|
contributors must read the
|
||||||
[architecture policy](policy/architecture.md) before making changes.
|
[architecture policy](policy/architecture.md) before making changes. Agents must
|
||||||
|
also follow the [agent policy](policy/agents.md) before recommendations or work.
|
||||||
|
|
||||||
## Initial Orientation
|
## Initial Orientation
|
||||||
|
|
||||||
@@ -25,7 +26,8 @@ Start with:
|
|||||||
documentation;
|
documentation;
|
||||||
- the [testing policy](policy/testing.md) before adding, rewriting, or deleting
|
- the [testing policy](policy/testing.md) before adding, rewriting, or deleting
|
||||||
tests; and
|
tests; and
|
||||||
- the [release procedure](release.md) for version and publication work.
|
- the [release policy](policy/release.md) and [release procedure](release.md)
|
||||||
|
for version and publication work.
|
||||||
|
|
||||||
## Task-Specific Reading Guide
|
## Task-Specific Reading Guide
|
||||||
|
|
||||||
@@ -34,108 +36,54 @@ 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). |
|
| Release preparation or publication | The [release policy](policy/release.md) and [release procedure](release.md). |
|
||||||
|
|
||||||
For cross-cutting changes, follow every applicable row. Do not create
|
For cross-cutting changes, follow every applicable row. Do not create
|
||||||
placeholder documents for packages, APIs, or integrations that do not yet
|
placeholder documents for packages, APIs, or integrations that do not yet
|
||||||
exist.
|
exist.
|
||||||
|
|
||||||
## Maintainer-Run Validation
|
## Maintainer Validation
|
||||||
|
|
||||||
Promptkit does not currently use hosted CI. Maintainers are responsible for
|
For ordinary work, select validation proportionate to the change. Do not repeat
|
||||||
running the documented checks before accepting changes. Run the default Go
|
successful checks for unchanged content merely to create a commit. The full
|
||||||
validation from the Promptkit repository root:
|
release-candidate checks have one executable entry point from the repository root:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./...
|
./scripts/release-check.sh
|
||||||
go test -race ./...
|
|
||||||
go vet ./...
|
|
||||||
go build ./...
|
|
||||||
go run ./examples/go-library/prepare
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Check formatting across every tracked Go file:
|
The [script](../scripts/release-check.sh) owns the executable checks:
|
||||||
|
race-enabled tests, vet, the three offline consumer examples with semantic output
|
||||||
|
assertions, tracked Go formatting, local Markdown links, module/workspace
|
||||||
|
hygiene, whitespace, and the credential-pattern scan. It does not publish,
|
||||||
|
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.
|
||||||
|
|
||||||
```sh
|
The script can run on intended uncommitted changes during development.
|
||||||
gofmt -l $(git ls-files '*.go')
|
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.
|
||||||
|
|
||||||
The formatting command must produce no paths. Follow every added or changed
|
### Change Review
|
||||||
Markdown link and confirm its target exists. Finally, check whitespace:
|
|
||||||
|
|
||||||
```sh
|
Inspect `git status --short --untracked-files=all` and the complete diff before
|
||||||
git diff --check
|
accepting a change. Reject unintended files, credentials, private data,
|
||||||
```
|
generated binaries, test output, downloaded assets, and template residue.
|
||||||
|
The automated credential scan does not replace review of the actual change.
|
||||||
|
|
||||||
Documentation-only work does not require unrelated new tests, but it still
|
For releases, follow the [release procedure](release.md). Its publication
|
||||||
requires link validation and `git diff --check`. Run the Go validation whenever
|
command runs the full local checks once on the final committed candidate and
|
||||||
documentation changes commands, examples, generated output, or another
|
automates release-note structure and Git checks. Do not repeat ordinary change
|
||||||
behavior checked by the module.
|
review as a release sign-off. Roadmap retirement remains preparation housekeeping.
|
||||||
|
|
||||||
## Focused Validation
|
Follow the [agent commit policy](policy/agents.md#git-commit-policy) for local
|
||||||
|
commits. Ordinary commits may leave unrelated work in the workspace; preserve
|
||||||
Use focused checks while iterating, then run the complete validation sequence
|
and report it. A clean worktree and index are required for release candidates,
|
||||||
before accepting the change. The root package supports:
|
as enforced by the publication command, not for every ordinary commit.
|
||||||
|
|
||||||
```sh
|
|
||||||
go test .
|
|
||||||
go vet .
|
|
||||||
go build .
|
|
||||||
```
|
|
||||||
|
|
||||||
Filter tests by name without assuming a fixed internal package layout:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./... -run 'TestName'
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace `TestName` with a useful regular expression. Target only paths that
|
|
||||||
exist, and consult the internal component overview for their owning
|
|
||||||
documentation. A filtered or package-specific run does not replace the
|
|
||||||
complete repository validation.
|
|
||||||
|
|
||||||
## Coordinated Work With Scriptorium
|
|
||||||
|
|
||||||
Promptkit and Scriptorium must remain independently valid. For temporary local
|
|
||||||
integration, use either a Go workspace outside both repositories or an
|
|
||||||
uncommitted replacement in the consuming module.
|
|
||||||
|
|
||||||
If the repositories are sibling directories, run the workspace commands from
|
|
||||||
their parent directory:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go work init ./promptkit ./scriptorium
|
|
||||||
go work sync
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the workspace only for coordinated local checks. From the same parent
|
|
||||||
directory, remove it when finished:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
rm -f go.work go.work.sum
|
|
||||||
```
|
|
||||||
|
|
||||||
Alternatively, from the Scriptorium repository root, temporarily point its
|
|
||||||
Promptkit dependency at the sibling checkout:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go mod edit -replace gitea.maximumdirect.net/eric/promptkit=../promptkit
|
|
||||||
```
|
|
||||||
|
|
||||||
After coordinated checks, remove the replacement and reconcile module
|
|
||||||
metadata:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go mod edit -dropreplace gitea.maximumdirect.net/eric/promptkit
|
|
||||||
go mod tidy
|
|
||||||
```
|
|
||||||
|
|
||||||
Never commit `go.work`, `go.work.sum`, or a local filesystem `replace`
|
|
||||||
directive. Before committing in either repository, inspect its module files and
|
|
||||||
working tree independently. Published consumer versions must depend on a tagged
|
|
||||||
Promptkit version, not a workspace, local replacement, or unpublished commit.
|
|
||||||
|
|||||||
235
docs/formats.md
235
docs/formats.md
@@ -9,9 +9,10 @@ explains how to select these sources and invoke the engine. The
|
|||||||
owns the resulting outbound wire behavior.
|
owns the resulting outbound wire behavior.
|
||||||
|
|
||||||
Prompt and profile sources recursively discover files ending in `.yaml` or
|
Prompt and profile sources recursively discover files ending in `.yaml` or
|
||||||
`.yml`. YAML decoding is strict: unknown fields are errors for the selected
|
`.yml`. Each prompt-definition and profile file contains exactly one YAML
|
||||||
definition. Definitions are selected by their YAML `id`, not their file name
|
document; comments and trailing whitespace are allowed. YAML decoding is
|
||||||
or directory.
|
strict: unknown fields are errors for the selected definition. Definitions are
|
||||||
|
selected by their YAML `id`, not their file name or directory.
|
||||||
|
|
||||||
## Prompt Definitions
|
## Prompt Definitions
|
||||||
|
|
||||||
@@ -58,6 +59,11 @@ When a request omits a version, the selected prompt ID must identify exactly
|
|||||||
one definition. When it supplies a version, the ID and version pair must be
|
one definition. When it supplies a version, the ID and version pair must be
|
||||||
unique.
|
unique.
|
||||||
|
|
||||||
|
Exact prompt inspection uses this same configured source, strict decoding,
|
||||||
|
referenced content-file resolution, and ID/version selection. It reports the
|
||||||
|
selected definition's declared metadata without changing the prompt format or
|
||||||
|
executing the definition.
|
||||||
|
|
||||||
### Inputs
|
### Inputs
|
||||||
|
|
||||||
Each `inputs` item has these fields:
|
Each `inputs` item has these fields:
|
||||||
@@ -76,14 +82,27 @@ 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.
|
||||||
|
|
||||||
For directory and `fs.FS` prompt sources, `content_file` resolves relative to
|
`content_file` must be a relative path. It resolves from the directory that
|
||||||
the prompt file and remains within the source root. `WithPromptFile` also
|
contains the prompt file and must remain within the configured prompt source
|
||||||
resolves it relative to that file.
|
root; parent components are allowed only when the resolved target remains
|
||||||
|
inside that root. Absolute paths and paths that escape the root are rejected.
|
||||||
|
Operating-system directory and single-file sources also reject symlink targets
|
||||||
|
outside the root, while injected `fs.FS` sources apply containment in that
|
||||||
|
filesystem's relative path namespace. For `WithPromptFile`, the source root is
|
||||||
|
the directory containing the selected prompt file. Promptkit uses the parsed
|
||||||
|
path text exactly after checking separately that it is not blank, so leading
|
||||||
|
and trailing whitespace can name real filesystem entries.
|
||||||
|
|
||||||
Request variables are the template data, so a variable named `audience` is
|
Request variables are the template data, so a variable named `audience` is
|
||||||
referenced as `{{.audience}}`. The `{{input "note"}}` helper renders the body
|
referenced as `{{.audience}}`. The `{{input "note"}}` helper renders the body
|
||||||
@@ -113,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:
|
||||||
|
|
||||||
@@ -123,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:
|
||||||
@@ -149,11 +185,21 @@ 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 | Non-empty profile identifier. IDs must be unique within one source. |
|
| `id` | yes | Profile identifier, trimmed before selection and publication. It must be non-empty after trimming and unique within one source after normalization. |
|
||||||
| `backend` | unless `endpoint` is present | Backend registry ID. It is trimmed and registry membership is checked when the profile is prepared. |
|
| `base_profile` | no | One optional parent profile ID. A derived profile may inherit target fields from it. |
|
||||||
| `endpoint` | unless `backend` is present | Non-empty OpenAI-compatible base URL, including an API version path when required. When both connection fields are present, this overrides the backend endpoint without changing backend identity. |
|
| `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. |
|
||||||
| `model` | yes | Non-empty provider model name. |
|
| `model` | yes | Non-empty provider model name. |
|
||||||
| `temperature` | no | Number from 0 through 2. |
|
| `temperature` | no | Number from 0 through 2. |
|
||||||
| `max_tokens` | no | Integer zero or greater. |
|
| `max_tokens` | no | Integer zero or greater. |
|
||||||
@@ -161,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.
|
||||||
|
|
||||||
@@ -178,30 +229,33 @@ GoDoc.
|
|||||||
objects with string keys. Keys must be non-empty. With the built-in client,
|
objects with string keys. Keys must be non-empty. With the built-in client,
|
||||||
they also cannot collide with the standard fields listed in the
|
they also cannot collide with the standard fields listed in the
|
||||||
[outbound request contract](integrations/openai-compatible-chat.md#request-body).
|
[outbound request contract](integrations/openai-compatible-chat.md#request-body).
|
||||||
|
Excessively deep or large JSON-shaped values are rejected for safety.
|
||||||
|
|
||||||
### Defaults And Overrides
|
### Defaults And Overrides
|
||||||
|
|
||||||
Execution settings resolve in this order:
|
Execution settings resolve in this order:
|
||||||
|
|
||||||
1. framework defaults;
|
1. the framework timeout baseline;
|
||||||
2. the selected backend, when the profile names one;
|
2. the selected backend, when the profile names one;
|
||||||
3. the selected profile; and
|
3. the selected profile; and
|
||||||
4. request `ExecutionTargetOverride` values.
|
4. request `ExecutionTargetOverride` values.
|
||||||
|
|
||||||
The framework defaults are:
|
The framework baseline is:
|
||||||
|
|
||||||
| Setting | Default |
|
| Setting | Default |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `temperature` | `0` |
|
| `temperature` | Unspecified and omitted from compatible provider requests unless a profile or runtime override selects it. |
|
||||||
| `max_tokens` | `0` |
|
| `max_tokens` | Unspecified and omitted from compatible provider requests unless a profile or runtime override selects it. |
|
||||||
| `top_p` | `1` |
|
| `top_p` | Unspecified and omitted from compatible provider requests unless a profile or runtime override selects it. |
|
||||||
| `timeout_seconds` | `600` |
|
| `timeout_seconds` | `600` |
|
||||||
|
|
||||||
Numeric zero in a file or in-memory profile means that the profile does not
|
Numeric zero in a file or in-memory profile does not select a numeric value.
|
||||||
replace the framework default. Numeric request overrides use pointers, so an
|
For `temperature`, `max_tokens`, and `top_p`, it leaves the provider control
|
||||||
explicit zero is preserved. In particular, an explicit request
|
unspecified. For `timeout_seconds`, it retains the framework deadline. Numeric
|
||||||
`timeout_seconds` of zero disables the per-generation deadline while leaving
|
request overrides use pointers, so an explicit zero is retained and sent to
|
||||||
the caller context and transport timeout intact.
|
compatible providers. In particular, an explicit request `timeout_seconds` of
|
||||||
|
zero disables the per-generation deadline while leaving the caller context and
|
||||||
|
transport timeout intact.
|
||||||
|
|
||||||
Non-empty profile strings replace backend defaults, and non-empty request
|
Non-empty profile strings replace backend defaults, and non-empty request
|
||||||
strings replace both. Request reasoning is the exception: a nil
|
strings replace both. Request reasoning is the exception: a nil
|
||||||
@@ -219,54 +273,86 @@ defines how the effective settings are serialized.
|
|||||||
### Source And Profile Precedence
|
### Source And Profile Precedence
|
||||||
|
|
||||||
An explicit request profile ID takes precedence over the prompt's
|
An explicit request profile ID takes precedence over the prompt's
|
||||||
`default_profile`. If neither is present, preparation fails.
|
`default_profile`. If neither is present, preparation fails. Exact profile
|
||||||
|
inspection instead takes one explicit profile ID and does not use a prompt
|
||||||
|
default.
|
||||||
|
|
||||||
Profile sources resolve matching IDs in this order:
|
Profile sources resolve matching IDs in this order:
|
||||||
|
|
||||||
1. in-memory profiles supplied with `WithProfiles`;
|
1. in-memory profiles supplied with `WithProfiles`;
|
||||||
2. a profile file, `fs.FS`, or configured profile directory; and
|
2. the ordinary configured source selected by a profile file, `fs.FS`, or
|
||||||
3. embedded built-in profiles.
|
configured profile directory;
|
||||||
|
3. application fallback profiles supplied with `WithFallbackProfileFS`; and
|
||||||
|
4. Promptkit-owned profiles; and
|
||||||
|
5. maintained external catalog profiles.
|
||||||
|
|
||||||
A higher-precedence source falls back only when the profile is absent. An
|
A profile source supplies a complete definition; definitions and their fields
|
||||||
invalid matching profile is an error and does not fall back. In-memory
|
are not merged across sources. A higher-precedence source falls back only when
|
||||||
`Profile` values follow the same ranges as YAML profiles. They use
|
the requested profile ID is absent. An invalid matching profile is an error and
|
||||||
`APIKeyRequired` for request-scoped credentials instead of `api_key_env`.
|
does not fall back. In-memory `Profile` values follow the same ranges as YAML
|
||||||
|
profiles. They use `APIKeyRequired` for request-scoped credentials instead of
|
||||||
|
`api_key_env`. Preparation and exact profile inspection use this same source
|
||||||
|
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 custom 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.20260922.1 profiles](https://gitea.maximumdirect.net/eric/promptkit-backend-openrouter/src/tag/v2.20260922.1/catalog/profiles)
|
||||||
| mistral | `mistral-medium-3-5` | `mistralai/mistral-medium-3-5` |
|
- [Rakestrawhome catalog v2.20260922.0 profiles](https://gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/src/tag/v2.20260922.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
|
||||||
|
|
||||||
@@ -285,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.
|
||||||
|
|||||||
@@ -14,10 +14,16 @@ that produce these outbound settings.
|
|||||||
|
|
||||||
Generation sends an HTTP `POST` with `Content-Type: application/json`.
|
Generation sends an HTTP `POST` with `Content-Type: application/json`.
|
||||||
Before the client is called, the engine resolves framework, backend, profile,
|
Before the client is called, the engine resolves framework, backend, profile,
|
||||||
and request values into one execution target. A non-empty endpoint from that
|
and request values into one execution target. Endpoint configuration is trimmed
|
||||||
target overrides the client's configured base URL. After trailing slashes are
|
and must be an absolute HTTP or HTTPS URL with a host and without user
|
||||||
removed, `/chat/completions` is appended. Generation fails before sending when
|
information, a query, or a fragment. A non-empty endpoint from the target
|
||||||
neither source supplies an endpoint.
|
overrides the client's configured base URL. The final selected endpoint is
|
||||||
|
validated again before transport.
|
||||||
|
|
||||||
|
The completion URL is composed through parsed URL path operations. Nested base
|
||||||
|
paths are retained, repeated trailing slashes are normalized, and the result
|
||||||
|
has exactly one appended `/chat/completions` suffix. Generation fails before
|
||||||
|
sending when neither source supplies a valid endpoint.
|
||||||
|
|
||||||
The target's backend ID is routing metadata for prepared values, results, and
|
The target's backend ID is routing metadata for prepared values, results, and
|
||||||
injected clients. The built-in client does not derive the URL from that ID and
|
injected clients. The built-in client does not derive the URL from that ID and
|
||||||
@@ -25,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
|
||||||
@@ -47,18 +55,26 @@ 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.
|
||||||
|
|
||||||
The client conditionally includes:
|
The client conditionally includes:
|
||||||
|
|
||||||
- `temperature`, `max_tokens`, and `top_p` when non-zero or explicitly
|
- `temperature`, `max_tokens`, and `top_p` only when selected by a profile or
|
||||||
present;
|
runtime override, including an explicit runtime zero; they are absent when
|
||||||
|
unspecified;
|
||||||
- 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
|
||||||
@@ -81,13 +97,82 @@ request fields.
|
|||||||
|
|
||||||
## Response Handling
|
## Response Handling
|
||||||
|
|
||||||
Any 2xx response is decoded as an OpenAI-compatible chat response. The client
|
Any 2xx response body is limited to 16 MiB (16,777,216 bytes). A larger
|
||||||
returns the first choice's non-empty message content and maps prompt,
|
declared `Content-Length` is rejected before the body is read, and streamed,
|
||||||
completion, total, cached, and cache-write token counts.
|
chunked, or underreported bodies are read through the same bound with at most
|
||||||
|
one additional byte used to detect overflow. A body exactly at the limit is
|
||||||
|
allowed. The body is closed on every outcome and an oversized stream is not
|
||||||
|
drained.
|
||||||
|
|
||||||
Invalid JSON, absent choices, and empty first-choice content are malformed
|
The bounded body must contain exactly one OpenAI-compatible JSON response
|
||||||
responses. For a non-2xx status, the error includes the status code but never
|
object followed only by JSON whitespace and EOF. The client returns the first
|
||||||
the provider response body.
|
choice's explicitly present string message content, including an empty or
|
||||||
|
whitespace-only string, and maps prompt, completion, total, cached, and
|
||||||
|
cache-write token counts. Invalid or truncated JSON, trailing non-whitespace
|
||||||
|
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.
|
||||||
|
|
||||||
|
Each of the five token fields retains whether the provider reported a
|
||||||
|
non-null integer, including zero. Omitted and `null` fields are unknown for
|
||||||
|
observations. A strictly decoded successful envelope can yield observed usage
|
||||||
|
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
|
||||||
|
identity and the exact transport error for `errors.Is` and `errors.As` checks.
|
||||||
|
The rendered error does not include the selected endpoint, request headers,
|
||||||
|
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
|
||||||
|
|
||||||
@@ -102,5 +187,7 @@ Timeouts are layered:
|
|||||||
timeout when the supplied value is not positive.
|
timeout when the supplied value is not positive.
|
||||||
|
|
||||||
The earliest applicable caller, generation, or transport deadline controls the
|
The earliest applicable caller, generation, or transport deadline controls the
|
||||||
request. Constructing the internal client does not mutate a supplied
|
request. Caller cancellation retains `context.Canceled`; caller, generation,
|
||||||
`http.Client`.
|
and whole-request timeout failures retain `context.DeadlineExceeded`, together
|
||||||
|
with the request-failure identity. Constructing the internal client does not
|
||||||
|
mutate a supplied `http.Client`.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -29,51 +29,73 @@ One pool owns immutable active and total limits plus mutex-protected admission
|
|||||||
count, active count, and ordered waiter list. Pool state exists only for the
|
count, active count, and ordered waiter list. Pool state exists only for the
|
||||||
lifetime of its engine.
|
lifetime of its engine.
|
||||||
|
|
||||||
## Bounded Run Admission
|
## Bounded Execution Admission
|
||||||
|
|
||||||
The runner asks the manager to admit a run after resolving the prompt, profile,
|
For ordinary `Run`, the runner asks the manager to admit after resolving the
|
||||||
selected backend, effective execution target, credentials, and output contract,
|
prompt, profile, selected backend, effective execution target, credentials, and
|
||||||
but before schema loading, artifact loading, or rendering. Admission is
|
output contract, but before schema loading, artifact loading, or rendering.
|
||||||
immediate: a limited pool either reserves a slot or returns the internal
|
`PrepareExecution` performs no admission. `RunPrepared` claims its handle,
|
||||||
`ErrCapacityExceeded` identity. The root facade maps that identity to the
|
rechecks credential availability, and then asks the manager to admit the
|
||||||
public error without treating it as an invalid request or generation failure.
|
frozen backend before generation.
|
||||||
|
|
||||||
|
Admission is immediate: a limited pool either reserves a slot or returns only
|
||||||
|
the internal `ErrCapacityExceeded` identity. The runner attaches the selected
|
||||||
|
backend identity at its use-case boundary, and the root facade translates that
|
||||||
|
typed value without treating it as an invalid request or generation failure.
|
||||||
|
|
||||||
The total admitted bound is the active-generation limit plus its configured
|
The total admitted bound is the active-generation limit plus its configured
|
||||||
waiting capacity. The returned release function is idempotent. The runner
|
waiting capacity. The returned release function is idempotent. The runner
|
||||||
defers it as soon as admission succeeds and holds the lease across remaining
|
defers it as soon as admission succeeds. An ordinary run holds the lease across
|
||||||
preparation, initial generation, validation, every repair attempt, and all
|
remaining preparation, initial generation, validation, every repair attempt,
|
||||||
failure or cancellation exits. A repair is part of its original admission and
|
and all failure or cancellation exits. Prepared execution holds the normal
|
||||||
does not reserve another bounded slot.
|
lease across generation, validation, every internal repair attempt, and all
|
||||||
|
execution exits. A repair is part of its original admission and does not
|
||||||
|
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.
|
backend, credential, numeric-presence metadata, structured-output settings,
|
||||||
|
and retry budget. Scheduling does not change provider omission semantics
|
||||||
|
between 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.
|
for provider transport behavior. The runner, rather than the
|
||||||
|
capacity layer, sums all five usage fields from the initial response and every
|
||||||
|
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
|
||||||
@@ -90,11 +112,19 @@ unlimited admission. The
|
|||||||
FIFO transfer, canceled-waiter removal, grant/cancel races, independent pools,
|
FIFO transfer, canceled-waiter removal, grant/cancel races, independent pools,
|
||||||
unlimited calls, passthrough behavior, and panic release.
|
unlimited calls, passthrough behavior, and panic release.
|
||||||
|
|
||||||
The [runner tests](../../internal/usecase/runner_test.go) own early admission,
|
The [runner tests](../../internal/usecase/runner_test.go) own ordinary early
|
||||||
lease lifetime, failure release, and shared initial/repair scheduling. The
|
admission, lease lifetime, failure release, and shared initial/repair
|
||||||
|
scheduling. The
|
||||||
|
[prepared-execution use-case tests](../../internal/usecase/prepared_execution_test.go)
|
||||||
|
own deferred admission, credential ordering, and prepared-execution lease
|
||||||
|
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)
|
||||||
|
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
|
||||||
the public generation category and context identity when generation is
|
the public generation category and context identity when generation is
|
||||||
canceled.
|
canceled.
|
||||||
|
|||||||
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,38 +21,109 @@ 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 validates the configured base URL and clones any supplied
|
Construction trims and validates a nonempty configured base URL and clones any
|
||||||
`http.Client` so Promptkit can apply its timeout default without mutating the
|
supplied `http.Client` so Promptkit can apply its timeout default without
|
||||||
caller's client. Generation then:
|
mutating the caller's client. An empty configured base remains valid because a
|
||||||
|
resolved request target may supply the endpoint. Generation preparation then:
|
||||||
|
|
||||||
1. validates request-level timeout and endpoint requirements;
|
1. validates shared execution-setting invariants and the final selected base
|
||||||
|
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. resolves authentication;
|
4. composes `/chat/completions` through parsed URL path operations;
|
||||||
5. performs the outbound request under the applicable deadlines; and
|
5. resolves authentication and freezes the URL, body, and optional credential.
|
||||||
6. decodes the first response choice and token usage.
|
|
||||||
|
Each execution of that prepared request creates a fresh HTTP request, body
|
||||||
|
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
|
||||||
|
|
||||||
|
For [`RunPrepared`](../../engine.go), the runner supplies the model client with
|
||||||
|
the target, rendered messages, and structured-output constraint retained by
|
||||||
|
executable preparation. Execution does not reopen or rerender consumer
|
||||||
|
sources.
|
||||||
|
|
||||||
|
Before backend admission, the runner rechecks a frozen credential
|
||||||
|
environment-variable name only when the target explicitly requires a
|
||||||
|
credential. The handle does not retain the environment value; the model client
|
||||||
|
resolves the value visible when generation begins. For optional sources with no
|
||||||
|
usable value, the built-in client omits `Authorization` and continues 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).
|
||||||
|
|
||||||
## Failure Categories
|
## Failure Categories
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
Caller cancellation and deadline failures during the outbound request are
|
Invalid nonempty configured endpoints are configuration failures. A missing or
|
||||||
reported as request execution failures. The runner classifies these identities
|
invalid final selected endpoint is an invalid generation request and is
|
||||||
without depending on HTTP status mapping.
|
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
|
||||||
|
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,
|
||||||
|
truncation, malformed JSON, trailing data, and a second value are malformed
|
||||||
|
responses with no partial result or provider content in the error. Every body
|
||||||
|
is closed, and an unbounded oversized stream is not drained.
|
||||||
|
|
||||||
|
After framing succeeds, the first choice must contain an explicitly present
|
||||||
|
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:
|
||||||
|
the package request-failure sentinel and the exact returned transport error are
|
||||||
|
both available through `errors.Is` and `errors.As`, while the rendered text
|
||||||
|
does not expose the endpoint, headers, request content, credential, transport
|
||||||
|
detail, or provider body. Caller cancellation retains `context.Canceled`;
|
||||||
|
caller deadlines, generation deadlines, and whole-request client timeouts
|
||||||
|
retain `context.DeadlineExceeded`. The runner adds its generation category
|
||||||
|
without discarding those identities or depending on HTTP status mapping.
|
||||||
|
|
||||||
## Test Ownership
|
## Test Ownership
|
||||||
|
|
||||||
@@ -60,7 +131,15 @@ The
|
|||||||
[OpenAI-compatible client tests](../../internal/llm/openai_compatible_client_test.go)
|
[OpenAI-compatible client tests](../../internal/llm/openai_compatible_client_test.go)
|
||||||
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, and response-body suppression. The root transport contract test
|
cancellation, endpoint selection and composition, pre-transport rejection, and
|
||||||
also verifies that resolved backend settings reach this client without
|
bounded single-document successful-response framing, closure, and
|
||||||
serializing backend identity. All use local test servers or test transports;
|
response-body suppression. The focused
|
||||||
the default suite makes no live or paid provider requests.
|
[provider HTTP error tests](../../internal/llm/provider_http_error_test.go)
|
||||||
|
own envelope parsing, normalization, and bounded-reader cases; their
|
||||||
|
[transport tests](../../internal/llm/provider_http_error_transport_test.go)
|
||||||
|
own non-success response closure and integration. Root transport contract tests
|
||||||
|
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 and result values, profile construction, extension interfaces, value conversion, redacted formatting, public error mapping, and engine-local assembly. | [Package GoDoc](../../doc.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/capacity` | Owns engine-local bounded run admission and FIFO model-generation permits for limited backend IDs, including cancellation-safe waiter removal and client wrapping. | [Internal capacity management](capacity.md) |
|
| `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/domain` | Defines internal framework values for requests, artifacts, prompt definitions, profiles, execution targets, rendering, generation, and validation. | [Domain declarations](../../internal/domain/domain.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/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 JSON-compatible extra-parameter trees while preserving supported concrete value types. | [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, and combines it with an optional primary repository. | [Built-in catalog](../formats.md#built-in-profile-catalog), [repository](../../internal/profile/builtin/repository.go) |
|
|
||||||
| `internal/prompt` | Renders prompt messages from Go templates with artifact, variable, session, and cache-control data. | [Go-template renderer](../../internal/prompt/go_renderer.go) |
|
| `internal/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. | [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 backend, profile, and request settings and coordinates preparation and execution across internal sources, rendering, artifact loading, generation, validation, and optional repair. | [Internal runner](runner.md) |
|
| `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,16 +18,47 @@ 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. Schema
|
admitter, and a generation coordinator using the same capacity manager. Validation
|
||||||
documents are loaded through the validator's optional schema-loader interface.
|
plans and provider-facing schema metadata come from the validator's preparation
|
||||||
An output repairer can be injected internally, but the ordinary runner
|
interface.
|
||||||
constructor does not enable one.
|
The internal engine supplies one default output repairer through the explicit
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Shared Prompt Selection
|
||||||
|
|
||||||
|
The runner uses one prompt-selection and hashing boundary for ordinary
|
||||||
|
preparation and exact prompt inspection. Preparation retains its early
|
||||||
|
request-ID check before direct-session normalization; both operations then use
|
||||||
|
the configured prompt repository to select one definition, load referenced
|
||||||
|
message content, and calculate the same prompt hash.
|
||||||
|
|
||||||
|
Inspection stops after that structural lookup. It does not parse templates or
|
||||||
|
touch profile, artifact, schema, renderer, validator, admission, or model
|
||||||
|
collaborators. The root [`Engine.InspectPrompt`](../../engine.go) GoDoc owns
|
||||||
|
the public operation's exact contract.
|
||||||
|
|
||||||
|
## Shared Profile Selection
|
||||||
|
|
||||||
|
The runner uses one profile-selection and target-resolution boundary for
|
||||||
|
ordinary preparation and exact profile inspection. Preparation first selects a
|
||||||
|
request profile or a prompt default; inspection begins with its required
|
||||||
|
explicit profile ID. Both then apply the ordinary source precedence, resolve a
|
||||||
|
named backend, and construct the effective target from framework, backend, and
|
||||||
|
profile values.
|
||||||
|
|
||||||
|
Inspection stops after the resulting endpoint and model are structurally
|
||||||
|
validated. It does not check credential availability or perform prompt,
|
||||||
|
artifact, schema, rendering, admission, or model-client work. The root
|
||||||
|
[`Engine.InspectProfile`](../../engine.go) GoDoc owns the public operation's
|
||||||
|
exact contract.
|
||||||
|
|
||||||
## Shared Preparation Pipeline
|
## Shared Preparation Pipeline
|
||||||
|
|
||||||
`Prepare` and `Run` share one private preparation pipeline split at the point
|
`Prepare` and `Run` share one private preparation pipeline split at the point
|
||||||
@@ -41,17 +72,20 @@ performs only the work needed to validate routing and admission:
|
|||||||
5. resolve application-neutral defaults, backend defaults, profile values,
|
5. resolve application-neutral defaults, backend defaults, profile values,
|
||||||
and explicit request overrides in that order;
|
and explicit request overrides in that order;
|
||||||
6. validate endpoint, model, numeric overrides, and credential requirements;
|
6. validate endpoint, model, numeric overrides, and credential requirements;
|
||||||
7. resolve the effective output contract without loading its schema; and
|
7. resolve and validate the effective output contract without loading its
|
||||||
|
schema; and
|
||||||
8. retain the definition, source identities, effective settings, output
|
8. retain the definition, source identities, effective settings, output
|
||||||
contract, and preparation start time in invocation-local state.
|
contract, and preparation start time in invocation-local state.
|
||||||
|
|
||||||
The completion phase consumes that state without reloading the prompt,
|
The completion phase consumes that state without reloading the prompt,
|
||||||
profile, or backend:
|
profile, or backend:
|
||||||
|
|
||||||
1. load structured-output schema metadata when required;
|
1. create one operation-local validation plan and derive structured-output
|
||||||
|
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.
|
||||||
|
|
||||||
@@ -59,7 +93,10 @@ profile, or backend:
|
|||||||
`Run` performs backend admission between the phases. This structure preserves
|
`Run` performs backend admission between the phases. This structure preserves
|
||||||
one execution-precedence and error-ordering implementation while allowing a
|
one execution-precedence and error-ordering implementation while allowing a
|
||||||
full backend pool to reject work before expensive schema, artifact, and
|
full backend pool to reject work before expensive schema, artifact, and
|
||||||
rendering operations.
|
rendering operations. `Prepare` discards the plan after returning its public
|
||||||
|
metadata. `Run` retains the plan through initial and repaired-output validation
|
||||||
|
and discards it when the operation ends. Prepared execution stores the same
|
||||||
|
kind of plan only in its private payload.
|
||||||
|
|
||||||
Pointer-based numeric overrides preserve an explicit zero. Invalid negative or
|
Pointer-based numeric overrides preserve an explicit zero. Invalid negative or
|
||||||
out-of-range values fail as invalid requests. Endpoint overrides do not change
|
out-of-range values fail as invalid requests. Endpoint overrides do not change
|
||||||
@@ -77,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
|
||||||
@@ -90,31 +130,67 @@ its `RunAdmitter` to reserve capacity for the effective backend ID. A nil
|
|||||||
admitter is an internal unlimited fallback. After successful admission, `Run`
|
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. Invalid generated content remains a validation
|
and validates that artifact with the plan compiled during completion. Invalid
|
||||||
result; an inability to perform validation is an operational error.
|
generated content remains a validation result; an inability to generate or
|
||||||
|
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
|
||||||
|
Promptkit-controlled boundary and do not publish a partial plan or result.
|
||||||
|
Schema reads are bounded and context-checked between chunks; JSON decoding,
|
||||||
|
schema compilation, and schema execution are checked immediately before and
|
||||||
|
after their synchronous calls. Promptkit does not move arbitrary filesystem or
|
||||||
|
JSON Schema work to background goroutines, so an already-blocked dependency
|
||||||
|
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 and session ID, validation errors, prior output, and structured-output
|
starts with a fresh copy of the complete effective message sequence (the
|
||||||
specification. The default repairer uses the same wrapped client as initial
|
configured prefix followed by the request suffix), includes only the latest
|
||||||
|
nonempty candidate as an assistant message, and appends one corrective user
|
||||||
|
message. Empty candidates omit that assistant message. The
|
||||||
|
correction carries validation diagnostics as JSON data bounded to 64 KiB; the
|
||||||
|
full diagnostics remain in the validation result.
|
||||||
|
|
||||||
|
Repair receives the effective execution target, explicit numeric-presence bits,
|
||||||
|
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
|
generation, so each repair reacquires the selected backend's active permit
|
||||||
while remaining inside its original admission lease. Repair never performs a
|
while remaining inside its original admission lease. Repair never performs a
|
||||||
second bounded admission. This capability remains internal and is not a public
|
second bounded admission, and repaired outputs use the operation's existing
|
||||||
option.
|
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
|
||||||
profile and backend, effective settings, input hashes, token usage, a generated
|
profile and backend, effective settings, input hashes, token usage, a generated
|
||||||
run identifier, and UTC timing. The same effective session reaches initial
|
run identifier, and UTC timing. The same effective session reaches initial
|
||||||
generation and any repair attempt through the rendered prompt. The same
|
generation and any repair attempt through the rendered prompt. The same
|
||||||
effective target, including backend identity, reaches generation and any
|
effective target and presence metadata, including backend identity and direct
|
||||||
repair attempt.
|
credential during execution, reaches generation and every repair attempt.
|
||||||
|
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
|
||||||
|
validation state still come from the last candidate. A repair error returns no
|
||||||
|
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
|
||||||
|
|
||||||
@@ -124,18 +200,21 @@ validation failures. Wrapping preserves the package identities mapped by the
|
|||||||
public facade and retains collaborator identities where they are part of the
|
public facade and retains collaborator identities where they are part of the
|
||||||
internal contract.
|
internal contract.
|
||||||
|
|
||||||
Admission capacity exhaustion retains the internal capacity identity and adds
|
Admission capacity exhaustion retains the internal capacity identity. At the
|
||||||
the selected backend ID as context. It is not recategorized as an invalid
|
use-case boundary, the runner attaches the selected backend ID in an internal
|
||||||
request or generation failure, and no partial result is returned. A context
|
typed error, and the root facade copies that value into the public
|
||||||
already done at admission retains its context identity directly. Cancellation
|
[`CapacityError`](../../capacity_error.go) without parsing diagnostic text. It
|
||||||
while waiting for an active generation permit prevents client invocation when
|
is not recategorized as an invalid request or generation failure, and no
|
||||||
it wins the grant race; the model-client boundary then preserves the context
|
partial result is returned. A context already done at admission retains its
|
||||||
error through the generation-failure category. Deferred release restores the
|
context identity directly. Cancellation while waiting for an active generation
|
||||||
admission lease on preparation, generation, validation, repair, and
|
permit prevents client invocation when it wins the grant race; the model-client
|
||||||
cancellation failures.
|
boundary then preserves the context error through the generation-failure
|
||||||
|
category. Deferred release restores the admission lease on preparation,
|
||||||
|
generation, validation, repair, and cancellation failures.
|
||||||
|
|
||||||
Other context cancellation propagates through the invoked collaborator and is
|
Other context cancellation propagates through the invoked collaborator and is
|
||||||
classified by the owning operation.
|
classified by the owning operation. In particular, cancellation observed by
|
||||||
|
validation retains the context identity through the validation error category.
|
||||||
An overlong direct session is an invalid request before source loading, while
|
An overlong direct session is an invalid request before source loading, while
|
||||||
an invalid or overlong prompt session template remains a prompt-render failure.
|
an invalid or overlong prompt session template remains a prompt-render failure.
|
||||||
An unknown selected backend, or a selected backend with no configured resolver,
|
An unknown selected backend, or a selected backend with no configured resolver,
|
||||||
@@ -147,10 +226,12 @@ The [runner tests](../../internal/usecase/runner_test.go) own preparation order,
|
|||||||
selection and override precedence, the two-phase boundary, early admission,
|
selection and override precedence, the two-phase boundary, early admission,
|
||||||
lease lifetime and release, direct-session resolution, schema-before-generation
|
lease lifetime and release, direct-session resolution, schema-before-generation
|
||||||
behavior, hashing, generation and validation outcomes, backend propagation,
|
behavior, hashing, generation and validation outcomes, backend propagation,
|
||||||
bounded repair, shared initial/repair capacity, credentials and redaction,
|
bounded repair progression, initial/repair request parity, cumulative usage,
|
||||||
error categories, artifact metadata, usage, and timing. The
|
shared initial/repair capacity, credentials and redaction, error categories,
|
||||||
[capacity subsystem document](capacity.md) identifies the focused pool,
|
artifact metadata, and timing. The
|
||||||
waiter, and wrapped-client tests.
|
[capacity subsystem document](capacity.md) identifies the focused pool and
|
||||||
|
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,15 +6,38 @@ 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.
|
||||||
|
|
||||||
## Prompt Definitions
|
## Prompt Definitions
|
||||||
|
|
||||||
`internal/promptdef` discovers YAML deterministically, decodes and validates
|
`internal/promptdef` uses one source-neutral flow for prompt selection and
|
||||||
definitions, selects an ID and optional version, and resolves file-backed
|
normalization. That flow scans normalized YAML ID and version metadata,
|
||||||
message content within the selected operating-system or `fs.FS` source.
|
requires one strictly decoded document per file, classifies errors for the
|
||||||
|
selected definition, detects duplicates, and normalizes the exact match.
|
||||||
|
Small operating-system and `fs.FS` adapters own discovery, byte reads, display
|
||||||
|
paths, content opening, and root containment. Each lookup remains a
|
||||||
|
point-in-time scan: definitions and catalogs are not cached, and file-backed
|
||||||
|
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
|
||||||
|
targets so symlinks cannot escape. Injected `fs.FS` sources enforce containment
|
||||||
|
in their clean relative path namespace. A single-file source uses the selected
|
||||||
|
prompt file's containing directory as its root. Every content path must be
|
||||||
|
relative and is opened from its exact parsed text after a separate blank check;
|
||||||
|
contained parent components and whitespace-bearing names remain valid.
|
||||||
|
|
||||||
|
Exact prompt inspection performs one point-in-time lookup through that same
|
||||||
|
repository and validates referenced message content before returning declared
|
||||||
|
metadata. It does not parse templates or read profile, input, or schema
|
||||||
|
sources, and it does not retain the definition for a later execution.
|
||||||
|
|
||||||
Its package tests own prompt selection, strict decoding, definition validation,
|
Its package tests own prompt selection, strict decoding, definition validation,
|
||||||
duplicate detection, and source containment:
|
duplicate detection, and source containment:
|
||||||
@@ -22,30 +45,88 @@ 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`. It supports a primary repository
|
validation, overlays, and resolution for execution profiles. In-memory
|
||||||
with fallback only when the primary reports that a profile is absent. Strict
|
definitions are normalized and validated once at that repository boundary and
|
||||||
YAML decoding recognizes the optional `backend` field, trims its value, and
|
are defensively copied on storage and lookup. A file contains
|
||||||
requires a model plus at least one non-blank backend or endpoint. Loading does
|
exactly one YAML document and its trimmed YAML `id` is its only selection
|
||||||
not check registry membership because the available registry belongs to the
|
identity; filenames do not confer authority. Each point lookup reads discovered
|
||||||
assembled engine; the runner checks membership during preparation.
|
files once for their metadata and reuses the selected file's bytes for strict
|
||||||
|
decoding; unrelated profiles are not fully decoded. Strict selected decoding
|
||||||
|
recognizes `base_profile` and the optional `backend` field, trims their values,
|
||||||
|
and permits inherited target fields only when a base is named. File-backed
|
||||||
|
`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.
|
||||||
|
|
||||||
`internal/profile/builtin` embeds the maintained built-in profile catalog and
|
`LoadFSRepository` is the eager immutable loading boundary for internal
|
||||||
can place a caller-selected repository ahead of that catalog. Every embedded
|
catalog consumers. It discovers and strictly validates every raw profile once,
|
||||||
profile selects `openrouter` and inherits its endpoint and credential
|
preserves safe source metadata including explicitly present YAML fields, and
|
||||||
environment-variable name from the built-in backend registry rather than
|
publishes independently copied values from memory. It does not resolve profile
|
||||||
repeating those values. Profile behavior is owned by the
|
inheritance. Configured consumer sources continue to use the lazy point lookup
|
||||||
[profile repository tests](../../internal/profile/repository_test.go), while
|
repositories described above.
|
||||||
catalog completeness, the backend-selection invariant, duplicate IDs, and
|
|
||||||
overlay behavior are owned by the
|
`internal/catalog` validates the imported immutable OpenRouter and
|
||||||
[built-in repository tests](../../internal/profile/builtin/repository_test.go).
|
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
|
||||||
|
higher-precedence repository reports that a profile is absent. A reliably
|
||||||
|
selected malformed profile stops fallback, while an unrelated malformed file
|
||||||
|
does not become authoritative through its filename. Loading does not check
|
||||||
|
backend registry membership because the available registry belongs to the
|
||||||
|
assembled engine; the runner checks membership during preparation and exact
|
||||||
|
profile inspection.
|
||||||
|
|
||||||
|
The internal engine assembles one raw composite catalog in precedence order:
|
||||||
|
in-memory profiles, one ordinary configured source, an application fallback
|
||||||
|
source, Promptkit-owned profiles, then the maintained external catalog. It
|
||||||
|
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.
|
||||||
|
|
||||||
|
The resolving repository traverses every selected chain afresh, retains no
|
||||||
|
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.
|
||||||
|
Prepared execution instead freezes the fully resolved target; a later ordinary
|
||||||
|
operation performs a fresh traversal.
|
||||||
|
|
||||||
|
The imported external catalog provides external profiles and matching backend
|
||||||
|
definitions. The internal engine owns the Promptkit profiles documented in the
|
||||||
|
[framework format reference](../formats.md#built-in-profile-catalog). Profile
|
||||||
|
loading and overlay behavior are owned by the [profile repository tests](../../internal/profile/repository_test.go), while
|
||||||
|
catalog completeness, backend selection, and duplicate IDs are owned by the
|
||||||
|
[catalog adapter tests](../../internal/catalog/catalog_test.go).
|
||||||
|
|
||||||
## Ordinary Artifacts
|
## Ordinary Artifacts
|
||||||
|
|
||||||
`internal/artifact` resolves inline references and unrestricted,
|
`internal/artifact` accepts explicitly typed inline references even when their
|
||||||
caller-selected file paths. It copies content into an artifact, records
|
body is empty. It also resolves unrestricted, caller-selected paths only when
|
||||||
metadata and a content hash, applies a content-type fallback, and honors
|
they identify regular operating-system files, checking that condition before
|
||||||
context cancellation.
|
and after opening the file. It copies content into an artifact, records
|
||||||
|
metadata and an opaque content-equality value, and applies a content-type
|
||||||
|
fallback.
|
||||||
|
|
||||||
|
Regular files are read synchronously in bounded chunks. Cancellation is
|
||||||
|
checked before opening, before and after every read, and before publishing the
|
||||||
|
artifact, so a canceled read never publishes partial content. The ordinary
|
||||||
|
reader does not detach file reads into background goroutines.
|
||||||
|
|
||||||
This ordinary reader does not implement an inbound HTTP security boundary. In
|
This ordinary reader does not implement an inbound HTTP security boundary. In
|
||||||
particular, it does not constrain files to an application root or impose an
|
particular, it does not constrain files to an application root or impose an
|
||||||
@@ -57,7 +138,17 @@ implemented reader behavior and failures.
|
|||||||
## Rendering
|
## Rendering
|
||||||
|
|
||||||
`internal/prompt` renders definition messages as Go templates using named
|
`internal/prompt` renders definition messages as Go templates using named
|
||||||
artifacts and variables. It carries message roles, session IDs, and cache
|
artifacts and variables. Within one render, each referenced artifact body is
|
||||||
|
converted to text lazily and cached by input name for reuse across the session
|
||||||
|
and every message; the cache is not shared across renders. Conversion uses
|
||||||
|
bounded chunks and preserves the artifact bytes exactly.
|
||||||
|
|
||||||
|
Session and message parsing and execution remain synchronous. The renderer
|
||||||
|
checks cancellation before and after each parse and execution boundary,
|
||||||
|
between artifact conversion chunks, around each message, and before publishing
|
||||||
|
the complete prompt. It cannot interrupt template work already in progress and
|
||||||
|
never publishes a partial prompt after observing cancellation. It validates and
|
||||||
|
canonicalizes message roles before carrying roles, session IDs, and cache
|
||||||
control into the rendered prompt. The
|
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.
|
||||||
|
|
||||||
@@ -68,6 +159,35 @@ filesystem or an `fs.FS`. Invalid generated content is returned as a validation
|
|||||||
result; inability to load, register, or compile a schema is an operational
|
result; inability to load, register, or compile a schema is an operational
|
||||||
error.
|
error.
|
||||||
|
|
||||||
|
Every preparation operation creates one operation-local validation plan. None,
|
||||||
|
basic, and JSON modes retain the effective output contract without source
|
||||||
|
access. JSON Schema mode loads the root document once, resolves and compiles
|
||||||
|
each transitive reference, and retains the compiled validator. Schema compiler
|
||||||
|
resources use canonical escaped file or private-scheme URLs; loaders decode
|
||||||
|
their paths once and enforce the configured source boundary. The
|
||||||
|
provider-facing structured-output metadata uses the root document captured by
|
||||||
|
the same plan.
|
||||||
|
|
||||||
|
Schema preparation and execution remain synchronous. Promptkit checks
|
||||||
|
cancellation before and after source resolution, JSON decoding, compilation,
|
||||||
|
and validation, and between bounded schema-read chunks. Once cancellation is
|
||||||
|
observed it returns the context error without publishing a partial plan or
|
||||||
|
validation result, even when a compiler or validator has just returned a
|
||||||
|
different error or a successful result. An `fs.FS` method or JSON Schema
|
||||||
|
dependency call already in progress cannot be preempted; Promptkit waits for
|
||||||
|
that call to return and then gives cancellation precedence. Validation does
|
||||||
|
not detach dependency work into background goroutines.
|
||||||
|
|
||||||
|
`Prepare` discards its validation plan after returning metadata. `Run` retains
|
||||||
|
its plan for initial and repaired-output validation, then discards it with the
|
||||||
|
operation. `PrepareExecution` retains the plan in its private frozen payload;
|
||||||
|
`RunPrepared` uses that plan without reopening prompt, profile, input, or
|
||||||
|
schema sources or rerendering the request. A later ordinary `Run` always
|
||||||
|
performs fresh source resolution and preparation.
|
||||||
|
|
||||||
The [validator tests](../../internal/validate/standard_validator_test.go) own
|
The [validator tests](../../internal/validate/standard_validator_test.go) own
|
||||||
basic, JSON, JSON Schema, source resolution, schema loading, compilation, and
|
basic, JSON, JSON Schema, source resolution, schema loading, compilation,
|
||||||
content-failure behavior.
|
frozen-reference behavior, content-failure behavior, and the synchronous
|
||||||
|
cancellation boundary. Prepared execution
|
||||||
|
orchestration is owned by the
|
||||||
|
[use-case tests](../../internal/usecase/prepared_execution_test.go).
|
||||||
|
|||||||
88
docs/policy/agents.md
Normal file
88
docs/policy/agents.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Agent Policy
|
||||||
|
|
||||||
|
**Base Policy Version:** 1.1
|
||||||
|
|
||||||
|
**Last Updated:** 2026-09-23
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Agents must follow these house rules before recommending, planning, or
|
||||||
|
implementing repository changes.
|
||||||
|
|
||||||
|
## Implementation Discipline
|
||||||
|
|
||||||
|
Write the simplest idiomatic code that satisfies the user's request.
|
||||||
|
Simplify the mechanism, not the user's choices. Do not implicitly strengthen
|
||||||
|
requirements, restrictions, or guarantees.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**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. Existing
|
||||||
|
explicit contracts remain authoritative; changing or weakening one requires a
|
||||||
|
deliberate decision rather than being inferred from this principle.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Do not invent guarantees.** Do not strengthen public contracts, guarantees,
|
||||||
|
or invariants beyond those expressly requested by the user.
|
||||||
|
|
||||||
|
**Use the standard library.** Prefer simple, idiomatic standard-library
|
||||||
|
implementations when they satisfy the requested behavior. Do not add custom
|
||||||
|
power-loss protection or defenses against privileged concurrent filesystem
|
||||||
|
actors unless expressly required. Preserve existing repository contracts.
|
||||||
|
|
||||||
|
## Git Commit Policy
|
||||||
|
|
||||||
|
By default, create a commit after completing each coherent 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. Do not commit after every intermediate edit or create empty commits.
|
||||||
|
Complete the appropriate validation before committing. If work is incomplete or
|
||||||
|
validation is blocked, report that state rather than presenting it as completed.
|
||||||
|
|
||||||
|
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. Do not disturb unrelated
|
||||||
|
staged or unstaged work.
|
||||||
|
4. Review `git diff --staged` and run appropriate checks for the change. Do not
|
||||||
|
repeat checks already passed for unchanged content merely to create a commit.
|
||||||
|
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,5 +1,9 @@
|
|||||||
# Architecture Policy
|
# Architecture Policy
|
||||||
|
|
||||||
|
**Base Policy Version:** 1.0
|
||||||
|
|
||||||
|
**Last Updated:** 2026-09-23
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
This document defines Promptkit's current high-level architecture and the
|
This document defines Promptkit's current high-level architecture and the
|
||||||
@@ -17,53 +21,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 shared by later internal
|
The internal engine assembles one immutable backend registry, one capacity manager,
|
||||||
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.
|
||||||
|
|
||||||
@@ -92,6 +68,13 @@ coordinates internal components and adapts the supported public extension
|
|||||||
interfaces to narrow internal abstractions. Internal components must not depend
|
interfaces to narrow internal abstractions. Internal components must not depend
|
||||||
on consumers or on Scriptorium.
|
on consumers or on Scriptorium.
|
||||||
|
|
||||||
|
`internal/domain` owns source-neutral invariants for values shared across
|
||||||
|
multiple input and execution boundaries, including execution-setting bounds,
|
||||||
|
OpenAI-compatible base endpoints, session identifiers, and output-contract
|
||||||
|
legality. Callers retain source parsing, required-field rules, other
|
||||||
|
source-specific normalization, defaulting, error classification, and policy
|
||||||
|
specific to their own boundary.
|
||||||
|
|
||||||
## Repository And Consumer Boundary
|
## Repository And Consumer Boundary
|
||||||
|
|
||||||
Scriptorium is a downstream application that consumes Promptkit through
|
Scriptorium is a downstream application that consumes Promptkit through
|
||||||
@@ -118,8 +101,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.
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# Documentation Policy
|
# Documentation Policy
|
||||||
|
|
||||||
|
**Base Policy Version:** 1.1
|
||||||
|
|
||||||
|
**Last Updated:** 2026-09-23
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
This policy assigns each Promptkit documentation topic to one canonical owner.
|
This policy assigns each Promptkit documentation topic to one canonical owner.
|
||||||
@@ -68,17 +72,19 @@ mechanisms, not secret values.
|
|||||||
|
|
||||||
| 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. |
|
| Project orientation | `README.md` | What Promptkit is, its current usability, module identity, license summary, a minimal successful quickstart, and links onward. | Exact API contracts, contributor procedures, architecture detail, and release steps. |
|
||||||
| 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 workflow | `docs/development.md` | Task-oriented reading guide, local workflow, validation commands, and repository hygiene. | Architecture rules, API semantics, subsystem behavior, and release procedure. |
|
||||||
| 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. |
|
| 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. |
|
||||||
|
| Agent policy | `docs/policy/agents.md` | Agent implementation discipline, proportionality, idiomatic simplicity, local commit authorization and discipline, and rules against inventing restrictions, guarantees, or repository policy. | Public contracts, architecture, testing, and contributor procedures. |
|
||||||
| Documentation organization | `docs/policy/documentation.md` | Documentation ownership, audience boundaries, maintenance rules, and document lifecycle. | Library architecture or runtime behavior. |
|
| Documentation organization | `docs/policy/documentation.md` | Documentation ownership, audience boundaries, maintenance rules, and document lifecycle. | Library architecture or runtime behavior. |
|
||||||
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk-based sufficiency, test boundaries, doubles, coverage guidance, regression policy, and test maintenance. | Subsystem behavior, exact public contracts, subsystem-specific test inventories, and implementation plans. |
|
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk-based sufficiency, test boundaries, doubles, coverage guidance, regression policy, and test maintenance. | Subsystem behavior, exact public contracts, subsystem-specific test inventories, and implementation plans. |
|
||||||
| Release procedure | `docs/release.md`, when present | Required release validation, version and tag procedure, release ordering, and post-publication checks. | General contributor workflow, public API semantics, and decision history. |
|
| Release policy | `docs/policy/release.md` | Versioning, compatibility, release-note requirements, validation obligations, immutability, recovery, and adoption scope. | Executable commands, publication mechanics, and historical release summaries. |
|
||||||
|
| Release procedure | `docs/release.md` | Maintainer prerequisites, candidate preparation, concrete publication guards and commands, verification, and recovery steps. | Release-policy requirements, public API semantics, and historical summaries. |
|
||||||
| 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. |
|
| Consumer guidance | `docs/consumers/`, when consumer workflows require dedicated guidance | Task-oriented use of implemented public APIs, minimal examples, and consumer responsibilities. | Exact exported declarations and internal mechanics. |
|
||||||
| Durable integration contracts | `docs/integrations/`, when integrations exist | External formats and protocols, compatibility behavior, and upstream or downstream responsibilities. | Internal transformations and public Go declarations. |
|
| Durable integration contracts | `docs/integrations/`, when integrations exist | External formats and protocols, compatibility behavior, and upstream or downstream responsibilities. | Internal transformations and public Go declarations. |
|
||||||
| 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. |
|
| Release-note history | `docs/releases/` | Mandatory version-matched historical summaries for releases made under the adopted release policy; the note at the immutable tag records that release. | Current APIs, behavior, formats, compatibility contracts, and release procedures. |
|
||||||
| Implemented component inventory | `docs/internal/overview.md` | Current packages and components, their implemented responsibilities, and links to focused internal documents. | Normative architecture, contributor workflow, external contracts, and proposed components. |
|
| Implemented component inventory | `docs/internal/overview.md` | Current packages and components, their implemented responsibilities, and links to focused internal documents. | Normative architecture, contributor workflow, external contracts, and proposed components. |
|
||||||
| Internal subsystem behavior | Other files under `docs/internal/`, when a subsystem needs durable detail | Implementation flow, internal collaborators and state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, public API definitions, and future package plans. |
|
| Internal subsystem behavior | Other files under `docs/internal/`, when a subsystem needs durable detail | Implementation flow, internal collaborators and state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, public API definitions, and future package plans. |
|
||||||
| Architectural decision history | `docs/adr/`, when repository-local decisions require records | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, and task sequencing. |
|
| Architectural decision history | `docs/adr/`, when repository-local decisions require records | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, and task sequencing. |
|
||||||
@@ -110,22 +116,22 @@ but must link to its canonical definition rather than restate it.
|
|||||||
The [framework format reference](../formats.md) owns exact prompt, profile, and
|
The [framework format reference](../formats.md) owns exact prompt, profile, and
|
||||||
schema-file contracts. Integration documents own external wire formats.
|
schema-file contracts. Integration documents own external wire formats.
|
||||||
|
|
||||||
### Supplemental Release Guidance
|
### Release Documentation
|
||||||
|
|
||||||
Files under `docs/releases/` may provide changelog-style summaries and
|
The [release policy](release.md) owns release requirements; the
|
||||||
migration guidance for a particular release. They are navigation and
|
[repository release procedure](../release.md) owns their concrete execution.
|
||||||
orientation aids, not canonical owners of public APIs, behavior, formats,
|
Version-matched checked-in notes are required for future releases under that
|
||||||
integrations, architecture, release procedure, or other durable facts. When a
|
policy. The source tag is the release identity, and the note in its immutable
|
||||||
reader needs detail beyond a short release-specific note, the release document
|
tree is the historical summary; annotated tag messages complement that record.
|
||||||
must link to the applicable canonical documentation rather than reproduce its
|
|
||||||
contract.
|
|
||||||
|
|
||||||
The annotated tag message required by the
|
Release notes do not own current API or behavior contracts. Link to the
|
||||||
[release procedure](../release.md#write-the-release-note) remains the
|
canonical owners for details instead of reproducing those contracts. Published
|
||||||
authoritative release record. Supplemental release documents may be corrected,
|
notes are not disposable guidance and must not be replaced under the same
|
||||||
consolidated, archived, or removed at any time when they are no longer useful,
|
version. Corrections belong in a subsequent release.
|
||||||
provided maintained documentation does not depend on them and the annotated
|
|
||||||
tag record remains intact.
|
Adoption is prospective. Previously published tags and historical notes retain
|
||||||
|
their original records and are not reformatted or rewritten to satisfy the new
|
||||||
|
requirements.
|
||||||
|
|
||||||
### Security Topics
|
### Security Topics
|
||||||
|
|
||||||
@@ -178,9 +184,8 @@ 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:
|
Before completing documentation work:
|
||||||
|
|
||||||
|
|||||||
146
docs/policy/release.md
Normal file
146
docs/policy/release.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# Release Policy
|
||||||
|
|
||||||
|
**Base Policy Version:** 1.0
|
||||||
|
|
||||||
|
**Last Updated:** 2026-09-23
|
||||||
|
|
||||||
|
## Purpose And Adoption Scope
|
||||||
|
|
||||||
|
This policy owns Promptkit's release requirements and compatibility rules.
|
||||||
|
The [release procedure](../release.md) owns the commands used to satisfy them.
|
||||||
|
This scoped adoption applies to releases prepared after its adoption on
|
||||||
|
2026-09-23. Published tags and historical notes are not rewritten to retrofit
|
||||||
|
the new note format.
|
||||||
|
|
||||||
|
## Release Model And Compatibility
|
||||||
|
|
||||||
|
Promptkit publishes source-only Go module releases from committed, pushed
|
||||||
|
`main` candidates through annotated `vMAJOR.MINOR.PATCH` tags. It publishes
|
||||||
|
no binaries, hosted releases, or other artifacts and uses maintainer-run
|
||||||
|
validation rather than hosted CI. Versions must be unused locally and on
|
||||||
|
`origin`; the existing procedure accepts stable version tags only.
|
||||||
|
|
||||||
|
Before v1, minor releases may change the public API and patches preserve
|
||||||
|
compatibility within their minor line, subject to the catalog-data provision
|
||||||
|
below. Release notes must identify compatibility impact, public API changes,
|
||||||
|
and required consumer action. The source tag identifies the exact release;
|
||||||
|
the checked-in note at that tag records its historical summary.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Release Documentation
|
||||||
|
|
||||||
|
Every new release must include `docs/releases/<version>.md` in the tagged
|
||||||
|
source, using the complete tag as the filename. Its title contains Promptkit
|
||||||
|
and that exact version. Use this structure:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Promptkit vMAJOR.MINOR.PATCH
|
||||||
|
|
||||||
|
This release ...
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
```
|
||||||
|
|
||||||
|
State “No action required” or “None” where appropriate rather than omitting a
|
||||||
|
section. Keep exact current contracts in their canonical documents and link
|
||||||
|
to them. Update affected canonical documentation in the same candidate.
|
||||||
|
|
||||||
|
These notes are historical records, not optional disposable guidance. Published
|
||||||
|
release-note contents must not be replaced under the same version. Corrections
|
||||||
|
belong in a subsequent release. Annotated-tag messages complement the checked-in
|
||||||
|
note: generate the annotation from that note and the validated commit rather
|
||||||
|
than maintaining a second summary.
|
||||||
|
|
||||||
|
## Validation And Publication
|
||||||
|
|
||||||
|
The [publication script](../../scripts/release.sh) runs the
|
||||||
|
[local checks](../../scripts/release-check.sh) once for the final candidate.
|
||||||
|
Retain clean-candidate, synchronized-main, unused-version, module-hygiene,
|
||||||
|
release-note structure, and tag-target checks. Publish only the selected tag
|
||||||
|
ref. Changing the candidate requires fresh validation. No provider endpoint
|
||||||
|
probes or inference calls are release checks.
|
||||||
|
|
||||||
|
Release-note content and retirement of completed roadmaps are preparation
|
||||||
|
responsibilities, not separate human sign-off gates. Preserve planned and
|
||||||
|
deferred work and repair links. Ordinary change review covers unintended files,
|
||||||
|
secrets, and private data; do not repeat it as a release-specific approval.
|
||||||
|
|
||||||
|
A successful tag push to origin completes the release. Never wait for CI to
|
||||||
|
finish or succeed. CI results, remote read-back, and Go module availability
|
||||||
|
are informational follow-ups, not completion gates. An ambiguous push result
|
||||||
|
requires confirming publication on origin, not waiting for CI.
|
||||||
|
|
||||||
|
## Immutability And Failure Recovery
|
||||||
|
|
||||||
|
Never move, replace, recreate, or delete a published tag or replace published
|
||||||
|
release contents. If validation fails before publication, correct the candidate,
|
||||||
|
rerun the complete checks, and repeat the publication guards. Remove an
|
||||||
|
unpublished local tag only after confirming it was not published.
|
||||||
|
|
||||||
|
If publication or subsequent investigation reveals a defect, leave the
|
||||||
|
published release intact. Correct the defect on main, select a new patch or
|
||||||
|
other semantically appropriate version, write its matching release note, and
|
||||||
|
repeat the complete procedure. If publication status is uncertain, inspect
|
||||||
|
the authoritative remote before deciding whether a tag is unpublished; do not
|
||||||
|
overwrite it to retry.
|
||||||
|
|
||||||
|
Transient network, authentication, CI, or module-availability failures do not
|
||||||
|
by themselves warrant a new version. Investigate separately and publish a
|
||||||
|
corrective release only when published contents actually need changing.
|
||||||
|
|
||||||
|
## Policy Exceptions And Deliberate Scope
|
||||||
|
|
||||||
|
This adoption follows the maintainer-approved lean workflow rather than every
|
||||||
|
base-policy guard:
|
||||||
|
|
||||||
|
- Collision checks concern origin, the authoritative publication destination,
|
||||||
|
rather than every configured remote. Local and origin tag checks, synchronized
|
||||||
|
main, and an explicit selected-ref push protect the actual release channel.
|
||||||
|
- Automated local validation and publication-state checks replace manual
|
||||||
|
mechanical inspections and separate human sign-offs. Ordinary change review,
|
||||||
|
editorial responsibility for release notes, and roadmap housekeeping remain;
|
||||||
|
repeating them as approvals would add friction without distinct protection.
|
||||||
|
- Successful tag publication completes this source-only release. CI can be slow
|
||||||
|
or unreliable, and module availability depends on external services. Neither
|
||||||
|
is a completion gate. Local candidate validation and explicit tag-target
|
||||||
|
checks protect publication; ambiguous push results are resolved against origin.
|
||||||
|
Actual defects are corrected in a new release, never by replacing a tag.
|
||||||
|
|
||||||
|
Ignored local files are not prohibited merely because they exist: they are not
|
||||||
|
included in the tagged commit. The clean-candidate guard and ordinary review of
|
||||||
|
tracked changes protect the published contents.
|
||||||
|
|
||||||
|
The catalog profile provision preserves an already approved compatibility
|
||||||
|
boundary; it does not extend volatility to backend definitions or runtime
|
||||||
|
contracts. Further release-governance changes require deliberate approval.
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# Testing Policy
|
# Testing Policy
|
||||||
|
|
||||||
|
**Base Policy Version:** 1.0
|
||||||
|
|
||||||
|
**Last Updated:** 2026-09-23
|
||||||
|
|
||||||
## 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**.
|
||||||
@@ -50,19 +54,25 @@ Examples of appropriate seams include clocks, randomness, subprocesses, remote A
|
|||||||
## 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 repository-documented test, vet, build, formatting,
|
Ordinary work requires validation proportionate to its scope and risk. Do not
|
||||||
documentation-link, and repository-hygiene checks before accepting changes.
|
repeat successful checks for unchanged content merely to create a commit.
|
||||||
|
Release candidates require the complete local workflow in the
|
||||||
|
[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.
|
||||||
|
|
||||||
The complete test sequence includes ordinary and race-enabled package tests.
|
Full release-candidate validation includes race-enabled package tests, explicit
|
||||||
The maintained offline consumer workflow is also run from the repository root:
|
static analysis, and execution with automated semantic checks of the three maintained
|
||||||
|
offline consumer examples. The test run builds the packages; the examples
|
||||||
```sh
|
also exercise ordinary compilation. Separate ordinary tests and a standalone
|
||||||
go test ./...
|
build are not additional release gates. Ordinary tests remain useful during
|
||||||
go test -race ./...
|
development. The preparation example protects assembled preparation and
|
||||||
go run ./examples/go-library/prepare
|
inspection behavior. The execution example separately protects assembled
|
||||||
```
|
`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
|
||||||
@@ -88,8 +98,9 @@ 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 example protects one representative
|
- The maintained offline preparation, execution, and observation examples protect distinct
|
||||||
assembled consumer workflow without contacting a model provider.
|
representative assembled consumer workflows without contacting a model
|
||||||
|
provider.
|
||||||
- Fixtures should be minimal, synthetic, versioned with the behavior they
|
- Fixtures should be minimal, synthetic, versioned with the behavior they
|
||||||
exercise, and free of credentials or private data.
|
exercise, and free of credentials or private data.
|
||||||
- Golden files are appropriate only when the complete output is intentionally
|
- Golden files are appropriate only when the complete output is intentionally
|
||||||
|
|||||||
316
docs/release.md
316
docs/release.md
@@ -1,278 +1,82 @@
|
|||||||
# 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
|
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.
|
||||||
|
|
||||||
Choose a version that has not been published and export it as
|
## Validate And Publish
|
||||||
`RELEASE_VERSION`. Run every command in this procedure from the Promptkit
|
|
||||||
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
|
Run the following with the chosen unused version (replace the placeholder):
|
||||||
missing or malformed version, a checkout other than synchronized `main`,
|
|
||||||
uncommitted changes, an active Go workspace, a module replacement, a vendor
|
|
||||||
tree, or an existing local or remote tag:
|
|
||||||
|
|
||||||
```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).
|
||||||
|
|
||||||
Run the complete maintainer validation required by the
|
|
||||||
[development guide](development.md):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./...
|
|
||||||
go test -race ./...
|
|
||||||
go vet ./...
|
|
||||||
go build ./...
|
|
||||||
go run ./examples/go-library/prepare
|
|
||||||
```
|
|
||||||
|
|
||||||
Check every tracked Go file. This command must produce no output:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
unformatted=$(
|
|
||||||
git ls-files '*.go' |
|
|
||||||
while IFS= read -r go_file
|
|
||||||
do
|
|
||||||
gofmt -l "$go_file"
|
|
||||||
done
|
|
||||||
)
|
|
||||||
test -z "$unformatted"
|
|
||||||
```
|
|
||||||
|
|
||||||
Follow every maintained Markdown link and confirm that its local or published
|
|
||||||
target exists. Review the repository for generated binaries, test or coverage
|
|
||||||
output, credentials, template residue, downloaded assets, and other files that
|
|
||||||
do not belong in source control.
|
|
||||||
|
|
||||||
Recheck module and repository hygiene, whitespace, and the clean checkout:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
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
|
|
||||||
git diff --check
|
|
||||||
test -z "$(git status --porcelain)"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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](../../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).
|
||||||
@@ -90,7 +90,7 @@ engine, err := promptkit.NewEngine(
|
|||||||
```
|
```
|
||||||
|
|
||||||
See the
|
See the
|
||||||
[custom-backend consumer guide](../consumers/pkg-promptkit.md#register-a-custom-backend)
|
[local-endpoint consumer guide](../consumers/pkg-promptkit.md#configure-a-local-openai-compatible-endpoint)
|
||||||
for task-oriented usage. The
|
for task-oriented usage. The
|
||||||
[`Backend` and `WithBackend` GoDoc](../../backends.go) owns exact registration,
|
[`Backend` and `WithBackend` GoDoc](../../backends.go) owns exact registration,
|
||||||
validation, copying, defaulting, and uniqueness semantics. The
|
validation, copying, defaulting, and uniqueness semantics. The
|
||||||
|
|||||||
74
docs/releases/v0.3.0.md
Normal file
74
docs/releases/v0.3.0.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Promptkit v0.3.0
|
||||||
|
|
||||||
|
This supplemental changelog summarizes the consumer-facing changes from
|
||||||
|
`v0.2.0` to `v0.3.0`. The annotated `v0.3.0` tag is the authoritative release
|
||||||
|
record. Exact current contracts belong to the linked GoDoc and durable
|
||||||
|
documentation.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`v0.3.0` adds a concise way to register the common local OpenAI-compatible
|
||||||
|
backend configuration:
|
||||||
|
|
||||||
|
- `BackendLocal` provides the conventional, non-reserved backend ID `"local"`;
|
||||||
|
and
|
||||||
|
- `LocalBackend` constructs an ordinary `Backend` from an endpoint and
|
||||||
|
concurrency limit.
|
||||||
|
|
||||||
|
The helper is explicit and additive. It does not pre-register a backend, read
|
||||||
|
environment variables, select a model, or replace the complete `Backend`
|
||||||
|
configuration interface.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
Existing `v0.2.0` consumers require no migration. Endpoint-only profiles,
|
||||||
|
complete custom `Backend` values, the built-in OpenRouter backend, and existing
|
||||||
|
registrations using the literal ID `"local"` continue to work unchanged.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Update the module dependency with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/promptkit@v0.3.0
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the consuming project's ordinary tests and race-enabled tests after the
|
||||||
|
upgrade.
|
||||||
|
|
||||||
|
## Configure A Local Backend
|
||||||
|
|
||||||
|
Register the convenience value through the existing `WithBackend` option and
|
||||||
|
select it from one or more profiles:
|
||||||
|
|
||||||
|
```go
|
||||||
|
engine, err := promptkit.NewEngine(
|
||||||
|
promptkit.Config{PromptDir: "prompts"},
|
||||||
|
promptkit.WithBackend(
|
||||||
|
promptkit.LocalBackend("http://localhost:8000/v1", 2),
|
||||||
|
),
|
||||||
|
promptkit.WithProfiles(promptkit.Profile{
|
||||||
|
ID: "local-summary",
|
||||||
|
BackendID: promptkit.BackendLocal,
|
||||||
|
Model: "example-model",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use an endpoint-only profile when shared backend identity and capacity policy
|
||||||
|
are unnecessary. Continue to use a complete keyed `Backend` value for custom
|
||||||
|
IDs, authentication, extra request parameters, explicit queue capacity, or
|
||||||
|
multiple local endpoints.
|
||||||
|
|
||||||
|
See the
|
||||||
|
[local-endpoint consumer guide](../consumers/pkg-promptkit.md#configure-a-local-openai-compatible-endpoint)
|
||||||
|
for task-oriented configuration choices. The
|
||||||
|
[`BackendLocal`, `LocalBackend`, and `WithBackend` GoDoc](../../backends.go)
|
||||||
|
owns their exact construction, registration, validation, and concurrency
|
||||||
|
semantics.
|
||||||
|
|
||||||
|
## Consumer Action
|
||||||
|
|
||||||
|
None. Adopt the convenience constructor when it simplifies local endpoint
|
||||||
|
configuration.
|
||||||
189
docs/releases/v0.4.0.md
Normal file
189
docs/releases/v0.4.0.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# Promptkit v0.4.0
|
||||||
|
|
||||||
|
This supplemental changelog and adoption guide summarizes the consumer-facing
|
||||||
|
changes from `v0.3.0` to `v0.4.0`. The annotated `v0.4.0` tag is the
|
||||||
|
authoritative release record. Exact current contracts belong to the linked
|
||||||
|
GoDoc and durable documentation.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`v0.4.0` adds four complementary capabilities:
|
||||||
|
|
||||||
|
- opaque prepared-execution handles for preparing once, inspecting safe
|
||||||
|
details, and executing the same frozen snapshot;
|
||||||
|
- exact prompt-definition inspection without profile resolution or execution;
|
||||||
|
- exact profile inspection without selecting a prompt or checking credential
|
||||||
|
availability; and
|
||||||
|
- structured backend identity on engine admission-capacity rejection.
|
||||||
|
|
||||||
|
These APIs let consumers perform more precise preflight work and retain useful
|
||||||
|
operational context without reproducing Promptkit's internal resolution logic.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
The release is additive for `v0.3.0` consumers. Existing uses of `Prepare`,
|
||||||
|
`Run`, backend registration, endpoint-only profiles, local-backend helpers,
|
||||||
|
runtime overrides, public JSON values, and error sentinels continue to work
|
||||||
|
without migration.
|
||||||
|
|
||||||
|
Capacity rejection now returns a structured error while continuing to match
|
||||||
|
`ErrCapacityExceeded` through `errors.Is`. Error-string wording and direct
|
||||||
|
sentinel equality were not public contracts.
|
||||||
|
|
||||||
|
The new inspection values, capacity error, and prepared-execution handle do not
|
||||||
|
have stable JSON representations. `PreparedExecution.Details` returns the
|
||||||
|
existing stable `PreparedRun` value.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Update the module dependency with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/promptkit@v0.4.0
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the consuming project's ordinary and race-enabled tests after upgrading.
|
||||||
|
No source migration is required.
|
||||||
|
|
||||||
|
## Prepare Once And Execute The Same Snapshot
|
||||||
|
|
||||||
|
Consumers that need to persist preparation details before generation can now
|
||||||
|
prepare an opaque, engine-bound execution:
|
||||||
|
|
||||||
|
```go
|
||||||
|
prepared, err := engine.PrepareExecution(ctx, request)
|
||||||
|
if err != nil {
|
||||||
|
// Handle preparation failure.
|
||||||
|
}
|
||||||
|
defer prepared.Discard()
|
||||||
|
|
||||||
|
details := prepared.Details()
|
||||||
|
// Persist a consumer-selected, appropriately protected preparation record.
|
||||||
|
|
||||||
|
result, err := engine.RunPrepared(ctx, prepared)
|
||||||
|
```
|
||||||
|
|
||||||
|
Preparation freezes the selected sources, rendered messages, effective
|
||||||
|
settings, input content, structured-output metadata, and validation resources
|
||||||
|
needed by execution. `Details` returns a fresh, caller-owned,
|
||||||
|
credential-redacted `PreparedRun`.
|
||||||
|
|
||||||
|
A handle belongs to its creating engine and permits one execution attempt.
|
||||||
|
`RunPrepared` consumes that attempt on success and on operational failure.
|
||||||
|
`Discard` is idempotent and releases an unclaimed handle's execution-only
|
||||||
|
state. Consumers should discard handles they will not execute, particularly
|
||||||
|
when a direct request API key may be retained privately until claim or
|
||||||
|
discard.
|
||||||
|
|
||||||
|
Prepared execution does not reserve backend admission during preparation.
|
||||||
|
Credential availability and backend admission are checked when execution
|
||||||
|
begins. The execution context is independent of the preparation context.
|
||||||
|
|
||||||
|
See the
|
||||||
|
[prepared-execution consumer guide](../consumers/pkg-promptkit.md#prepare-now-and-execute-the-same-snapshot-later),
|
||||||
|
the [`PreparedExecution` GoDoc](../../prepared_execution.go), and the
|
||||||
|
[`Engine.PrepareExecution` and `Engine.RunPrepared` GoDoc](../../engine.go)
|
||||||
|
for the exact lifecycle, ownership, cancellation, capacity, timing, and
|
||||||
|
failure contracts.
|
||||||
|
|
||||||
|
## Inspect A Prompt
|
||||||
|
|
||||||
|
`Engine.InspectPrompt` resolves one prompt ID and optional version through the
|
||||||
|
engine's configured prompt source:
|
||||||
|
|
||||||
|
```go
|
||||||
|
inspection, err := engine.InspectPrompt(ctx, "report.summary", "")
|
||||||
|
```
|
||||||
|
|
||||||
|
The result includes prompt identity, the opaque prompt hash, declared default
|
||||||
|
profile ID, declared input metadata, and normalized output contract. It
|
||||||
|
structurally loads the selected definition and referenced message content but
|
||||||
|
does not resolve a profile, load schemas or artifacts, render templates,
|
||||||
|
reserve capacity, or contact a model.
|
||||||
|
|
||||||
|
Use inspection for exact configuration checks and metadata discovery. Use
|
||||||
|
`PrepareExecution` rather than relying on a prior inspection when later
|
||||||
|
execution must freeze one exact source state, because filesystem-backed
|
||||||
|
inspection is only a point-in-time lookup.
|
||||||
|
|
||||||
|
See the
|
||||||
|
[prompt-inspection consumer guide](../consumers/pkg-promptkit.md#inspect-a-prompt-before-preparation)
|
||||||
|
and [`Engine.InspectPrompt` GoDoc](../../engine.go) for exact selection,
|
||||||
|
ownership, and error behavior.
|
||||||
|
|
||||||
|
## Inspect A Profile
|
||||||
|
|
||||||
|
`Engine.InspectProfile` resolves one explicit profile independently of a
|
||||||
|
prompt:
|
||||||
|
|
||||||
|
```go
|
||||||
|
inspection, err := engine.InspectProfile(ctx, "report-production")
|
||||||
|
```
|
||||||
|
|
||||||
|
The result includes the resolved effective execution target and whether a
|
||||||
|
later request must provide a direct credential. Environment-variable names may
|
||||||
|
be reported, but inspection does not read credential values or require the
|
||||||
|
named variable to be populated.
|
||||||
|
|
||||||
|
Inspection applies the engine's profile source precedence and resolves any
|
||||||
|
selected backend. It does not load a prompt, render content, reserve capacity,
|
||||||
|
or contact a model.
|
||||||
|
|
||||||
|
See the
|
||||||
|
[profile-inspection consumer guide](../consumers/pkg-promptkit.md#inspect-a-profile-before-prompt-work)
|
||||||
|
and [`Engine.InspectProfile` GoDoc](../../engine.go) for the exact resolution,
|
||||||
|
credential, ownership, and error contracts.
|
||||||
|
|
||||||
|
## Identify Capacity-Rejected Backends
|
||||||
|
|
||||||
|
Calls rejected at Promptkit's bounded engine admission boundary continue to
|
||||||
|
match `ErrCapacityExceeded`. Consumers can additionally obtain the selected
|
||||||
|
registered backend ID without parsing diagnostic text:
|
||||||
|
|
||||||
|
```go
|
||||||
|
result, err := engine.Run(ctx, request)
|
||||||
|
if errors.Is(err, promptkit.ErrCapacityExceeded) {
|
||||||
|
var capacityErr *promptkit.CapacityError
|
||||||
|
if errors.As(err, &capacityErr) {
|
||||||
|
// Record capacityErr.BackendID using application-owned diagnostics.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply application-owned overload or retry policy.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The structured error applies to `Run` and `RunPrepared` admission rejection.
|
||||||
|
It does not represent provider throttling, quota exhaustion, cancellation
|
||||||
|
while waiting for generation capacity, or another model-client failure.
|
||||||
|
Promptkit does not prescribe retry timing or transport status mapping.
|
||||||
|
|
||||||
|
See the
|
||||||
|
[error-handling consumer guide](../consumers/pkg-promptkit.md#handle-errors),
|
||||||
|
the [`CapacityError` GoDoc](../../capacity_error.go), and the
|
||||||
|
[`ErrCapacityExceeded` GoDoc](../../engine.go) for the canonical contracts.
|
||||||
|
|
||||||
|
## Public API Additions
|
||||||
|
|
||||||
|
The release adds:
|
||||||
|
|
||||||
|
- `Engine.PrepareExecution`;
|
||||||
|
- `Engine.RunPrepared`;
|
||||||
|
- `PreparedExecution`, including `Details`, `Discard`, `String`, and
|
||||||
|
`GoString`;
|
||||||
|
- `Engine.InspectPrompt`;
|
||||||
|
- `PromptInspection`;
|
||||||
|
- `PromptInputDefinition`;
|
||||||
|
- `Engine.InspectProfile`;
|
||||||
|
- `ProfileInspection`; and
|
||||||
|
- `CapacityError`.
|
||||||
|
|
||||||
|
No public API was removed.
|
||||||
|
|
||||||
|
## Consumer Action
|
||||||
|
|
||||||
|
None. Existing `v0.3.0` workflows may upgrade without adopting the new APIs.
|
||||||
|
|
||||||
|
Consumers that adopt prepared execution should discard unused handles.
|
||||||
|
Consumers that need backend-specific capacity diagnostics may add an
|
||||||
|
`errors.As` check while retaining their existing `errors.Is` classification.
|
||||||
125
docs/releases/v0.5.0.md
Normal file
125
docs/releases/v0.5.0.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# Promptkit v0.5.0
|
||||||
|
|
||||||
|
This supplemental changelog and migration guide summarizes the consumer-facing
|
||||||
|
changes from `v0.4.0` to `v0.5.0`. The annotated `v0.5.0` tag is the
|
||||||
|
authoritative release record. Exact current contracts belong to the linked
|
||||||
|
GoDoc and durable documentation.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`v0.5.0` makes provider requests less prescriptive and adds an application
|
||||||
|
fallback layer for profile definitions:
|
||||||
|
|
||||||
|
- unset optional provider controls are omitted from OpenAI-compatible request
|
||||||
|
bodies instead of being populated with framework values; and
|
||||||
|
- `WithFallbackProfileFS` lets an application package profile defaults that
|
||||||
|
operators can override through the existing ordinary profile sources.
|
||||||
|
|
||||||
|
These changes let compatible providers apply their own model defaults while
|
||||||
|
giving applications stable embedded profile IDs without weakening operator
|
||||||
|
configuration precedence.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
The release adds one public function and removes no public declaration.
|
||||||
|
Existing source code should continue to compile.
|
||||||
|
|
||||||
|
There is one intentional behavior change: when no profile or runtime override
|
||||||
|
selects `top_p`, Promptkit no longer sends the former framework value of `1`.
|
||||||
|
It omits `top_p` and lets the provider choose its behavior. Unset
|
||||||
|
`temperature` and `max_tokens` are likewise omitted. Explicit nonzero profile
|
||||||
|
values and runtime values—including explicit runtime zero values—retain their
|
||||||
|
precedence and wire effect.
|
||||||
|
|
||||||
|
Consumers that relied on Promptkit always sending `top_p: 1` should add that
|
||||||
|
value to the relevant profile or runtime override before upgrading. Consumers
|
||||||
|
that did not rely on the implicit sampling value require no migration.
|
||||||
|
|
||||||
|
Application fallback profiles are opt-in. Engines that do not call
|
||||||
|
`WithFallbackProfileFS` retain the previous profile-source behavior.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Update the module dependency with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/promptkit@v0.5.0
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the consuming project's ordinary and race-enabled tests after upgrading.
|
||||||
|
If request payloads or model behavior are asserted in fixtures, review them for
|
||||||
|
the optional-parameter omission described below.
|
||||||
|
|
||||||
|
## Omitted Optional Provider Controls
|
||||||
|
|
||||||
|
The built-in OpenAI-compatible client now includes `temperature`,
|
||||||
|
`max_tokens`, and `top_p` only when a profile or runtime override selects the
|
||||||
|
value. An explicit runtime zero remains present because runtime override
|
||||||
|
pointers distinguish zero from an unspecified value.
|
||||||
|
|
||||||
|
Promptkit's positive generation deadline remains a framework concern and is
|
||||||
|
not a provider request-body default. Required request fields, session IDs,
|
||||||
|
structured output, reasoning selection, credentials, and explicit extra
|
||||||
|
parameters retain their existing behavior.
|
||||||
|
|
||||||
|
See the [framework default and precedence reference](../formats.md#defaults-and-overrides),
|
||||||
|
the [`ExecutionTargetOverride` GoDoc](../../types.go), and the
|
||||||
|
[OpenAI-compatible request-body contract](../integrations/openai-compatible-chat.md#request-body)
|
||||||
|
for current details.
|
||||||
|
|
||||||
|
## Embedded Application Fallback Profiles
|
||||||
|
|
||||||
|
Applications can package ordinary profile YAML in an `fs.FS` and register it
|
||||||
|
as a fallback source:
|
||||||
|
|
||||||
|
```go
|
||||||
|
//go:embed profiles/*.yaml
|
||||||
|
var applicationProfiles embed.FS
|
||||||
|
|
||||||
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
||||||
|
PromptDir: "prompts",
|
||||||
|
ProfileDir: operatorProfileDir,
|
||||||
|
},
|
||||||
|
promptkit.WithFallbackProfileFS(applicationProfiles, "profiles"),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave `operatorProfileDir` empty when no operator source is configured. A
|
||||||
|
configured ordinary source is authoritative: a matching definition overrides
|
||||||
|
the application fallback, while a read or validation failure remains an error
|
||||||
|
instead of silently reaching a lower layer.
|
||||||
|
|
||||||
|
Profile definitions resolve in this order:
|
||||||
|
|
||||||
|
1. in-memory profiles supplied with `WithProfiles`;
|
||||||
|
2. the ordinary configured source selected by `WithProfileFile`,
|
||||||
|
`WithProfileFS`, or `Config.ProfileDir`;
|
||||||
|
3. the application source supplied with `WithFallbackProfileFS`; and
|
||||||
|
4. Promptkit's embedded built-in profiles.
|
||||||
|
|
||||||
|
Only an absent profile ID falls through. Sources provide complete profiles and
|
||||||
|
do not merge fields. Loading remains lazy, and the new source uses the existing
|
||||||
|
strict profile YAML and credential rules.
|
||||||
|
|
||||||
|
See the
|
||||||
|
[embedded-default consumer guidance](../consumers/pkg-promptkit.md#supply-embedded-application-defaults),
|
||||||
|
the [`WithFallbackProfileFS` GoDoc](../../engine.go), and the
|
||||||
|
[profile source reference](../formats.md#source-and-profile-precedence) for
|
||||||
|
current details.
|
||||||
|
|
||||||
|
## Public API Changes
|
||||||
|
|
||||||
|
The release adds:
|
||||||
|
|
||||||
|
- `WithFallbackProfileFS`.
|
||||||
|
|
||||||
|
No public declaration was removed or changed.
|
||||||
|
|
||||||
|
## Consumer Action
|
||||||
|
|
||||||
|
- Review any workflow that depended on Promptkit's implicit `top_p: 1` and
|
||||||
|
configure the value explicitly when required.
|
||||||
|
- Optionally adopt `WithFallbackProfileFS` when an application should package
|
||||||
|
overridable profile defaults.
|
||||||
|
- Run consumer tests after updating the module dependency.
|
||||||
131
docs/releases/v0.6.0.md
Normal file
131
docs/releases/v0.6.0.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# Promptkit v0.6.0
|
||||||
|
|
||||||
|
This supplemental changelog and migration guide summarizes the consumer-facing
|
||||||
|
changes from `v0.5.0` to `v0.6.0`. The annotated `v0.6.0` tag is the
|
||||||
|
authoritative release record. Exact current contracts belong to the linked
|
||||||
|
GoDoc and durable documentation.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`v0.6.0` is a broad correctness, safety, efficiency, and maintainability
|
||||||
|
release. It does not add or remove public declarations. The release:
|
||||||
|
|
||||||
|
- centralizes shared execution-setting, output-contract, endpoint, and
|
||||||
|
JSON-compatible-value rules;
|
||||||
|
- unifies prompt repository behavior and avoids unnecessary prompt and profile
|
||||||
|
decoding;
|
||||||
|
- bounds consumer-controlled JSON trees and successful provider responses;
|
||||||
|
- hardens prompt content paths, artifact files, provider URLs, JSON framing,
|
||||||
|
and error propagation;
|
||||||
|
- reuses compiled schema plans and rendered artifact text within an operation;
|
||||||
|
and
|
||||||
|
- improves cancellation behavior, prepared-value ownership, deterministic
|
||||||
|
transport testing, and maintainer validation.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
No public declaration was added, removed, or changed. Ordinary valid `v0.5.0`
|
||||||
|
configurations and requests should continue to compile and behave as before.
|
||||||
|
|
||||||
|
The release intentionally rejects or reports several inputs that were
|
||||||
|
previously accepted, altered, or misclassified:
|
||||||
|
|
||||||
|
- execution settings must be finite, within their documented ranges, and safe
|
||||||
|
to convert to Go durations;
|
||||||
|
- output formats, validation modes, repair counts, and JSON Schema dependencies
|
||||||
|
are validated consistently;
|
||||||
|
- file-backed prompt and profile identity comes from normalized YAML metadata,
|
||||||
|
not filenames;
|
||||||
|
- prompt `content_file` values must be exact relative paths contained by their
|
||||||
|
configured source root;
|
||||||
|
- built-in file artifacts must resolve to regular files;
|
||||||
|
- selected provider endpoints must be absolute HTTP or HTTPS URLs without user
|
||||||
|
information, query strings, or fragments;
|
||||||
|
- JSON documents and successful provider responses must contain exactly one
|
||||||
|
value, and successful provider bodies are limited to 16 MiB; and
|
||||||
|
- excessively deep or expansive JSON-compatible values fail with ordinary
|
||||||
|
validation errors.
|
||||||
|
|
||||||
|
These are compatibility corrections and safety boundaries rather than new
|
||||||
|
consumer configuration requirements. Consumers relying on an invalid or
|
||||||
|
ambiguous input should correct that input before upgrading.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Update the module dependency with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/promptkit@v0.6.0
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the consuming project's ordinary and race-enabled tests after upgrading.
|
||||||
|
Applications with custom prompt/profile sources, local provider endpoints,
|
||||||
|
unusual artifact paths, or assertions over provider error identities should
|
||||||
|
pay particular attention to the compatibility notes below.
|
||||||
|
|
||||||
|
## Source Loading And Identity
|
||||||
|
|
||||||
|
Prompt definitions now share one source-neutral selection and normalization
|
||||||
|
flow across operating-system and `fs.FS` sources. YAML `id` and `version`
|
||||||
|
metadata are authoritative; filenames do not create a second identity system.
|
||||||
|
Only selected content bodies are loaded, malformed unrelated definitions do
|
||||||
|
not shadow valid exact matches, and per-file read failures are reported as
|
||||||
|
prompt-load failures rather than false absence.
|
||||||
|
|
||||||
|
File-backed profiles likewise use normalized YAML IDs, reuse their metadata
|
||||||
|
read for selected strict decoding, and avoid fully decoding unrelated files.
|
||||||
|
Selected malformed definitions remain authoritative and do not silently fall
|
||||||
|
through to a lower-precedence source.
|
||||||
|
|
||||||
|
Prompt `content_file` paths are opened exactly as declared after a separate
|
||||||
|
blank check. They must remain relative to and contained by the configured
|
||||||
|
prompt source root, including across operating-system symlinks.
|
||||||
|
|
||||||
|
See the [framework source and identity reference](../formats.md) and
|
||||||
|
[internal source overview](../internal/sources.md) for the current contracts.
|
||||||
|
|
||||||
|
## Validation, Cancellation, And Efficiency
|
||||||
|
|
||||||
|
JSON Schema documents preserve exact JSON-number representations. Schema
|
||||||
|
resource URLs safely escape legal filesystem names, and each operation loads
|
||||||
|
and compiles its schema graph once. `Run` and prepared execution reuse that
|
||||||
|
operation-local plan; Promptkit does not introduce a cross-operation cache.
|
||||||
|
|
||||||
|
Artifact reading, rendering, schema loading, compilation, and validation now
|
||||||
|
check cancellation at the synchronous boundaries Promptkit controls. Rendering
|
||||||
|
memoizes each artifact's text within one render operation, while plain JSON
|
||||||
|
validation avoids materializing an unnecessary generic tree.
|
||||||
|
|
||||||
|
The shared JSON-compatible-value owner now limits nesting and produced work so
|
||||||
|
unsafe consumer-controlled structures return errors instead of risking
|
||||||
|
unbounded recursion or allocation. See the
|
||||||
|
[architecture policy](../policy/architecture.md) for invariant ownership and
|
||||||
|
the [format reference](../formats.md) for validation behavior.
|
||||||
|
|
||||||
|
## Provider Transport Hardening
|
||||||
|
|
||||||
|
OpenAI-compatible endpoints are parsed and composed structurally, including
|
||||||
|
nested base paths. Underlying transport cancellation and deadline errors remain
|
||||||
|
discoverable with `errors.Is` through Promptkit's generation error category.
|
||||||
|
|
||||||
|
Successful provider bodies are read with a fixed 16 MiB bound and must contain
|
||||||
|
exactly one JSON response object followed only by whitespace. Oversized,
|
||||||
|
truncated, malformed, or multiply framed responses fail without returning a
|
||||||
|
partial result. See the
|
||||||
|
[OpenAI-compatible integration contract](../integrations/openai-compatible-chat.md)
|
||||||
|
for the canonical request, endpoint, error, and response behavior.
|
||||||
|
|
||||||
|
## Public API Changes
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Consumer Action
|
||||||
|
|
||||||
|
- Correct any configuration or request that depends on the formerly permissive
|
||||||
|
cases described under Compatibility.
|
||||||
|
- Confirm custom local provider endpoints are absolute HTTP or HTTPS base URLs
|
||||||
|
without credentials, queries, or fragments.
|
||||||
|
- Confirm prompt content paths remain within their configured source root and
|
||||||
|
file artifacts resolve to regular files.
|
||||||
|
- Run ordinary and race-enabled consumer tests after updating the dependency.
|
||||||
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.
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
# Backend-Specific Concurrency Management
|
|
||||||
|
|
||||||
**Status:** Complete.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This roadmap defines the scope and target end state for engine-local,
|
|
||||||
backend-specific concurrency management. It records the intended capability,
|
|
||||||
consumer value, and important policy choices.
|
|
||||||
|
|
||||||
This document is planning material, not a description of current behavior.
|
|
||||||
Current exported contracts remain owned by Go declarations and GoDoc, backend
|
|
||||||
registration guidance by the
|
|
||||||
[consumer guide](../consumers/pkg-promptkit.md#register-a-custom-backend), and
|
|
||||||
implemented orchestration by the
|
|
||||||
[internal runner document](../internal/runner.md).
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
Different model backends can sustain very different request loads. A local
|
|
||||||
network endpoint may need a small concurrency limit, while OpenRouter can
|
|
||||||
usually accept substantially more simultaneous work. Requiring every consumer
|
|
||||||
to build its own semaphores and queues would duplicate routing knowledge,
|
|
||||||
create inconsistent cancellation behavior, and make it easy for one caller to
|
|
||||||
bypass the intended backend limit.
|
|
||||||
|
|
||||||
Promptkit should own this coordination because it already resolves each run to
|
|
||||||
an engine-scoped backend identity and owns every model-generation call made by
|
|
||||||
the runner. Consumers should continue submitting ready-to-run requests through
|
|
||||||
the synchronous API, including concurrently from multiple goroutines, without
|
|
||||||
implementing their own backend scheduler.
|
|
||||||
|
|
||||||
The buffered queue is a safety boundary, not an ordinary throughput
|
|
||||||
restriction. Its primary purpose is to prevent a bug or unintended submission
|
|
||||||
loop from creating an unbounded in-memory backlog.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
The feature will add optional concurrency policy to registered backends and
|
|
||||||
coordinate `Run` calls against independent per-backend capacity pools.
|
|
||||||
|
|
||||||
Each policy has two distinct controls:
|
|
||||||
|
|
||||||
- an active-generation limit, which protects the backend from too many
|
|
||||||
simultaneous model requests; and
|
|
||||||
- a bounded waiting capacity, which protects the process from admitting an
|
|
||||||
unbounded backlog.
|
|
||||||
|
|
||||||
Concurrency policy belongs to a backend registration. It is not a profile
|
|
||||||
model parameter and cannot be overridden per run. Profiles select the policy
|
|
||||||
through their backend ID, while a profile or request endpoint override remains
|
|
||||||
in the selected backend's pool.
|
|
||||||
|
|
||||||
`Prepare` does not call a model and will remain outside concurrency admission.
|
|
||||||
|
|
||||||
## Defaults And Configuration
|
|
||||||
|
|
||||||
The built-in OpenRouter backend will use:
|
|
||||||
|
|
||||||
- an active-generation limit of 16; and
|
|
||||||
- a waiting capacity of 1024.
|
|
||||||
|
|
||||||
The waiting default is intentionally generous. Reaching it should indicate
|
|
||||||
abnormal submission pressure rather than normal application behavior.
|
|
||||||
|
|
||||||
Consumer-registered backends will remain unlimited unless the consumer
|
|
||||||
configures an active-generation limit. When a consumer enables a limit and
|
|
||||||
does not specify waiting capacity, the waiting capacity will default to 1024.
|
|
||||||
Consumers may configure a different bounded capacity, including zero when
|
|
||||||
they want no admitted backlog beyond the active-limit-sized run set.
|
|
||||||
|
|
||||||
The public representation must distinguish an omitted waiting capacity from
|
|
||||||
an explicit zero.
|
|
||||||
|
|
||||||
Endpoint-only profiles have no backend registration from which to obtain
|
|
||||||
policy and will remain unlimited. A future engine-wide or endpoint-keyed
|
|
||||||
policy can be considered separately if consumers demonstrate that need.
|
|
||||||
|
|
||||||
Invalid limits or capacities will fail engine construction as invalid
|
|
||||||
configuration. Policy values will be copied into engine-owned immutable state
|
|
||||||
along with the rest of the backend registration.
|
|
||||||
|
|
||||||
## Admission And Execution Behavior
|
|
||||||
|
|
||||||
`Run` remains a synchronous, wait-for-result operation. Concurrent callers may
|
|
||||||
block inside `Run` while waiting for their selected backend, then receive the
|
|
||||||
ordinary result or error from that invocation.
|
|
||||||
|
|
||||||
For a configured pool, the active-generation limit plus the waiting capacity
|
|
||||||
defines the maximum number of concurrent `Run` invocations that Promptkit will
|
|
||||||
accept for that backend. A waiting capacity of zero therefore accepts no more
|
|
||||||
runs than the active limit. Admission is immediate: a call either reserves one
|
|
||||||
of those bounded slots or receives the capacity error. An accepted run may
|
|
||||||
then wait internally for active-generation capacity.
|
|
||||||
|
|
||||||
For a limited backend, Promptkit will bound the number of accepted runs before
|
|
||||||
expensive artifact loading, prompt rendering, and large defensive copies where
|
|
||||||
practical. Lightweight prompt, profile, and backend resolution may occur first
|
|
||||||
when it is required to identify the correct capacity pool. This pre-admission
|
|
||||||
resolution must not become a second execution-precedence path with behavior
|
|
||||||
that can drift from `Prepare`.
|
|
||||||
|
|
||||||
An accepted run retains its admission until it completes or fails. Every
|
|
||||||
actual model-generation call for that run must separately observe the
|
|
||||||
backend's active-generation limit. This includes:
|
|
||||||
|
|
||||||
- the initial generation;
|
|
||||||
- every output-repair generation; and
|
|
||||||
- calls made through either the built-in or an injected model client.
|
|
||||||
|
|
||||||
Preparation and output validation should not hold an active-generation permit.
|
|
||||||
A repair remains part of its already-admitted run, but reacquires active
|
|
||||||
generation capacity so repairs cannot exceed the backend limit. It must not be
|
|
||||||
rejected merely because new runs filled the waiting queue after its initial
|
|
||||||
generation.
|
|
||||||
|
|
||||||
Within one backend pool, waiting generation calls should be served in FIFO
|
|
||||||
order, subject to canceled calls being removed. Different backend pools make
|
|
||||||
progress independently; a saturated local backend must not consume
|
|
||||||
OpenRouter's active or waiting capacity.
|
|
||||||
|
|
||||||
The feature will not promise ordering across backend pools or completion order
|
|
||||||
among admitted runs.
|
|
||||||
|
|
||||||
## Capacity Failure And Cancellation
|
|
||||||
|
|
||||||
When a backend's bounded waiting capacity is full, a new `Run` call will fail
|
|
||||||
promptly rather than waiting outside the bounded admission system. The public
|
|
||||||
API will expose a recognizable capacity-exhaustion error identity distinct
|
|
||||||
from invalid configuration, invalid requests, and model-client failures.
|
|
||||||
Rejected calls return no partial result and do not invoke the model client.
|
|
||||||
|
|
||||||
Waiting within the admitted backlog or for active-generation capacity must
|
|
||||||
honor the caller's context. Cancellation or deadline expiry will:
|
|
||||||
|
|
||||||
- stop waiting promptly;
|
|
||||||
- release any admission or generation capacity held by that invocation;
|
|
||||||
- preserve the applicable context error identity; and
|
|
||||||
- avoid invoking the model client if cancellation wins before generation
|
|
||||||
starts.
|
|
||||||
|
|
||||||
Capacity must also be released after preparation, generation, validation,
|
|
||||||
repair, or collaborator failure. One failed or canceled run must not reduce
|
|
||||||
the backend's future usable capacity.
|
|
||||||
|
|
||||||
Elapsed `Run` timing will include time spent waiting after the call is
|
|
||||||
accepted. `PreparedRun` timing will continue to describe preparation rather
|
|
||||||
than queue waiting.
|
|
||||||
|
|
||||||
## Engine And Client Boundaries
|
|
||||||
|
|
||||||
All pools and queued state belong to one `Engine`. Separate engines do not
|
|
||||||
share capacity, even when they register the same backend ID or endpoint. The
|
|
||||||
feature introduces no process-global scheduler.
|
|
||||||
|
|
||||||
The engine will apply policy consistently to the built-in model client and an
|
|
||||||
injected `LLMClient`. Consumers calling their own client outside Promptkit are
|
|
||||||
outside this boundary. Injected clients remain responsible for their internal
|
|
||||||
thread safety and cancellation behavior.
|
|
||||||
|
|
||||||
Backend policy is keyed by the resolved backend ID rather than endpoint text.
|
|
||||||
This preserves stable routing when a selected backend's endpoint is overridden
|
|
||||||
and avoids accidentally combining unrelated registrations that happen to use
|
|
||||||
the same URL.
|
|
||||||
|
|
||||||
## Queue Lifetime And Observability
|
|
||||||
|
|
||||||
Admission state is buffered, ephemeral, and in-process. It is not persisted
|
|
||||||
and has no survival guarantee across engine disposal or process termination.
|
|
||||||
Promptkit will not introduce background job ownership or require consumers to
|
|
||||||
start or stop workers.
|
|
||||||
|
|
||||||
The initial feature does not require public queue-depth metrics, callbacks, or
|
|
||||||
inspection APIs. Capacity errors and ordinary call timing provide the
|
|
||||||
consumer-visible behavior. Operational observability can be added later
|
|
||||||
without coupling the scheduling mechanism to an application logging or
|
|
||||||
metrics system.
|
|
||||||
|
|
||||||
## Compatibility
|
|
||||||
|
|
||||||
Consumer-registered backends and endpoint-only profiles remain unlimited
|
|
||||||
unless concurrency is explicitly configured, preserving their existing
|
|
||||||
behavior.
|
|
||||||
|
|
||||||
The built-in OpenRouter backend will change from unlimited concurrency to a
|
|
||||||
limit of 16 with a bounded waiting capacity of 1024. Ordinary synchronous
|
|
||||||
calls remain unchanged, while unusually high concurrent use may now wait or
|
|
||||||
return the capacity error. This behavioral change must be identified in the
|
|
||||||
release notes for the version that publishes it.
|
|
||||||
|
|
||||||
Adding backend policy fields and a public capacity error is otherwise
|
|
||||||
additive. The change will use a pre-`v1` minor release under Promptkit's
|
|
||||||
[release policy](../release.md#release-model).
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
This scope does not include:
|
|
||||||
|
|
||||||
- asynchronous job handles, polling, or detached result delivery;
|
|
||||||
- durable or cross-process queues;
|
|
||||||
- persistence or recovery across engine or process shutdown;
|
|
||||||
- priorities, scheduling weights, or consumer-defined fairness classes;
|
|
||||||
- automatic retries, backoff, rate-limit interpretation, or provider quota
|
|
||||||
discovery;
|
|
||||||
- token-per-minute or request-per-minute rate limiting;
|
|
||||||
- dynamic reconfiguration after engine construction;
|
|
||||||
- per-profile or per-run concurrency overrides;
|
|
||||||
- endpoint-keyed pooling for profiles without a backend ID;
|
|
||||||
- process-global coordination across engines;
|
|
||||||
- application worker lifecycle, logging, tracing, or metrics policy; or
|
|
||||||
- changes to prompt, profile, schema, or model-provider wire formats.
|
|
||||||
|
|
||||||
## Target End State
|
|
||||||
|
|
||||||
This roadmap reaches its target end state when:
|
|
||||||
|
|
||||||
- each engine independently coordinates configured backend capacity;
|
|
||||||
- the built-in OpenRouter backend allows 16 active generations and up to 1024
|
|
||||||
waiting runs;
|
|
||||||
- consumer backends can opt into their own active and waiting limits while
|
|
||||||
remaining unlimited by default;
|
|
||||||
- endpoint overrides retain the selected backend's capacity pool and
|
|
||||||
endpoint-only profiles remain unlimited;
|
|
||||||
- synchronous `Run` callers wait for and receive their ordinary result;
|
|
||||||
- admission is bounded before expensive preparation work where practical;
|
|
||||||
- every initial and repair generation observes the backend's active limit
|
|
||||||
without serializing preparation or validation;
|
|
||||||
- a full waiting queue returns a recognizable capacity error without invoking
|
|
||||||
the model client;
|
|
||||||
- cancellation and all failure paths promptly release capacity and preserve
|
|
||||||
context error identity;
|
|
||||||
- built-in and injected model clients receive the same scheduling behavior;
|
|
||||||
- pools remain ephemeral, engine-scoped, and independent across backend IDs;
|
|
||||||
and
|
|
||||||
- current-state GoDoc, consumer, internal, and release documentation describe
|
|
||||||
the implemented behavior once it lands.
|
|
||||||
61
docs/roadmap/deferred.md
Normal file
61
docs/roadmap/deferred.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Deferred Feature Ideas
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document catalogs feature ideas that remain potentially useful but have
|
||||||
|
been deliberately postponed. These ideas are not awaiting ordinary selection
|
||||||
|
from the [future feature catalog](future.md); each has a stated reason to wait
|
||||||
|
and should be reconsidered only when its trigger becomes relevant.
|
||||||
|
|
||||||
|
Deferred entries are not commitments, schedules, active implementation plans,
|
||||||
|
or descriptions of current behavior. When an entry is reactivated, move it to
|
||||||
|
`future.md` for evaluation or directly into a focused roadmap after its open
|
||||||
|
design dependencies have been resolved.
|
||||||
|
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
### Semantic Execution-Target Fingerprints
|
||||||
|
|
||||||
|
**Reason for deferral:** A stable digest requires a deliberate semantic-
|
||||||
|
equality and versioning design. Notarius can safely use conservative source
|
||||||
|
hashes and a Promptkit release marker today, while Weatherreporter does not
|
||||||
|
currently reuse LLM-dependent checkpoints.
|
||||||
|
|
||||||
|
Promptkit could expose an opaque equality value for a resolved profile and its
|
||||||
|
effective generation target. This would let checkpointing consumers detect
|
||||||
|
generation-affecting configuration changes without hashing YAML presentation
|
||||||
|
or depending on Promptkit's built-in catalog layout.
|
||||||
|
|
||||||
|
The digest should change with semantically relevant state such as the resolved
|
||||||
|
model, endpoint, backend routing identity, request defaults, extra parameters,
|
||||||
|
profile generation settings, and selected built-in profile semantics. It
|
||||||
|
should exclude credential values, concurrency and queue policy, source paths,
|
||||||
|
comments, formatting, and other representation-only changes. Whether a
|
||||||
|
credential environment-variable name affects equality must be decided
|
||||||
|
explicitly. The encoding should remain opaque and internally versioned so
|
||||||
|
Promptkit can deliberately invalidate earlier digests when its resolution
|
||||||
|
semantics change.
|
||||||
|
|
||||||
|
Reconsider this idea when a downstream consumer needs Promptkit-owned
|
||||||
|
checkpoint equality or when a broader semantic identity design is selected.
|
||||||
|
|
||||||
|
### Eager Source Validation
|
||||||
|
|
||||||
|
**Reason for deferral:** Exact prompt and profile inspection may already
|
||||||
|
provide a sufficiently small validation surface. Experience from downstream
|
||||||
|
adoption should establish whether an engine-wide operation would add enough
|
||||||
|
value to justify its broader contract.
|
||||||
|
|
||||||
|
Promptkit could provide an explicit offline operation that discovers and
|
||||||
|
structurally validates configured prompt, profile, and schema sources without
|
||||||
|
model generation. The normal `NewEngine` path would remain lazy.
|
||||||
|
|
||||||
|
An eager operation would need coherent handling for duplicate prompt IDs and
|
||||||
|
versions, strict YAML decoding, referenced content files, profile/backend
|
||||||
|
membership, schema syntax and transitive references, context cancellation,
|
||||||
|
and source-specific public errors. Credential declarations must remain
|
||||||
|
separate from credential values; checking current environment availability,
|
||||||
|
if supported at all, should be an explicit option and must not expose secrets.
|
||||||
|
|
||||||
|
Reconsider this idea after downstream use of `InspectPrompt`,
|
||||||
|
`InspectProfile`, and fixture-based preparation demonstrates a concrete gap.
|
||||||
@@ -12,6 +12,9 @@ consumer value, and important scope boundaries. Defer API design,
|
|||||||
implementation details, sequencing, and acceptance criteria until an idea is
|
implementation details, sequencing, and acceptance criteria until an idea is
|
||||||
selected.
|
selected.
|
||||||
|
|
||||||
|
Ideas that have been deliberately postponed rather than left available for
|
||||||
|
ordinary selection belong in the [deferred catalog](deferred.md).
|
||||||
|
|
||||||
## Using This Catalog
|
## Using This Catalog
|
||||||
|
|
||||||
- Add an idea when its purpose and likely value can be stated clearly.
|
- Add an idea when its purpose and likely value can be stated clearly.
|
||||||
@@ -23,6 +26,8 @@ selected.
|
|||||||
- When an idea is selected, move its active planning to a focused roadmap or,
|
- When an idea is selected, move its active planning to a focused roadmap or,
|
||||||
when it requires a durable architectural decision, an ADR. Update
|
when it requires a durable architectural decision, an ADR. Update
|
||||||
current-state documentation only when implementation lands.
|
current-state documentation only when implementation lands.
|
||||||
|
- Move an idea to `deferred.md` when maintainers decide to retain it but wait
|
||||||
|
for a stated design dependency, demand signal, or reconsideration trigger.
|
||||||
- Remove ideas that are no longer relevant. Retain a rejected idea only when
|
- Remove ideas that are no longer relevant. Retain a rejected idea only when
|
||||||
its rationale is likely to prevent repeated reconsideration.
|
its rationale is likely to prevent repeated reconsideration.
|
||||||
|
|
||||||
@@ -33,9 +38,8 @@ consumers.
|
|||||||
|
|
||||||
## Ideas
|
## Ideas
|
||||||
|
|
||||||
No ideas are currently cataloged. Backend-specific concurrency management has
|
No ideas are currently awaiting selection. Active feature work belongs in its
|
||||||
been selected for active planning in the
|
focused roadmap rather than this catalog.
|
||||||
[focused concurrency roadmap](concurrency.md).
|
|
||||||
|
|
||||||
## Entry Format
|
## Entry Format
|
||||||
|
|
||||||
|
|||||||
@@ -1,841 +0,0 @@
|
|||||||
# Backend-Specific Concurrency Management Implementation Plan
|
|
||||||
|
|
||||||
**Status:** Complete.
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
This document is the decision-complete implementation plan for
|
|
||||||
[backend-specific concurrency management](concurrency.md). It is written for a
|
|
||||||
coding agent that will implement each stage in order.
|
|
||||||
|
|
||||||
The feature roadmap owns the intended capability, consumer value, policy
|
|
||||||
choices, compatibility decision, and target end state. This document owns the
|
|
||||||
concrete API, internal representation, scheduling architecture, implementation
|
|
||||||
sequence, test ownership, documentation updates, and completion gates.
|
|
||||||
|
|
||||||
## Implementation Rules
|
|
||||||
|
|
||||||
- Complete the stages in order. Keep the repository compiling and the focused
|
|
||||||
tests passing at every stage boundary.
|
|
||||||
- Preserve unrelated working-tree changes. In particular, `concurrency.md` and
|
|
||||||
the removal of its source idea from `future.md` may already be uncommitted
|
|
||||||
when implementation begins; retain both.
|
|
||||||
- Follow every policy under `docs/policy/`, the task-specific reading guide in
|
|
||||||
`docs/development.md`, and the target behavior in `concurrency.md`.
|
|
||||||
- Keep the public API in the root `promptkit` package and implementation
|
|
||||||
details under `internal/`. Do not expose scheduler types or create another
|
|
||||||
public package.
|
|
||||||
- Use only the Go standard library for scheduling. Do not add a queue,
|
|
||||||
semaphore, worker-pool, or metrics dependency.
|
|
||||||
- Preserve synchronous, wait-for-result `Run`, unrestricted `Prepare`,
|
|
||||||
engine-local state, endpoint-only profiles, backend-selected profiles,
|
|
||||||
backend identity through endpoint overrides, and injected `LLMClient`
|
|
||||||
behavior.
|
|
||||||
- Do not broaden the work into asynchronous jobs, durable queues, retries,
|
|
||||||
rate limiting, dynamic configuration, priorities, worker lifecycle,
|
|
||||||
endpoint-keyed pools, or public queue observability.
|
|
||||||
- Keep all tests deterministic, bounded, offline, and race-safe. Coordinate
|
|
||||||
concurrent tests with channels and barriers rather than timing assumptions
|
|
||||||
or live providers.
|
|
||||||
- Update exact GoDoc with each exported declaration change. Update durable
|
|
||||||
current-state documents only after the corresponding behavior is
|
|
||||||
implemented.
|
|
||||||
- Test configurable mechanisms with small test-owned limits. Assert the exact
|
|
||||||
OpenRouter `16` and default queue `1024` values only at the registry contract
|
|
||||||
that owns those operational defaults.
|
|
||||||
- Do not create a release, change a module version, or tag a commit. The final
|
|
||||||
implementation handoff must identify the built-in OpenRouter behavior change
|
|
||||||
for the next pre-`v1` minor release.
|
|
||||||
|
|
||||||
## Fixed Design
|
|
||||||
|
|
||||||
### Public Backend Configuration
|
|
||||||
|
|
||||||
Append these fields to the existing root `Backend` type in `backends.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Backend struct {
|
|
||||||
// Existing fields remain unchanged and in their current order.
|
|
||||||
|
|
||||||
ConcurrencyLimit int
|
|
||||||
QueueCapacity *int
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use these exact semantics:
|
|
||||||
|
|
||||||
| Public values | Meaning |
|
|
||||||
| --- | --- |
|
|
||||||
| `ConcurrencyLimit == 0`, `QueueCapacity == nil` | Unlimited backend; preserve current behavior. |
|
|
||||||
| `ConcurrencyLimit > 0`, `QueueCapacity == nil` | Limit active generations and use the default waiting capacity of 1024. |
|
|
||||||
| `ConcurrencyLimit > 0`, `QueueCapacity != nil` | Limit active generations and use the pointed-to capacity exactly, including zero. |
|
|
||||||
| `ConcurrencyLimit < 0` | Invalid engine configuration. |
|
|
||||||
| `QueueCapacity != nil` and `*QueueCapacity < 0` | Invalid engine configuration. |
|
|
||||||
| `ConcurrencyLimit == 0` and `QueueCapacity != nil` | Invalid engine configuration because a queue without an active limit has no defined consumer value. |
|
|
||||||
|
|
||||||
`ConcurrencyLimit` counts simultaneous calls to the engine-owned internal
|
|
||||||
model-client boundary for this backend. `QueueCapacity` controls additional
|
|
||||||
accepted `Run` invocations beyond that limit. The maximum admitted runs for a
|
|
||||||
limited backend is therefore:
|
|
||||||
|
|
||||||
```text
|
|
||||||
ConcurrencyLimit + effective QueueCapacity
|
|
||||||
```
|
|
||||||
|
|
||||||
Guard that addition against integer overflow during backend validation.
|
|
||||||
Do not impose an arbitrary upper bound beyond non-negativity and overflow
|
|
||||||
safety.
|
|
||||||
|
|
||||||
The `QueueCapacity` pointer exists only to distinguish omission from explicit
|
|
||||||
zero. `WithBackend` and `NewEngine` must not retain the caller's pointer.
|
|
||||||
`Backend` continues to have no stable JSON representation, and consumers
|
|
||||||
remain directed to keyed literals.
|
|
||||||
|
|
||||||
Do not add concurrency fields to `Profile`, `ExecutionTarget`,
|
|
||||||
`ExecutionTargetOverride`, `RunRequest`, prompt or profile files, or stable
|
|
||||||
prepared/result JSON.
|
|
||||||
|
|
||||||
### Built-In And Custom Defaults
|
|
||||||
|
|
||||||
The backend registry owns these exact operational defaults:
|
|
||||||
|
|
||||||
```go
|
|
||||||
const (
|
|
||||||
openRouterConcurrencyLimit = 16
|
|
||||||
defaultQueueCapacity = 1024
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
The built-in `openrouter` definition has a normalized concurrency limit of 16
|
|
||||||
and queue capacity of 1024.
|
|
||||||
|
|
||||||
Consumer registrations remain unlimited when concurrency is omitted. For a
|
|
||||||
consumer backend with a positive limit and omitted queue capacity, normalize
|
|
||||||
the queue capacity to 1024. Preserve an explicitly configured zero.
|
|
||||||
|
|
||||||
Consumers still cannot replace the reserved `openrouter` registration.
|
|
||||||
Endpoint-only profiles have no backend policy and remain unlimited. A selected
|
|
||||||
backend retains its pool when a profile or request overrides only its endpoint.
|
|
||||||
|
|
||||||
### Internal Backend Representation
|
|
||||||
|
|
||||||
Extend `internal/domain.Backend` with scalar policy values and explicit
|
|
||||||
presence rather than retaining a pointer:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Backend struct {
|
|
||||||
// Existing fields...
|
|
||||||
ConcurrencyLimit int
|
|
||||||
QueueCapacity int
|
|
||||||
QueueCapacitySet bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type BackendCapacityPolicy struct {
|
|
||||||
ConcurrencyLimit int
|
|
||||||
QueueCapacity int
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`WithBackend` converts the public pointer into `QueueCapacity` plus
|
|
||||||
`QueueCapacitySet`. Registry normalization validates the combinations above,
|
|
||||||
fills the default, and leaves every limited stored backend with
|
|
||||||
`QueueCapacitySet == true`. Unlimited stored backends retain zero values and
|
|
||||||
`QueueCapacitySet == false`.
|
|
||||||
|
|
||||||
Add this internal registry method:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (r *Registry) CapacityPolicies() map[string]domain.BackendCapacityPolicy
|
|
||||||
```
|
|
||||||
|
|
||||||
It returns a newly allocated map containing only limited backends. Values are
|
|
||||||
scalars, so callers cannot mutate registry state. The built-in OpenRouter
|
|
||||||
policy is included. `GetBackend` continues returning a defensive backend copy,
|
|
||||||
now including normalized scalar capacity metadata.
|
|
||||||
|
|
||||||
Capacity policy is operational registry metadata. Do not merge it into an
|
|
||||||
execution target or expose it to injected model clients.
|
|
||||||
|
|
||||||
### Public Capacity Error
|
|
||||||
|
|
||||||
Add this root sentinel beside the other run errors in `engine.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
var ErrCapacityExceeded = errors.New("backend capacity exceeded")
|
|
||||||
```
|
|
||||||
|
|
||||||
Its GoDoc must state that it identifies a `Run` rejected because the selected
|
|
||||||
backend has already admitted `ConcurrencyLimit + QueueCapacity` runs. It is
|
|
||||||
not an invalid request, an LLM/provider rate-limit response, or an
|
|
||||||
`ErrLLMGenerate` failure.
|
|
||||||
|
|
||||||
The internal capacity component owns a corresponding internal
|
|
||||||
`ErrCapacityExceeded`. Add its mapping in `publicErrorFor` before the broader
|
|
||||||
generation and invalid-request cases. The public error must preserve the
|
|
||||||
internal error through wrapping while matching `ErrCapacityExceeded` with
|
|
||||||
`errors.Is`.
|
|
||||||
|
|
||||||
A capacity rejection returns no partial result and must not invoke the
|
|
||||||
artifact reader, renderer, schema loader, validator, or model client. Prompt,
|
|
||||||
profile, and backend loading needed to select the pool may already have
|
|
||||||
occurred.
|
|
||||||
|
|
||||||
### Internal Capacity Component
|
|
||||||
|
|
||||||
Add `internal/capacity` as the single owner of engine-local run admission and
|
|
||||||
active-generation permits.
|
|
||||||
|
|
||||||
Use these package-level boundaries:
|
|
||||||
|
|
||||||
```go
|
|
||||||
var ErrCapacityExceeded error
|
|
||||||
|
|
||||||
type Manager struct {
|
|
||||||
// Private immutable pool map.
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewManager(
|
|
||||||
policies map[string]domain.BackendCapacityPolicy,
|
|
||||||
) (*Manager, error)
|
|
||||||
|
|
||||||
func (m *Manager) Admit(
|
|
||||||
ctx context.Context,
|
|
||||||
backendID string,
|
|
||||||
) (release func(), err error)
|
|
||||||
|
|
||||||
func NewClient(m *Manager, next llm.Client) llm.Client
|
|
||||||
```
|
|
||||||
|
|
||||||
`NewManager` copies the supplied map and creates one independent pool per
|
|
||||||
limited backend. Defensively reject blank IDs, non-positive concurrency
|
|
||||||
limits, negative queue capacities, or total-capacity overflow even though the
|
|
||||||
registry normally supplies normalized values. Construction creates no worker
|
|
||||||
goroutines.
|
|
||||||
|
|
||||||
An absent manager, blank backend ID, or ID absent from the policy map is
|
|
||||||
unlimited:
|
|
||||||
|
|
||||||
- `Admit` succeeds with a non-nil no-op release function; and
|
|
||||||
- the client wrapper calls the next client directly.
|
|
||||||
|
|
||||||
For a limited pool, `Admit` is immediate and context-aware:
|
|
||||||
|
|
||||||
1. return `ctx.Err()` if the context is already done;
|
|
||||||
2. under the pool lock, compare admitted runs with
|
|
||||||
`ConcurrencyLimit + QueueCapacity`;
|
|
||||||
3. return an error matching internal `ErrCapacityExceeded` when full; or
|
|
||||||
4. increment admitted runs and return an idempotent release function.
|
|
||||||
|
|
||||||
The release function decrements admission exactly once, even if accidentally
|
|
||||||
called more than once. It does not release an active-generation permit; those
|
|
||||||
permits have their own lifetime.
|
|
||||||
|
|
||||||
### FIFO Generation Permits
|
|
||||||
|
|
||||||
`NewClient` returns an internal `llm.Client` wrapper around either the built-in
|
|
||||||
client or the public-client adapter. It must preserve requests, successful
|
|
||||||
responses, nil responses, and collaborator error identities exactly.
|
|
||||||
`next` must be non-nil; `NewEngine` and internal runner construction maintain
|
|
||||||
that invariant. A nil manager returns `next` unchanged.
|
|
||||||
|
|
||||||
For a configured backend ID, the wrapper:
|
|
||||||
|
|
||||||
1. acquires one active-generation permit from the matching pool;
|
|
||||||
2. waits in FIFO order when the active count equals `ConcurrencyLimit`;
|
|
||||||
3. removes a canceled waiter and returns `ctx.Err()` when cancellation wins
|
|
||||||
before the permit is granted;
|
|
||||||
4. invokes the next client only after a permit is granted; and
|
|
||||||
5. releases the permit with `defer` after every success, nil response,
|
|
||||||
collaborator error, panic unwinding, or context outcome.
|
|
||||||
|
|
||||||
Implement FIFO and cancellation explicitly with a mutex and an ordered waiter
|
|
||||||
list. A channel used only as a counting semaphore is insufficient because it
|
|
||||||
does not define FIFO ordering or safe removal of canceled waiters.
|
|
||||||
|
|
||||||
Permit grant and cancellation must have one lock-protected linearization
|
|
||||||
point. If cancellation removes the waiter first, do not invoke the next
|
|
||||||
client. If grant wins first, invoke the next client with the caller's context;
|
|
||||||
the next client may then observe cancellation normally. Never lose or
|
|
||||||
double-release a permit in this race.
|
|
||||||
|
|
||||||
Releasing a permit transfers it to the oldest non-canceled waiter before
|
|
||||||
making it generally available. Different backend pools never share admission
|
|
||||||
or active counts.
|
|
||||||
|
|
||||||
The active wrapper enforces its limit even if an internal caller invokes it
|
|
||||||
without a run admission lease. Bounded backlog is guaranteed for ordinary
|
|
||||||
engine `Run` calls by the runner admission path; no public API exposes the
|
|
||||||
wrapped internal client directly.
|
|
||||||
|
|
||||||
### Engine Assembly
|
|
||||||
|
|
||||||
In `NewEngine`, after constructing the validated backend registry:
|
|
||||||
|
|
||||||
1. obtain `backendRegistry.CapacityPolicies()`;
|
|
||||||
2. construct one `capacity.Manager`;
|
|
||||||
3. construct the selected base internal LLM client exactly as today;
|
|
||||||
4. wrap that base client with `capacity.NewClient`; and
|
|
||||||
5. pass both the wrapped client and manager-as-admitter to the runner.
|
|
||||||
|
|
||||||
Every `NewEngine` call constructs a distinct manager. Do not cache managers,
|
|
||||||
pools, or policies in package globals. The wrapper must be applied after a
|
|
||||||
public injected client is adapted to `internal/llm.Client`, so built-in and
|
|
||||||
injected clients receive identical scheduling behavior.
|
|
||||||
|
|
||||||
If `NewManager` reports a defensive configuration error, make `NewEngine`
|
|
||||||
return an error matching `ErrInvalidConfig`.
|
|
||||||
|
|
||||||
`Prepare` does not use the manager. An injected client remains required to be
|
|
||||||
safe for concurrent calls because different backend pools and unlimited
|
|
||||||
backends may still invoke it concurrently.
|
|
||||||
|
|
||||||
### Shared Two-Phase Preparation
|
|
||||||
|
|
||||||
Refactor `internal/usecase.Runner` so `Prepare` and `Run` share one preparation
|
|
||||||
pipeline with two private phases. Do not duplicate prompt/profile/backend
|
|
||||||
selection or execution precedence.
|
|
||||||
|
|
||||||
The first phase resolves only the state required before admission:
|
|
||||||
|
|
||||||
1. validate `PromptID`;
|
|
||||||
2. normalize the direct session ID;
|
|
||||||
3. load the prompt definition;
|
|
||||||
4. hash the original prompt definition at its existing error-order position;
|
|
||||||
5. select and load the execution profile;
|
|
||||||
6. resolve the selected backend;
|
|
||||||
7. resolve and validate the effective execution target and credentials; and
|
|
||||||
8. resolve the effective output contract without loading its schema.
|
|
||||||
|
|
||||||
Return a private state value containing the loaded definition, normalized
|
|
||||||
direct session, prompt-definition hash, selected profile ID, effective target,
|
|
||||||
numeric-presence metadata, effective output contract, and preparation start
|
|
||||||
time. Keep this value private to `internal/usecase`.
|
|
||||||
|
|
||||||
The second phase consumes that state and performs:
|
|
||||||
|
|
||||||
1. structured-output schema loading;
|
|
||||||
2. artifact loading and input hashing;
|
|
||||||
3. message and prompt-session rendering;
|
|
||||||
4. direct-session application;
|
|
||||||
5. rendered-prompt hashing; and
|
|
||||||
6. `PreparedRun` construction and timing.
|
|
||||||
|
|
||||||
Preserve every existing precedence rule, error identity, direct-session
|
|
||||||
template bypass, hash input, selected identity, copy guarantee, and timing
|
|
||||||
field. Do not reload the prompt, profile, or backend between phases.
|
|
||||||
|
|
||||||
`Runner.Prepare` records its start time, runs both phases consecutively, and
|
|
||||||
never calls admission. Its behavior and error ordering remain unchanged.
|
|
||||||
|
|
||||||
`Runner.Run` records its existing run start time, runs the first preparation
|
|
||||||
phase, and then calls:
|
|
||||||
|
|
||||||
```go
|
|
||||||
release, err := r.admitter.Admit(ctx, effectiveBackendID)
|
|
||||||
```
|
|
||||||
|
|
||||||
Use a narrow use-case-owned interface with the same signature:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type RunAdmitter interface {
|
|
||||||
Admit(context.Context, string) (func(), error)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
A nil admitter means unlimited behavior for internal constructors and tests.
|
|
||||||
On successful admission, immediately `defer release()` around the remainder of
|
|
||||||
the run. Then run the second preparation phase, initial generation,
|
|
||||||
validation, and all repair attempts.
|
|
||||||
|
|
||||||
If admission returns internal `capacity.ErrCapacityExceeded`, add useful
|
|
||||||
backend context without changing its identity. If it returns `ctx.Err()`,
|
|
||||||
preserve that identity directly rather than recategorizing it as invalid
|
|
||||||
request or generation failure.
|
|
||||||
|
|
||||||
This refactor intentionally replaces the current literal `Run`-calls-`Prepare`
|
|
||||||
implementation with shared private phases. Update current-state documentation
|
|
||||||
to describe one shared pipeline rather than retaining an inaccurate call-graph
|
|
||||||
claim.
|
|
||||||
|
|
||||||
### Generation And Repair Lifetime
|
|
||||||
|
|
||||||
The admission lease covers the entire accepted run:
|
|
||||||
|
|
||||||
- second-phase preparation;
|
|
||||||
- initial generation;
|
|
||||||
- validation;
|
|
||||||
- every repair; and
|
|
||||||
- all failure and cancellation exits.
|
|
||||||
|
|
||||||
Preparation and validation do not hold an active-generation permit. The
|
|
||||||
wrapped client acquires a permit only around each actual `Generate` call.
|
|
||||||
|
|
||||||
The runner's initial generation already carries the effective backend ID in
|
|
||||||
`GenerateRequest.Target`. Preserve that value. `RepairRequest.Target` and the
|
|
||||||
default repairer's generated request must continue carrying the same backend
|
|
||||||
ID, allowing each repair to reacquire the same pool's active permit.
|
|
||||||
|
|
||||||
When testing or constructing `NewRunnerWithRepairer`, pass the same wrapped
|
|
||||||
client to both the runner and `NewDefaultOutputRepairer`. Do not add capacity
|
|
||||||
state to `RepairRequest`, `ExecutionTarget`, or public generation values.
|
|
||||||
|
|
||||||
A repair remains within its existing admission lease. It waits for a FIFO
|
|
||||||
active permit but never performs a second bounded admission and therefore
|
|
||||||
cannot fail merely because later runs filled the admission capacity.
|
|
||||||
|
|
||||||
### Error And Cancellation Semantics
|
|
||||||
|
|
||||||
The required public outcomes are:
|
|
||||||
|
|
||||||
| Situation | Required error identity |
|
|
||||||
| --- | --- |
|
|
||||||
| Admission capacity is full | `ErrCapacityExceeded` only; not `ErrInvalidRequest` or `ErrLLMGenerate`. |
|
|
||||||
| Context is done before admission succeeds | Preserve `ctx.Err()`; do not return capacity exhaustion. |
|
|
||||||
| Context cancels while waiting for an active permit | Preserve `ctx.Err()` through the existing `ErrLLMGenerate` generation category. |
|
|
||||||
| Wrapped client fails after permit acquisition | Preserve existing `ErrLLMGenerate` and collaborator identities. |
|
|
||||||
| Preparation or validation fails after admission | Preserve its existing category and release admission. |
|
|
||||||
|
|
||||||
Maintain the existing rule that `Run` returns no partial result on any
|
|
||||||
operational error. Do not add queue status to errors or results.
|
|
||||||
|
|
||||||
`RunResult.Duration` continues to start at runner entry and therefore includes
|
|
||||||
pre-admission resolution, accepted preparation, and active-permit waiting.
|
|
||||||
`PreparedRun.DurationMS` continues to cover only its shared preparation phases;
|
|
||||||
it does not include later generation waiting. Capacity-rejected calls have no
|
|
||||||
result or timing value.
|
|
||||||
|
|
||||||
### Ownership And Concurrency Safety
|
|
||||||
|
|
||||||
The registry, capacity policy map, pool map, and per-pool limits are immutable
|
|
||||||
after engine construction. Only admission counts, active counts, and waiter
|
|
||||||
lists are mutable and must be protected by the owning pool mutex.
|
|
||||||
|
|
||||||
Do not retain public queue pointers, caller request values, contexts, or
|
|
||||||
generation requests after their call completes. A canceled waiter must be
|
|
||||||
unlinked so its context and request cannot remain reachable from the pool.
|
|
||||||
|
|
||||||
Do not hold a pool mutex while:
|
|
||||||
|
|
||||||
- loading or rendering prompts;
|
|
||||||
- reading artifacts or schemas;
|
|
||||||
- invoking a model client;
|
|
||||||
- validating output;
|
|
||||||
- closing a waiter notification channel if the implementation could re-enter
|
|
||||||
pool code; or
|
|
||||||
- calling consumer code.
|
|
||||||
|
|
||||||
No scheduler operation may spawn a goroutine whose lifetime outlasts the
|
|
||||||
calling `Run`. The zero steady-state goroutine count is part of the
|
|
||||||
in-process/no-worker-lifecycle design.
|
|
||||||
|
|
||||||
## Test Ownership
|
|
||||||
|
|
||||||
Use this ownership split and avoid repeating the full policy matrix at every
|
|
||||||
layer:
|
|
||||||
|
|
||||||
- `internal/backend/registry_test.go` owns normalization, validation, the exact
|
|
||||||
OpenRouter policy, the custom default queue, explicit zero, unlimited
|
|
||||||
omission, and policy-map copying.
|
|
||||||
- `internal/capacity/manager_test.go` owns admission bounds, idempotent release,
|
|
||||||
FIFO active permits, cancellation races, capacity recovery, independent
|
|
||||||
pools, unlimited IDs, and observed peak concurrency.
|
|
||||||
- `internal/capacity/client_test.go` owns wrapper request/response/error
|
|
||||||
transparency and the rule that cancellation before grant does not invoke the
|
|
||||||
next client. Combine these with manager tests if one coherent package test
|
|
||||||
expresses the behavior more clearly.
|
|
||||||
- `internal/usecase/runner_test.go` owns two-phase preparation parity, pool
|
|
||||||
selection, admission before expensive work, admission release across run
|
|
||||||
exits, `Prepare` bypass, and repair reuse of the admitted backend.
|
|
||||||
- Root external-package tests own public configuration conversion, assembled
|
|
||||||
engine-local behavior, endpoint-override routing, injected-client limiting,
|
|
||||||
and public capacity/context error identities.
|
|
||||||
- Existing model-client HTTP tests remain unchanged because scheduling does
|
|
||||||
not alter the OpenAI-compatible wire contract.
|
|
||||||
|
|
||||||
Concurrency tests must use test-owned limits such as one or two and
|
|
||||||
channel-controlled blocking clients. Record observed active and peak counts
|
|
||||||
under a mutex or atomics. Do not use `time.Sleep` to infer queue state.
|
|
||||||
Package-internal tests may inspect a waiter list under its mutex through a
|
|
||||||
small test helper when necessary to establish deterministic FIFO ordering; do
|
|
||||||
not add production metrics or hooks solely for tests.
|
|
||||||
|
|
||||||
Do not add separate tests for trivial scalar copies when registry or assembled
|
|
||||||
behavior already protects them.
|
|
||||||
|
|
||||||
## Stage 1 — Backend Policy And Public Configuration
|
|
||||||
|
|
||||||
**Status:** Complete.
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Add the public and internal backend policy representation, normalize all
|
|
||||||
configured states, and expose immutable normalized policies without changing
|
|
||||||
runtime scheduling yet.
|
|
||||||
|
|
||||||
### Work
|
|
||||||
|
|
||||||
1. Add `ConcurrencyLimit` and `QueueCapacity` to `Backend` in `backends.go`
|
|
||||||
with exact GoDoc for unlimited, defaulted, explicit-zero, invalid, and
|
|
||||||
engine-scoped behavior.
|
|
||||||
2. Convert the public queue pointer into scalar value plus presence in
|
|
||||||
`WithBackend`; do not retain the pointer.
|
|
||||||
3. Add the internal backend policy fields and
|
|
||||||
`BackendCapacityPolicy` to `internal/domain/domain.go`.
|
|
||||||
4. Add the two registry-owned constants and configure the built-in OpenRouter
|
|
||||||
definition with 16 and 1024.
|
|
||||||
5. Extend `normalizeBackend` with the fixed validation, defaulting, explicit
|
|
||||||
zero, and overflow rules.
|
|
||||||
6. Add `Registry.CapacityPolicies`, returning only limited policies in a fresh
|
|
||||||
map.
|
|
||||||
7. Update existing backend composite literals and assertions only where the
|
|
||||||
new fields are relevant. Continue using keyed literals.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
1. Extend the exact built-in registry test with the OpenRouter limit and queue.
|
|
||||||
2. Add one coherent table covering unlimited omission, default queue,
|
|
||||||
explicit-zero queue, negative values, queue-without-limit, and total
|
|
||||||
overflow.
|
|
||||||
3. Extend the registry copy/normalization test to prove returned policy maps
|
|
||||||
cannot mutate registry state.
|
|
||||||
4. Add root coverage only if needed to prove the public pointer/presence
|
|
||||||
conversion; do not reproduce registry validation cases at the facade.
|
|
||||||
|
|
||||||
### Focused Validation
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
gofmt -w backends.go internal/domain/domain.go \
|
|
||||||
internal/backend/registry.go internal/backend/registry_test.go
|
|
||||||
go test . ./internal/backend
|
|
||||||
go vet . ./internal/backend
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
|
|
||||||
Include another touched Go test file in `gofmt` only if it actually changed.
|
|
||||||
|
|
||||||
### Completion Gate
|
|
||||||
|
|
||||||
This stage is complete when every public configuration state has one normalized
|
|
||||||
internal meaning, OpenRouter exposes exactly 16/1024, custom backends remain
|
|
||||||
unlimited by omission, and no runtime call is scheduled yet.
|
|
||||||
|
|
||||||
## Stage 2 — Engine-Local Capacity Manager
|
|
||||||
|
|
||||||
**Status:** Complete.
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Implement and prove the bounded admission mechanism and FIFO active-generation
|
|
||||||
client wrapper independently of runner orchestration.
|
|
||||||
|
|
||||||
### Work
|
|
||||||
|
|
||||||
1. Add `internal/capacity/manager.go` with the manager, immutable policy copy,
|
|
||||||
per-backend pools, internal error, immediate admission, idempotent release,
|
|
||||||
and FIFO context-aware active permits.
|
|
||||||
2. Add `internal/capacity/client.go` with the transparent `llm.Client` wrapper.
|
|
||||||
3. Use mutex-protected waiter state and an ordered list; explicitly resolve
|
|
||||||
grant-versus-cancel races.
|
|
||||||
4. Ensure unlimited and independent-pool fast paths avoid queue allocation.
|
|
||||||
5. Do not start workers, timers, cleanup goroutines, or process-global state.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
1. Add a compact constructor-validation table for blank IDs, non-positive
|
|
||||||
limits, negative queues, and total-capacity overflow.
|
|
||||||
2. With a small configured policy, prove that exactly
|
|
||||||
`limit + queueCapacity` admissions succeed, the next matches
|
|
||||||
`ErrCapacityExceeded`, and a release permits another admission.
|
|
||||||
3. Prove release is idempotent.
|
|
||||||
4. Drive more blocked client calls than the active limit and assert observed
|
|
||||||
peak concurrency never exceeds that limit.
|
|
||||||
5. Prove FIFO order with deterministic queue-entry synchronization.
|
|
||||||
6. Cancel the first and a middle waiter and prove they are removed, never call
|
|
||||||
the wrapped client, and do not block later waiters.
|
|
||||||
7. Exercise the grant/cancel race repeatedly under `go test -race`, asserting
|
|
||||||
no permit leak or double invocation.
|
|
||||||
8. Prove different backend IDs proceed independently and blank, unknown, or
|
|
||||||
nil-manager paths remain unlimited.
|
|
||||||
9. Prove request values, successful and nil responses, and collaborator errors
|
|
||||||
pass through unchanged after permit acquisition.
|
|
||||||
|
|
||||||
### Focused Validation
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
gofmt -w internal/capacity/manager.go \
|
|
||||||
internal/capacity/manager_test.go \
|
|
||||||
internal/capacity/client.go \
|
|
||||||
internal/capacity/client_test.go
|
|
||||||
go test ./internal/capacity
|
|
||||||
go test -race ./internal/capacity
|
|
||||||
go vet ./internal/capacity
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
|
|
||||||
If tests are combined into one file, omit the nonexistent file from `gofmt`.
|
|
||||||
|
|
||||||
### Completion Gate
|
|
||||||
|
|
||||||
This stage is complete when the standalone component enforces relational
|
|
||||||
admission and active limits, FIFO cancellation is race-safe, separate pools
|
|
||||||
are independent, and the wrapper is transparent apart from waiting.
|
|
||||||
|
|
||||||
## Stage 3 — Shared Preparation And Early Run Admission
|
|
||||||
|
|
||||||
**Status:** Complete.
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Refactor runner preparation into one shared two-phase pipeline and place
|
|
||||||
bounded admission after backend resolution but before schema, artifact, and
|
|
||||||
rendering work.
|
|
||||||
|
|
||||||
### Work
|
|
||||||
|
|
||||||
1. Add the private pre-admission preparation state and split the existing
|
|
||||||
`Prepare` logic according to the fixed design.
|
|
||||||
2. Make `Runner.Prepare` call both phases without an admitter.
|
|
||||||
3. Add the `RunAdmitter` interface and runner field.
|
|
||||||
4. Update `NewRunner` and `NewRunnerWithRepairer` to accept the optional
|
|
||||||
admitter; update internal call sites with nil until root assembly is wired.
|
|
||||||
5. Change `Runner.Run` to use the first phase, admit by effective backend ID,
|
|
||||||
defer the returned release, and then use the second phase.
|
|
||||||
6. Preserve all existing error precedence, target resolution, hashes,
|
|
||||||
metadata, session behavior, and timing.
|
|
||||||
7. Return capacity and context errors with the fixed identities. Do not invoke
|
|
||||||
later collaborators after rejection.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
1. Keep the existing `Run`/`Prepare` parity coverage passing to prove the
|
|
||||||
shared phases do not drift.
|
|
||||||
2. Add a fake admitter that records backend IDs and release calls.
|
|
||||||
3. Prove a backend-selected run admits with the selected ID even when the
|
|
||||||
endpoint is overridden.
|
|
||||||
4. Prove an endpoint-only run uses the unlimited/blank identity and that
|
|
||||||
`Prepare` never calls admission.
|
|
||||||
5. Reject admission and assert schema, artifact, renderer, validator, repairer,
|
|
||||||
and LLM collaborators are not invoked.
|
|
||||||
6. Prove admission is released after one successful run and representative
|
|
||||||
second-phase, generation, and validation errors. Prefer a small table around
|
|
||||||
the single `defer` invariant rather than duplicating every error test.
|
|
||||||
7. Retain direct-session, backend precedence, credential, hashing, and repair
|
|
||||||
tests unchanged except for constructor arguments.
|
|
||||||
|
|
||||||
### Focused Validation
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
gofmt -w internal/usecase/runner.go \
|
|
||||||
internal/usecase/runner_test.go
|
|
||||||
go test ./internal/usecase
|
|
||||||
go test -race ./internal/usecase
|
|
||||||
go vet ./internal/usecase
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
|
|
||||||
### Completion Gate
|
|
||||||
|
|
||||||
This stage is complete when `Prepare` remains unrestricted, `Run` admits after
|
|
||||||
one canonical routing phase and before expensive completion work, every exit
|
|
||||||
releases admission, and existing preparation semantics remain unchanged.
|
|
||||||
|
|
||||||
## Stage 4 — Engine Assembly And Public Runtime Contract
|
|
||||||
|
|
||||||
**Status:** Complete.
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Wire one manager into each engine, schedule built-in and injected clients,
|
|
||||||
expose the capacity error, and prove assembled runtime behavior.
|
|
||||||
|
|
||||||
### Work
|
|
||||||
|
|
||||||
1. Add public `ErrCapacityExceeded` and its exact GoDoc in `engine.go`.
|
|
||||||
2. Map internal capacity exhaustion in `errors.go`.
|
|
||||||
3. Construct the manager from the registry policy snapshot in `NewEngine`.
|
|
||||||
4. Wrap the selected internal client after built-in or injected-client
|
|
||||||
selection and pass the manager and wrapped client to the runner.
|
|
||||||
5. Update `Engine`, `NewEngine`, `Run`, `WithLLMClient`, and `LLMClient` GoDoc
|
|
||||||
only where concurrency, capacity, or cancellation statements change.
|
|
||||||
6. Ensure manager-construction errors match `ErrInvalidConfig`.
|
|
||||||
7. For internal repair coverage, construct the default repairer with the same
|
|
||||||
wrapped client used by its runner and confirm repair target backend identity
|
|
||||||
remains intact.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
1. Add an external-package assembled test with a small custom limit and a
|
|
||||||
blocking injected client; assert peak generation equals or remains below
|
|
||||||
the configured limit.
|
|
||||||
2. With queue capacity zero, block one accepted run before generation and
|
|
||||||
assert the next matching-backend run returns `ErrCapacityExceeded`, does not
|
|
||||||
match `ErrInvalidRequest` or `ErrLLMGenerate`, returns no result, and never
|
|
||||||
reaches expensive collaborators or the client.
|
|
||||||
3. In the same or another focused workflow, prove an endpoint override remains
|
|
||||||
in the selected backend's pool.
|
|
||||||
4. Prove two engines with the same backend ID have independent capacity.
|
|
||||||
5. Prove an unlimited custom backend and an endpoint-only profile preserve
|
|
||||||
concurrent behavior.
|
|
||||||
6. Cancel a call waiting for an active permit; assert it matches both
|
|
||||||
`context.Canceled` and `ErrLLMGenerate`, never invokes the injected client,
|
|
||||||
and leaves capacity reusable.
|
|
||||||
7. Add one internal repair workflow with concurrent runs or controlled permits
|
|
||||||
showing initial and repair generations never exceed the same backend limit
|
|
||||||
and repairs do not perform a second admission.
|
|
||||||
8. Extend the public error sentinel contract test with
|
|
||||||
`ErrCapacityExceeded`.
|
|
||||||
|
|
||||||
Avoid a second HTTP-level concurrency suite: the capacity client tests and one
|
|
||||||
assembled injected-client workflow already protect the shared wrapper used by
|
|
||||||
the built-in client.
|
|
||||||
|
|
||||||
### Focused Validation
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
gofmt -w engine.go errors.go backends.go \
|
|
||||||
internal/usecase/runner.go internal/usecase/runner_test.go \
|
|
||||||
engine_test.go public_contract_test.go
|
|
||||||
go test . ./internal/backend ./internal/capacity ./internal/usecase
|
|
||||||
go test -race . ./internal/capacity ./internal/usecase
|
|
||||||
go vet . ./internal/backend ./internal/capacity ./internal/usecase
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
|
|
||||||
Add any newly created capacity files to `gofmt` when they changed in this
|
|
||||||
stage.
|
|
||||||
|
|
||||||
### Completion Gate
|
|
||||||
|
|
||||||
This stage is complete when every engine has independent pools, limited runs
|
|
||||||
are bounded and FIFO at generation, endpoint routing is correct, capacity and
|
|
||||||
context errors are stable, repairs reuse admission, and both client kinds pass
|
|
||||||
through the same wrapper.
|
|
||||||
|
|
||||||
## Stage 5 — Durable Documentation And Final Validation
|
|
||||||
|
|
||||||
**Status:** Complete.
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
|
|
||||||
Move implemented contracts into their durable owners, record compatibility
|
|
||||||
impact, and validate the complete repository.
|
|
||||||
|
|
||||||
### Work
|
|
||||||
|
|
||||||
1. Review every changed exported declaration. Ensure GoDoc is the canonical
|
|
||||||
owner of exact field types, nil/zero semantics, defaulting, error identity,
|
|
||||||
engine scope, concurrency safety, cancellation, and source compatibility.
|
|
||||||
2. Update `doc.go` so its concurrency summary acknowledges backend scheduling
|
|
||||||
while continuing to require injected collaborators to be concurrency-safe.
|
|
||||||
3. Update `docs/consumers/pkg-promptkit.md` with task-oriented examples for:
|
|
||||||
- a limited local backend;
|
|
||||||
- omitted queue capacity selecting 1024;
|
|
||||||
- explicit zero queue capacity; and
|
|
||||||
- handling `ErrCapacityExceeded`.
|
|
||||||
Keep exact field semantics in GoDoc rather than duplicating a full table.
|
|
||||||
4. Add `docs/internal/capacity.md` as the durable owner of pool lifecycle,
|
|
||||||
admission, FIFO active permits, cancellation, client wrapping, and test
|
|
||||||
ownership.
|
|
||||||
5. Add `internal/capacity` to `docs/internal/overview.md`.
|
|
||||||
6. Update `docs/policy/architecture.md` to include the implemented component
|
|
||||||
and root assembly dependency without turning policy into an API reference.
|
|
||||||
7. Update `docs/internal/runner.md` to describe the shared two-phase
|
|
||||||
preparation pipeline, early bounded admission, lease lifetime, generation
|
|
||||||
permits, repairs, capacity failures, and cancellation.
|
|
||||||
8. Review `docs/formats.md`; add only a concise link or clarification if needed
|
|
||||||
to explain that endpoint overrides preserve backend capacity identity.
|
|
||||||
Do not add concurrency fields to YAML.
|
|
||||||
9. Do not change the OpenAI-compatible integration contract or
|
|
||||||
`docs/internal/llm.md` unless implementation changes their current
|
|
||||||
statements; scheduling is outside the provider wire contract and concrete
|
|
||||||
model-client implementation.
|
|
||||||
10. Record in the implementation handoff that built-in OpenRouter now limits
|
|
||||||
active generations to 16 with queue capacity 1024 and that the release
|
|
||||||
must be a pre-`v1` minor release. Do not edit the release procedure or
|
|
||||||
create a tag.
|
|
||||||
11. After every check passes, set `concurrency.md`, this implementation plan,
|
|
||||||
and each stage status to `Complete`. Do not remove the roadmaps in the
|
|
||||||
implementation change; lifecycle retirement follows review.
|
|
||||||
|
|
||||||
### Full Validation
|
|
||||||
|
|
||||||
Run the complete sequence from `docs/development.md`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./...
|
|
||||||
go test -race ./...
|
|
||||||
go vet ./...
|
|
||||||
go build ./...
|
|
||||||
go run ./examples/go-library/prepare
|
|
||||||
gofmt -l $(git ls-files '*.go')
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
|
|
||||||
The formatting command must produce no paths. Follow every added or changed
|
|
||||||
Markdown link and confirm its target and heading exist.
|
|
||||||
|
|
||||||
Also inspect:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git status --short
|
|
||||||
git diff --stat
|
|
||||||
git diff
|
|
||||||
```
|
|
||||||
|
|
||||||
Confirm that:
|
|
||||||
|
|
||||||
- only intended backend, capacity, runner, facade, test, documentation, and
|
|
||||||
roadmap files changed;
|
|
||||||
- no `go.work`, `go.work.sum`, local module replacement, credential,
|
|
||||||
generated binary, coverage output, or unrelated change was introduced;
|
|
||||||
- the built-in OpenRouter policy is exactly 16/1024;
|
|
||||||
- custom and endpoint-only backends remain unlimited by omission;
|
|
||||||
- explicit queue zero is distinguishable from omission;
|
|
||||||
- no capacity value enters execution targets, generated requests, stable JSON,
|
|
||||||
prompt/profile YAML, or provider payloads;
|
|
||||||
- every engine owns distinct pools with no package-global mutable state;
|
|
||||||
- every initial and repair generation uses the active permit wrapper;
|
|
||||||
- capacity and waiter state is released on success, error, panic unwinding,
|
|
||||||
and cancellation;
|
|
||||||
- concurrency tests use deterministic coordination rather than sleeps;
|
|
||||||
- current-state documentation describes implemented behavior rather than
|
|
||||||
referring readers to the roadmaps; and
|
|
||||||
- the feature and implementation roadmaps contain no unresolved work marked
|
|
||||||
complete.
|
|
||||||
|
|
||||||
### Completion Gate
|
|
||||||
|
|
||||||
The implementation is complete only when every target-end-state item in
|
|
||||||
`concurrency.md` is implemented, race-enabled tests demonstrate the configured
|
|
||||||
limits and cancellation safety, durable contracts no longer depend on roadmap
|
|
||||||
prose, and the OpenRouter compatibility change is clearly reported for the
|
|
||||||
next minor release.
|
|
||||||
|
|
||||||
## Implementation Handoff
|
|
||||||
|
|
||||||
Backend-specific capacity management is implemented and has passed the complete
|
|
||||||
repository validation sequence. The built-in OpenRouter backend now permits 16
|
|
||||||
active generations and a waiting capacity of 1024. Custom backends remain
|
|
||||||
unlimited when their limit is omitted, and endpoint-only profiles remain
|
|
||||||
unlimited.
|
|
||||||
|
|
||||||
Publishing this behavior requires a pre-`v1` minor release. Its release notes
|
|
||||||
must identify that unusually high concurrent OpenRouter use can now wait or
|
|
||||||
return `ErrCapacityExceeded`. This implementation does not change a module
|
|
||||||
version or create a tag.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
None. The feature roadmap and this plan fix the public representation,
|
|
||||||
registry defaults, admission bound, FIFO generation behavior, early-routing
|
|
||||||
refactor, cancellation races, error identities, engine and repair lifetimes,
|
|
||||||
test ownership, compatibility treatment, and non-goals required for
|
|
||||||
implementation.
|
|
||||||
588
engine.go
588
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.
|
||||||
@@ -63,28 +52,46 @@ var (
|
|||||||
// ErrPromptRender identifies a failure to render prompt messages or the
|
// ErrPromptRender identifies a failure to render prompt messages or the
|
||||||
// session ID from the resolved inputs and variables.
|
// session ID from the resolved inputs and variables.
|
||||||
ErrPromptRender = errors.New("failed to render prompt")
|
ErrPromptRender = errors.New("failed to render prompt")
|
||||||
// ErrCapacityExceeded identifies a Run rejected because the selected backend
|
// ErrCapacityExceeded identifies a Run or RunPrepared rejected because the
|
||||||
// already admitted ConcurrencyLimit + QueueCapacity calls. It is not an
|
// selected backend already admitted ConcurrencyLimit + QueueCapacity calls.
|
||||||
// invalid request, an LLM or provider rate-limit response, or ErrLLMGenerate.
|
// A [CapacityError] reports the selected backend ID. It is not an invalid
|
||||||
|
// 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.
|
||||||
ErrValidation = errors.New("failed to validate output")
|
ErrValidation = errors.New("failed to validate output")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Engine prepares and runs Promptkit prompt requests.
|
// Engine inspects prompts and profiles and prepares and runs Promptkit prompt
|
||||||
|
// requests.
|
||||||
//
|
//
|
||||||
// An Engine is safe for concurrent calls to [Engine.Prepare] and [Engine.Run].
|
// An Engine is safe for concurrent calls to [Engine.InspectPrompt],
|
||||||
// Each Engine owns independent backend-capacity pools that coordinate Run
|
// [Engine.InspectProfile], [Engine.Prepare], [Engine.PrepareExecution],
|
||||||
// admission and model generation. Injected collaborators may still be invoked
|
// [Engine.Run], and [Engine.RunPrepared]. Each Engine owns independent
|
||||||
// concurrently across different backend pools or for unlimited backends.
|
// backend-capacity pools that coordinate Run and RunPrepared admission and
|
||||||
|
// model generation. Injected collaborators may still be invoked concurrently
|
||||||
|
// 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
|
||||||
@@ -94,9 +101,10 @@ type Config struct {
|
|||||||
// It is required unless a WithPromptFS or WithPromptFile option supplies the
|
// It is required unless a WithPromptFS or WithPromptFile option supplies the
|
||||||
// prompt source.
|
// prompt source.
|
||||||
PromptDir string
|
PromptDir string
|
||||||
// ProfileDir is an optional directory whose profiles take precedence over
|
// ProfileDir is an optional ordinary configured source whose profiles take
|
||||||
// embedded built-in profiles. An empty value selects only built-ins unless
|
// precedence over application fallback and maintained catalog profiles. An
|
||||||
// profile options are also supplied.
|
// empty value selects the lower-precedence sources unless a profile-source
|
||||||
|
// option supplies the ordinary source.
|
||||||
ProfileDir string
|
ProfileDir string
|
||||||
// SchemaDir is the root for JSON Schema files. An empty value uses the
|
// SchemaDir is the root for JSON Schema files. An empty value uses the
|
||||||
// current directory. WithSchemaFS or WithSchemaFile replaces this source.
|
// current directory. WithSchemaFS or WithSchemaFile replaces this source.
|
||||||
@@ -110,17 +118,22 @@ 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.
|
||||||
//
|
//
|
||||||
// NewEngine applies options in argument order and ignores nil options. Within
|
// NewEngine applies options in argument order and ignores nil options. Within
|
||||||
// each prompt-source, profile-source, in-memory-profile, schema-source,
|
// each prompt-source, ordinary-profile-source, fallback-profile-source,
|
||||||
// model-client, and artifact-reader category, the last non-nil valid option
|
// in-memory-profile, schema-source, model-client, and artifact-reader
|
||||||
// replaces earlier options in that category. WithBackend is the additive
|
// category, the last non-nil valid option replaces earlier options in that
|
||||||
// exception: unique registrations accumulate, and a repeated backend ID is an
|
// category. WithBackend is the additive exception: unique registrations
|
||||||
// error rather than a replacement. An invalid option fails construction even
|
// accumulate, and a repeated backend ID is an error rather than a replacement.
|
||||||
// if a later option would replace it.
|
// An invalid option fails construction even if a later option would replace it.
|
||||||
type Option interface {
|
type Option interface {
|
||||||
apply(*engineOptions) error
|
apply(*engineOptions) error
|
||||||
}
|
}
|
||||||
@@ -132,32 +145,23 @@ 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
|
|
||||||
memoryProfiles profile.Repository
|
|
||||||
backends []domain.Backend
|
|
||||||
validator validate.Validator
|
|
||||||
promptSource bool
|
|
||||||
profileSource bool
|
|
||||||
memorySource bool
|
|
||||||
validatorSource bool
|
|
||||||
artifactSource bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithLLMClient replaces the built-in model client used by [Engine.Run].
|
// WithLLMClient replaces the built-in model client used by [Engine.Run] and
|
||||||
|
// [Engine.RunPrepared].
|
||||||
//
|
//
|
||||||
// A nil client makes NewEngine fail with ErrInvalidConfig. The Engine schedules
|
// A nil client makes NewEngine fail with ErrInvalidConfig. The Engine schedules
|
||||||
// Generate calls according to the selected backend's capacity policy, but the
|
// Generate calls according to the selected backend's capacity policy, but the
|
||||||
// client may still be called concurrently across different backend pools or for
|
// client may still be called concurrently across different backend pools or for
|
||||||
// unlimited backends. The client is not used by [Engine.Prepare].
|
// unlimited backends. The client is not used by [Engine.Prepare] or
|
||||||
|
// [Engine.PrepareExecution].
|
||||||
func WithLLMClient(client LLMClient) Option {
|
func WithLLMClient(client LLMClient) Option {
|
||||||
return optionFunc(func(options *engineOptions) error {
|
return optionFunc(func(options *engineOptions) error {
|
||||||
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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -172,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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -187,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
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,73 +201,79 @@ 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.NewFSRepository(fsys, root)
|
|
||||||
options.promptSource = true
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithProfileFS loads execution profiles from fsys under root.
|
// WithProfileFS loads execution profiles from fsys under root.
|
||||||
//
|
//
|
||||||
// Profiles from this source overlay built-in profiles. Profile YAML must use
|
// Profiles from this ordinary configured source take precedence over
|
||||||
// api_key_env for environment-based credentials; raw API keys are rejected.
|
// application fallback and built-in profiles. Profile YAML must use api_key_env
|
||||||
// fsys must be non-nil and root must be non-empty; otherwise NewEngine fails
|
// for environment-based credentials; raw API keys are rejected. fsys must be
|
||||||
// with ErrInvalidConfig. This option replaces Config.ProfileDir and earlier
|
// non-nil and root must be non-empty; otherwise NewEngine fails with
|
||||||
// file or FS profile-source options, but remains below WithProfiles in
|
// ErrInvalidConfig. This option replaces Config.ProfileDir and earlier file or
|
||||||
// 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
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithProfileFile loads execution profiles from the single profile file at path.
|
// WithProfileFile loads execution profiles from the single profile file at path.
|
||||||
//
|
//
|
||||||
// The profile overlays built-in profiles. Profile YAML must use api_key_env for
|
// The profile takes precedence over application fallback and built-in profiles.
|
||||||
// environment-based credentials; raw API keys are rejected. path must name an
|
// Profile YAML must use api_key_env for environment-based credentials; raw API
|
||||||
// existing non-directory file when NewEngine applies the option. This option
|
// keys are rejected. path must name an existing non-directory file when
|
||||||
// replaces Config.ProfileDir and earlier file or FS profile-source options,
|
// NewEngine applies the option. This option replaces Config.ProfileDir and
|
||||||
// but remains below WithProfiles in precedence.
|
// earlier file or FS profile-source options, but remains below WithProfiles in
|
||||||
|
// 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)
|
// WithFallbackProfileFS supplies application-owned fallback profile
|
||||||
options.profileSource = true
|
// definitions from fsys under root.
|
||||||
return nil
|
//
|
||||||
|
// Profile lookup checks, in order, profiles supplied by WithProfiles; the
|
||||||
|
// ordinary configured source selected by WithProfileFile, WithProfileFS, or
|
||||||
|
// Config.ProfileDir; this fallback source; and Promptkit's maintained catalog
|
||||||
|
// profiles. Each source supplies a complete profile definition; profile fields
|
||||||
|
// are not merged between sources. Only an absent profile ID proceeds to the
|
||||||
|
// next source. A matching read, parse, duplicate, validation, or credential
|
||||||
|
// format failure stops resolution.
|
||||||
|
//
|
||||||
|
// Files use the ordinary strict profile YAML and api_key_env credential rules.
|
||||||
|
// Loading and validation are lazy: NewEngine validates this option's arguments
|
||||||
|
// but does not read profile files. fsys must be non-nil and root must be
|
||||||
|
// nonblank; otherwise NewEngine returns an error matching ErrInvalidConfig.
|
||||||
|
// Repeating this option replaces the earlier valid fallback source.
|
||||||
|
//
|
||||||
|
// This option controls profile-definition lookup, not provider or generation
|
||||||
|
// failover.
|
||||||
|
func WithFallbackProfileFS(fsys fs.FS, root string) Option {
|
||||||
|
return optionFunc(func(options *engineOptions) error {
|
||||||
|
return options.internal.SetFallbackProfileFS(fsys, root)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithProfiles configures in-memory profiles that take precedence over
|
// WithProfiles configures in-memory profiles that take precedence over
|
||||||
// configured profile files 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
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,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
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,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
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,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 {
|
||||||
@@ -336,94 +324,106 @@ 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 := builtin.NewRepositoryWithDirectory(cfg.ProfileDir)
|
|
||||||
if options.profileSource {
|
|
||||||
profiles = builtin.NewRepositoryWithPrimary(options.profiles)
|
|
||||||
}
|
|
||||||
if options.memorySource {
|
|
||||||
profiles = profile.NewOverlayRepository(options.memoryProfiles, profiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 fileSource(name string) (fs.FS, string, error) {
|
// InspectPrompt resolves one explicit prompt definition without selecting a
|
||||||
cleanName := strings.TrimSpace(name)
|
// profile or starting execution work.
|
||||||
if cleanName == "" {
|
//
|
||||||
return nil, "", ErrInvalidConfig
|
// InspectPrompt requires a nonblank promptID. It passes nonblank promptID and
|
||||||
|
// promptVersion values unchanged to the engine's ordinary, case-sensitive
|
||||||
|
// prompt selection. An empty version succeeds only when that source has one
|
||||||
|
// selected ID; a nonempty version selects one exact ID/version pair. The
|
||||||
|
// configured prompt source is used without merging, fallback, or enumeration.
|
||||||
|
//
|
||||||
|
// A successful result proves that the selected definition and any referenced
|
||||||
|
// message content files were structurally loaded. Inputs are returned in
|
||||||
|
// definition order. DefaultProfileID is declared metadata only and is not
|
||||||
|
// resolved. OutputContract is the normalized declared contract, with a JSON
|
||||||
|
// Schema path when declared but without loading or compiling that schema.
|
||||||
|
// PromptHash is the same opaque equality value as PreparedRun.PromptHash for
|
||||||
|
// the selected definition and observed source state; its spelling, length,
|
||||||
|
// encoding, algorithm, and security properties are not contracts.
|
||||||
|
//
|
||||||
|
// This method does not return prompt bodies, templates, source paths, schemas,
|
||||||
|
// rendered messages, or execution settings. It does not resolve a profile or
|
||||||
|
// credential, read artifacts or schemas, render, validate, admit capacity,
|
||||||
|
// contact a provider, or generate model output. The returned PromptInspection
|
||||||
|
// and its input slice are caller-owned. Filesystem-backed inspection is a
|
||||||
|
// point-in-time lookup and does not freeze a definition for later execution.
|
||||||
|
//
|
||||||
|
// A nil Engine returns an error matching ErrInvalidConfig. A blank prompt ID
|
||||||
|
// matches ErrInvalidRequest. An absent exact ID or version matches
|
||||||
|
// ErrPromptNotFound and not ErrPromptLoad. Malformed, unreadable, duplicate,
|
||||||
|
// ambiguous, referenced-content, or hashing failures match ErrPromptLoad.
|
||||||
|
// Cancellation during lookup matches ErrPromptLoad while preserving the
|
||||||
|
// context error. InspectPrompt returns no partial result on error.
|
||||||
|
func (e *Engine) InspectPrompt(
|
||||||
|
ctx context.Context,
|
||||||
|
promptID string,
|
||||||
|
promptVersion string,
|
||||||
|
) (*PromptInspection, error) {
|
||||||
|
if e == nil || e.internal == nil {
|
||||||
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
}
|
}
|
||||||
dir := filepath.Dir(cleanName)
|
|
||||||
base := filepath.Base(cleanName)
|
inspection, err := e.internal.InspectPrompt(ctx, promptID, promptVersion)
|
||||||
if base == "." || base == string(filepath.Separator) || strings.TrimSpace(base) == "" {
|
|
||||||
return nil, "", ErrInvalidConfig
|
|
||||||
}
|
|
||||||
info, err := os.Stat(cleanName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("%w: failed to access source file %q: %v", ErrInvalidConfig, cleanName, err)
|
return nil, mapPublicError(err)
|
||||||
}
|
}
|
||||||
if info.IsDir() {
|
return fromDomainPromptInspection(inspection), nil
|
||||||
return nil, "", fmt.Errorf("%w: source path %q must be a file", ErrInvalidConfig, cleanName)
|
}
|
||||||
|
|
||||||
|
// InspectProfile resolves one explicit profile without selecting a prompt or
|
||||||
|
// starting execution work.
|
||||||
|
//
|
||||||
|
// InspectProfile trims surrounding whitespace from profileID and looks up the
|
||||||
|
// resulting nonblank ID exactly and case-sensitively through the engine's
|
||||||
|
// in-memory, ordinary configured-source, application fallback, and built-in
|
||||||
|
// profile precedence. It applies the framework timeout baseline, selected
|
||||||
|
// backend, and then selected profile to EffectiveModelParams without a request
|
||||||
|
// override. BackendID is empty for an endpoint-only profile.
|
||||||
|
//
|
||||||
|
// APIKeyEnv in the returned target is an environment-variable name, never its
|
||||||
|
// value. APIKeyRequired instead reports a direct credential requirement and is
|
||||||
|
// mutually exclusive with a nonblank APIKeyEnv. InspectProfile neither derives
|
||||||
|
// an ID from a prompt default_profile nor checks credential availability, so an
|
||||||
|
// absent or blank named environment variable is not an error.
|
||||||
|
//
|
||||||
|
// The returned ProfileInspection and all nested mutable values are
|
||||||
|
// caller-owned. Filesystem-backed inspection is a point-in-time lookup and
|
||||||
|
// does not freeze the profile for a later execution. This method does not load
|
||||||
|
// a prompt, render, read artifacts or schemas, admit backend capacity, contact
|
||||||
|
// a provider, or generate model output.
|
||||||
|
//
|
||||||
|
// A nil Engine returns an error matching ErrInvalidConfig. A blank profile ID
|
||||||
|
// matches ErrInvalidRequest. An absent exact ID matches ErrProfileNotFound and
|
||||||
|
// not ErrProfileLoad. Malformed or unreadable profile data, an unknown backend,
|
||||||
|
// or an invalid resolved target matches ErrProfileLoad. Cancellation during
|
||||||
|
// profile loading matches ErrProfileLoad while preserving the context error.
|
||||||
|
// InspectProfile returns no partial result on error.
|
||||||
|
func (e *Engine) InspectProfile(ctx context.Context, profileID string) (*ProfileInspection, error) {
|
||||||
|
if e == nil || e.internal == nil {
|
||||||
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
}
|
}
|
||||||
return os.DirFS(dir), filepath.ToSlash(base), nil
|
|
||||||
|
inspection, err := e.internal.InspectProfile(ctx, profileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapPublicError(err)
|
||||||
|
}
|
||||||
|
return fromDomainProfileInspection(inspection), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -439,9 +439,10 @@ func fileSource(name string) (fs.FS, string, error) {
|
|||||||
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,33 +451,34 @@ 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)
|
||||||
}
|
}
|
||||||
return fromDomainPreparedRun(prepared), nil
|
return fromDomainPreparedRun(prepared), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run prepares a request, invokes the configured LLMClient, and validates the
|
// PrepareExecution completely prepares a prompt request without calling the
|
||||||
// generated output.
|
// configured LLMClient or reserving backend admission capacity. Validated
|
||||||
|
// RunRequest.AppendedMessages are included in the frozen effective messages.
|
||||||
//
|
//
|
||||||
// A content-validation failure is a successful run whose
|
// The returned opaque handle is bound to this Engine and permits one
|
||||||
// RunResult.Validation has Status ValidationFailed. An inability to perform
|
// [Engine.RunPrepared] invocation. Preparation freezes the selected sources,
|
||||||
// validation returns an error matching ErrValidation and no partial result.
|
// rendered messages, effective settings, inputs, provider structured-output
|
||||||
// The public Engine does not perform output repair, so validation is
|
// metadata, and validation resources needed by that invocation. The handle
|
||||||
// single-pass even when OutputContract.RepairAttempts is positive.
|
// retains a direct RunRequest.APIKey only in private execution state;
|
||||||
|
// [PreparedExecution.Details] is credential-redacted.
|
||||||
//
|
//
|
||||||
// Run can return every error category documented by [Engine.Prepare], plus
|
// The context governs preparation only. Cancellation after this method
|
||||||
// ErrCapacityExceeded and ErrLLMGenerate. ErrCapacityExceeded identifies
|
// returns does not invalidate the handle or propagate to RunPrepared.
|
||||||
// rejection before artifacts, schemas, rendering, or model generation because
|
// PrepareExecution returns the same error categories as [Engine.Prepare] and
|
||||||
// the selected backend's admission capacity is full; it does not match
|
// returns no handle on error. A nil Engine returns an error matching
|
||||||
// ErrInvalidRequest or ErrLLMGenerate. Errors from injected clients remain
|
// ErrInvalidConfig. The effective transient retry budget is frozen privately
|
||||||
// available through errors.Is. Cancellation while waiting for model-generation
|
// for RunPrepared and is absent from Details. A supplied Observer is retained
|
||||||
// capacity matches both ErrLLMGenerate and the context error. Cancellation
|
// privately for RunPrepared; preparation itself emits no events.
|
||||||
// otherwise follows the active collaborator's documented behavior. A nil
|
func (e *Engine) PrepareExecution(ctx context.Context, req RunRequest) (*PreparedExecution, error) {
|
||||||
// Engine returns ErrInvalidConfig. Run returns no partial result on error.
|
ctx = observation.WithoutExecutionState(ctx)
|
||||||
func (e *Engine) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
if e == nil || e.internal == nil {
|
||||||
if e == nil || e.runner == nil {
|
|
||||||
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,9 +487,149 @@ 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)
|
prepared, err := e.internal.PrepareExecution(ctx, domainReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapPublicError(err)
|
||||||
|
}
|
||||||
|
return &PreparedExecution{internal: prepared}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run prepares a request, invokes the configured LLMClient, and validates the
|
||||||
|
// 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
|
||||||
|
// RunResult.Validation has Status ValidationFailed. When its output contract
|
||||||
|
// has a positive repair budget, a failed eligible validation can make bounded
|
||||||
|
// additional model calls and stops at the first valid candidate. Exhaustion
|
||||||
|
// 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
|
||||||
|
// 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
|
||||||
|
// occurs before artifacts, schemas, rendering, or model generation because the
|
||||||
|
// selected backend's admission capacity is full; it does not match
|
||||||
|
// ErrInvalidRequest or ErrLLMGenerate. A built-in OpenAI-compatible non-2xx
|
||||||
|
// response is discoverable as [GenerationError]. Eligible-status exhaustion
|
||||||
|
// with a positive budget is discoverable as [RetryExhaustedError]. Injected
|
||||||
|
// errors remain available through errors.Is. Cancellation while waiting for
|
||||||
|
// 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) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
domainReq, err := toDomainRunRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := e.internal.Run(ctx, domainReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, mapPublicError(err)
|
return nil, mapPublicError(err)
|
||||||
}
|
}
|
||||||
return fromDomainRunResult(result), nil
|
return fromDomainRunResult(result), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunPrepared atomically claims and executes a handle created by
|
||||||
|
// [Engine.PrepareExecution].
|
||||||
|
//
|
||||||
|
// A valid owning-Engine invocation consumes the handle's one attempt before
|
||||||
|
// credential revalidation, backend admission, generation, or validation.
|
||||||
|
// Cancellation, capacity rejection, generation failure, operational
|
||||||
|
// validation failure, and success all leave the handle unusable. A nil,
|
||||||
|
// zero-value, foreign-Engine, discarded, claimed, or used handle returns an
|
||||||
|
// error matching ErrInvalidRequest; a nil Engine returns ErrInvalidConfig and
|
||||||
|
// does not claim the handle.
|
||||||
|
//
|
||||||
|
// The supplied context governs this execution attempt independently of the
|
||||||
|
// preparation context. It covers credential revalidation, admission,
|
||||||
|
// generation, validation, and any bounded output repair. Result timing begins
|
||||||
|
// after the claim and excludes preparation and consumer-held delay.
|
||||||
|
//
|
||||||
|
// RunPrepared can return ErrInvalidRequest, ErrAPIKeyEnvMissing,
|
||||||
|
// ErrCapacityExceeded, ErrLLMGenerate, or ErrValidation as applicable while
|
||||||
|
// preserving documented collaborator and context identities. An engine
|
||||||
|
// admission rejection is discoverable as [CapacityError] and still matches
|
||||||
|
// ErrCapacityExceeded. A built-in OpenAI-compatible non-2xx response is
|
||||||
|
// discoverable as [GenerationError]. Built-in HTTP execution failures match
|
||||||
|
// 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) {
|
||||||
|
ctx = observation.WithoutExecutionState(ctx)
|
||||||
|
if e == nil || e.internal == nil {
|
||||||
|
return nil, fmt.Errorf("%w: engine is nil", ErrInvalidConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
var internal *usecase.PreparedExecution
|
||||||
|
if prepared != nil {
|
||||||
|
internal = prepared.internal
|
||||||
|
}
|
||||||
|
var recorder *observation.Recorder
|
||||||
|
result, err := e.internal.RunPrepared(observation.WithClaimSink(ctx, &recorder), internal)
|
||||||
|
if err != nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
publicResult := fromDomainRunResult(result)
|
||||||
|
if recorder != nil {
|
||||||
|
terminal := observedFinish(publicResult, nil)
|
||||||
|
recorder.Finish(observation.FinishData{Outcome: terminal.Outcome})
|
||||||
|
}
|
||||||
|
return publicResult, nil
|
||||||
|
}
|
||||||
|
|||||||
1327
engine_test.go
1327
engine_test.go
File diff suppressed because it is too large
Load Diff
41
errors.go
41
errors.go
@@ -3,8 +3,11 @@ package promptkit
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"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"
|
||||||
@@ -14,11 +17,47 @@ func mapPublicError(err error) error {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
var internalCapacityError *usecase.CapacityError
|
||||||
|
if errors.As(err, &internalCapacityError) && internalCapacityError != nil &&
|
||||||
|
strings.TrimSpace(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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,3 +21,62 @@ func TestMapPublicErrorPreservesGenerationCancellation(t *testing.T) {
|
|||||||
t.Fatalf("mapped error=%v, want context.Canceled", err)
|
t.Fatalf("mapped error=%v, want context.Canceled", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMapPublicErrorTranslatesCapacityError(t *testing.T) {
|
||||||
|
internalErr := &usecase.CapacityError{BackendID: "limited"}
|
||||||
|
|
||||||
|
err := mapPublicError(internalErr)
|
||||||
|
var publicErr *CapacityError
|
||||||
|
if !errors.As(err, &publicErr) || publicErr == nil {
|
||||||
|
t.Fatalf("mapped error=%v, want public CapacityError", err)
|
||||||
|
}
|
||||||
|
if publicErr.BackendID != "limited" {
|
||||||
|
t.Fatalf("mapped backend ID=%q, want limited", publicErr.BackendID)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrCapacityExceeded) {
|
||||||
|
t.Fatalf("mapped error=%v, want ErrCapacityExceeded", err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrInvalidRequest) || errors.Is(err, ErrLLMGenerate) {
|
||||||
|
t.Fatalf("mapped capacity error has an unrelated category: %v", err)
|
||||||
|
}
|
||||||
|
var leakedInternalErr *usecase.CapacityError
|
||||||
|
if errors.As(err, &leakedInternalErr) {
|
||||||
|
t.Fatalf("mapped error exposes internal CapacityError: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
internalErr.BackendID = "changed"
|
||||||
|
if publicErr.BackendID != "limited" {
|
||||||
|
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.20260922.1
|
||||||
|
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260922.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.20260922.1 h1:S2/Q/JDE+m8Ca3pDhAjVbfYrdlEhs9blj0STgNopHo0=
|
||||||
|
gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2 v2.20260922.1/go.mod h1:a3VBbh9oaGXD6ruxhtwHj+AWW6DY/SSyby1QtMmYSUo=
|
||||||
|
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260922.0 h1:Y0EXc3eh12eKW2NrOviVqdk3oBo6UUjopt1YAa1B3Oc=
|
||||||
|
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260922.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=
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
ErrUnsupportedRefType = errors.New("unsupported artifact reference type")
|
ErrUnsupportedRefType = errors.New("unsupported artifact reference type")
|
||||||
ErrMissingInlineBody = errors.New("missing body for inline artifact")
|
|
||||||
ErrMissingFilePath = errors.New("missing file path for file artifact")
|
ErrMissingFilePath = errors.New("missing file path for file artifact")
|
||||||
|
ErrUnsupportedFile = errors.New("file artifact path is not a regular file")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const fileReadChunkSize = 64 * 1024
|
||||||
|
|
||||||
// Reader resolves artifact references into actual artifacts.
|
// Reader resolves artifact references into actual artifacts.
|
||||||
type Reader interface {
|
type Reader interface {
|
||||||
Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error)
|
Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error)
|
||||||
@@ -34,7 +36,7 @@ type CompositeReader struct {
|
|||||||
func NewCompositeReader() Reader {
|
func NewCompositeReader() Reader {
|
||||||
return &CompositeReader{
|
return &CompositeReader{
|
||||||
inlineReader: &inlineReader{},
|
inlineReader: &inlineReader{},
|
||||||
fileReader: &fileReader{},
|
fileReader: &fileReader{open: openArtifactFile},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +66,6 @@ func (r *inlineReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domai
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
if ref.Body == "" {
|
|
||||||
return nil, ErrMissingInlineBody
|
|
||||||
}
|
|
||||||
|
|
||||||
body := []byte(ref.Body)
|
body := []byte(ref.Body)
|
||||||
return &domain.Artifact{
|
return &domain.Artifact{
|
||||||
ContentType: defaults.ContentTypeTextPlain,
|
ContentType: defaults.ContentTypeTextPlain,
|
||||||
@@ -78,7 +76,15 @@ func (r *inlineReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domai
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type fileReader struct{}
|
type artifactFile interface {
|
||||||
|
Read([]byte) (int, error)
|
||||||
|
Stat() (os.FileInfo, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileReader struct {
|
||||||
|
open func(string) (artifactFile, error)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *fileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
func (r *fileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
||||||
select {
|
select {
|
||||||
@@ -91,25 +97,71 @@ func (r *fileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.
|
|||||||
return nil, ErrMissingFilePath
|
return nil, ErrMissingFilePath
|
||||||
}
|
}
|
||||||
|
|
||||||
return readFileArtifact(ref.URI)
|
return readFileArtifact(ctx, ref.URI, r.open)
|
||||||
}
|
}
|
||||||
|
|
||||||
func readFileArtifact(path string) (*domain.Artifact, error) {
|
func openArtifactFile(path string) (artifactFile, error) {
|
||||||
file, err := os.Open(path)
|
return os.Open(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFileArtifact(ctx context.Context, path string, open func(string) (artifactFile, error)) (*domain.Artifact, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedFile, path)
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
|
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
data, err := io.ReadAll(file)
|
openedInfo, err := file.Stat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
|
return nil, fmt.Errorf("failed to inspect opened file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if !openedInfo.Mode().IsRegular() {
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedFile, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := make([]byte, 0)
|
||||||
|
chunk := make([]byte, fileReadChunkSize)
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n, readErr := file.Read(chunk)
|
||||||
|
if n > 0 {
|
||||||
|
data = append(data, chunk[:n]...)
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if errors.Is(readErr, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read file %s: %w", path, readErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
contentType := mime.TypeByExtension(filepath.Ext(path))
|
contentType := mime.TypeByExtension(filepath.Ext(path))
|
||||||
if contentType == "" {
|
if contentType == "" {
|
||||||
contentType = defaults.ContentTypeTextPlain
|
contentType = defaults.ContentTypeTextPlain
|
||||||
}
|
}
|
||||||
|
hash := fmt.Sprintf("%x", sha256.Sum256(data))
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &domain.Artifact{
|
return &domain.Artifact{
|
||||||
Name: filepath.Base(path),
|
Name: filepath.Base(path),
|
||||||
@@ -117,6 +169,6 @@ func readFileArtifact(path string) (*domain.Artifact, error) {
|
|||||||
Body: data,
|
Body: data,
|
||||||
URI: path,
|
URI: path,
|
||||||
Size: int64(len(data)),
|
Size: int64(len(data)),
|
||||||
Hash: fmt.Sprintf("%x", sha256.Sum256(data)),
|
Hash: hash,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
43
internal/artifact/reader_fifo_linux_test.go
Normal file
43
internal/artifact/reader_fifo_linux_test.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package artifact
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFileReaderRejectsFIFOBeforeOpen(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "artifact.fifo")
|
||||||
|
if err := syscall.Mkfifo(path, 0o600); err != nil {
|
||||||
|
t.Fatalf("create fifo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
artifact *domain.Artifact
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
done := make(chan result, 1)
|
||||||
|
go func() {
|
||||||
|
artifact, err := NewCompositeReader().Read(context.Background(), domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefFile,
|
||||||
|
URI: path,
|
||||||
|
})
|
||||||
|
done <- result{artifact: artifact, err: err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-done:
|
||||||
|
if got.artifact != nil || !errors.Is(got.err, ErrUnsupportedFile) {
|
||||||
|
t.Fatalf("artifact=%#v err=%v, want nil/ErrUnsupportedFile", got.artifact, got.err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("FIFO read blocked instead of rejecting the non-regular file")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package artifact
|
package artifact
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
@@ -11,54 +12,97 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCompositeReader_Read(t *testing.T) {
|
func TestCompositeReaderRejectsUnsupportedReferences(t *testing.T) {
|
||||||
|
_, err := NewCompositeReader().Read(context.Background(), domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefType("unsupported"),
|
||||||
|
URI: "unsupported://bucket/key",
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrUnsupportedRefType) {
|
||||||
|
t.Fatalf("expected ErrUnsupportedRefType, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompositeReaderSourceParityAndOpaqueHashes(t *testing.T) {
|
||||||
reader := NewCompositeReader()
|
reader := NewCompositeReader()
|
||||||
ctx := context.Background()
|
hashes := make(map[string]string)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
}{
|
||||||
|
{name: "empty", content: ""},
|
||||||
|
{name: "ordinary", content: "same content"},
|
||||||
|
{name: "changed", content: "changed content"},
|
||||||
|
}
|
||||||
|
|
||||||
t.Run("inline artifact", func(t *testing.T) {
|
for _, tc := range tests {
|
||||||
ref := domain.ArtifactRef{
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
Type: domain.ArtifactRefInline,
|
filePath := filepath.Join(t.TempDir(), "artifact.txt")
|
||||||
Body: "hello world",
|
if err := os.WriteFile(filePath, []byte(tc.content), 0o600); err != nil {
|
||||||
}
|
t.Fatal(err)
|
||||||
art, err := reader.Read(ctx, ref)
|
}
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if string(art.Body) != "hello world" {
|
|
||||||
t.Errorf("expected 'hello world', got %s", string(art.Body))
|
|
||||||
}
|
|
||||||
if art.ContentType != "text/plain" {
|
|
||||||
t.Errorf("expected text/plain content type, got %q", art.ContentType)
|
|
||||||
}
|
|
||||||
if art.Hash != "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" {
|
|
||||||
t.Errorf("unexpected hash: %s", art.Hash)
|
|
||||||
}
|
|
||||||
if art.Size != int64(len(ref.Body)) {
|
|
||||||
t.Errorf("expected size %d, got %d", len(ref.Body), art.Size)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("inline artifact missing body", func(t *testing.T) {
|
sources := []struct {
|
||||||
ref := domain.ArtifactRef{
|
name string
|
||||||
Type: domain.ArtifactRefInline,
|
ref domain.ArtifactRef
|
||||||
Body: "",
|
wantURI string
|
||||||
}
|
}{
|
||||||
_, err := reader.Read(ctx, ref)
|
{
|
||||||
if !errors.Is(err, ErrMissingInlineBody) {
|
name: "inline",
|
||||||
t.Errorf("expected ErrMissingInlineBody, got %v", err)
|
ref: domain.ArtifactRef{Type: domain.ArtifactRefInline, Body: tc.content},
|
||||||
}
|
},
|
||||||
})
|
{
|
||||||
|
name: "inline with uri",
|
||||||
|
ref: domain.ArtifactRef{Type: domain.ArtifactRefInline, URI: "memory://input", Body: tc.content},
|
||||||
|
wantURI: "memory://input",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file",
|
||||||
|
ref: domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: filePath},
|
||||||
|
wantURI: filePath,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
t.Run("unsupported ref type", func(t *testing.T) {
|
var sourceHash string
|
||||||
ref := domain.ArtifactRef{
|
for _, source := range sources {
|
||||||
Type: domain.ArtifactRefType("unsupported"),
|
t.Run(source.name, func(t *testing.T) {
|
||||||
URI: "unsupported://bucket/key",
|
first, err := reader.Read(context.Background(), source.ref)
|
||||||
}
|
if err != nil {
|
||||||
_, err := reader.Read(ctx, ref)
|
t.Fatalf("first read: %v", err)
|
||||||
if !errors.Is(err, ErrUnsupportedRefType) {
|
}
|
||||||
t.Error("expected error for unsupported type")
|
second, err := reader.Read(context.Background(), source.ref)
|
||||||
}
|
if err != nil {
|
||||||
})
|
t.Fatalf("second read: %v", err)
|
||||||
|
}
|
||||||
|
if string(first.Body) != tc.content || first.Size != int64(len(tc.content)) {
|
||||||
|
t.Fatalf("body=%q size=%d, want %q/%d", first.Body, first.Size, tc.content, len(tc.content))
|
||||||
|
}
|
||||||
|
if first.URI != source.wantURI {
|
||||||
|
t.Fatalf("URI = %q, want %q", first.URI, source.wantURI)
|
||||||
|
}
|
||||||
|
if first.Hash == "" || first.Hash != second.Hash {
|
||||||
|
t.Fatalf("hashes are not non-empty and stable: %q/%q", first.Hash, second.Hash)
|
||||||
|
}
|
||||||
|
if sourceHash == "" {
|
||||||
|
sourceHash = first.Hash
|
||||||
|
} else if first.Hash != sourceHash {
|
||||||
|
t.Fatalf("equal content hashes differ: %q/%q", sourceHash, first.Hash)
|
||||||
|
}
|
||||||
|
if source.ref.Type == domain.ArtifactRefFile {
|
||||||
|
if first.Name != filepath.Base(filePath) || !strings.HasPrefix(first.ContentType, "text/plain") {
|
||||||
|
t.Fatalf("unexpected file metadata: %+v", first)
|
||||||
|
}
|
||||||
|
} else if first.ContentType != "text/plain" {
|
||||||
|
t.Fatalf("inline content type = %q", first.ContentType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
hashes[tc.name] = sourceHash
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if hashes["empty"] == hashes["ordinary"] || hashes["ordinary"] == hashes["changed"] {
|
||||||
|
t.Fatalf("changed content did not change opaque hash: %#v", hashes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompositeReaderCopiesInlineData(t *testing.T) {
|
func TestCompositeReaderCopiesInlineData(t *testing.T) {
|
||||||
@@ -87,94 +131,154 @@ func TestCompositeReaderCopiesInlineData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompositeReaderHonorsCancellation(t *testing.T) {
|
func TestCompositeReaderHonorsPreCancellation(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
filePath := filepath.Join(t.TempDir(), "artifact.txt")
|
||||||
cancel()
|
if err := os.WriteFile(filePath, []byte("ignored"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ref domain.ArtifactRef
|
||||||
|
}{
|
||||||
|
{name: "inline", ref: domain.ArtifactRef{Type: domain.ArtifactRefInline, Body: "ignored"}},
|
||||||
|
{name: "file", ref: domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: filePath}},
|
||||||
|
}
|
||||||
|
|
||||||
_, err := NewCompositeReader().Read(ctx, domain.ArtifactRef{
|
for _, tc := range tests {
|
||||||
Type: domain.ArtifactRefInline,
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
Body: "ignored",
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
})
|
cancel()
|
||||||
if !errors.Is(err, context.Canceled) {
|
|
||||||
t.Fatalf("expected context cancellation, got %v", err)
|
artifact, err := NewCompositeReader().Read(ctx, tc.ref)
|
||||||
|
if artifact != nil || !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("artifact=%#v err=%v, want nil/context.Canceled", artifact, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileReader_Read(t *testing.T) {
|
func TestFileReaderFailuresAndMetadata(t *testing.T) {
|
||||||
content := []byte("test file content")
|
|
||||||
filePath := filepath.Join(t.TempDir(), "artifact.txt")
|
|
||||||
if err := os.WriteFile(filePath, content, 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reader := NewCompositeReader()
|
reader := NewCompositeReader()
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
t.Run("file artifact loading", func(t *testing.T) {
|
|
||||||
ref := domain.ArtifactRef{
|
|
||||||
Type: domain.ArtifactRefFile,
|
|
||||||
URI: filePath,
|
|
||||||
}
|
|
||||||
art, err := reader.Read(ctx, ref)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if string(art.Body) != string(content) {
|
|
||||||
t.Errorf("expected %s, got %s", string(content), string(art.Body))
|
|
||||||
}
|
|
||||||
if art.Name != filepath.Base(filePath) {
|
|
||||||
t.Errorf("expected name %q, got %q", filepath.Base(filePath), art.Name)
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(art.ContentType, "text/plain") {
|
|
||||||
t.Errorf("expected text content type, got %q", art.ContentType)
|
|
||||||
}
|
|
||||||
if art.URI != filePath {
|
|
||||||
t.Errorf("expected URI %q, got %q", filePath, art.URI)
|
|
||||||
}
|
|
||||||
if art.Size != int64(len(content)) {
|
|
||||||
t.Errorf("expected size %d, got %d", len(content), art.Size)
|
|
||||||
}
|
|
||||||
if art.Hash != "60f5237ed4049f0382661ef009d2bc42e48c3ceb3edb6600f7024e7ab3b838f3" {
|
|
||||||
t.Errorf("unexpected hash: %s", art.Hash)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("missing file path", func(t *testing.T) {
|
t.Run("missing file path", func(t *testing.T) {
|
||||||
ref := domain.ArtifactRef{
|
_, err := reader.Read(context.Background(), domain.ArtifactRef{Type: domain.ArtifactRefFile})
|
||||||
Type: domain.ArtifactRefFile,
|
|
||||||
URI: "",
|
|
||||||
}
|
|
||||||
_, err := reader.Read(ctx, ref)
|
|
||||||
if !errors.Is(err, ErrMissingFilePath) {
|
if !errors.Is(err, ErrMissingFilePath) {
|
||||||
t.Errorf("expected ErrMissingFilePath, got %v", err)
|
t.Fatalf("expected ErrMissingFilePath, got %v", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("missing file", func(t *testing.T) {
|
t.Run("missing file", func(t *testing.T) {
|
||||||
ref := domain.ArtifactRef{
|
_, err := reader.Read(context.Background(), domain.ArtifactRef{
|
||||||
Type: domain.ArtifactRefFile,
|
Type: domain.ArtifactRefFile,
|
||||||
URI: filepath.Join(t.TempDir(), "missing.txt"),
|
URI: filepath.Join(t.TempDir(), "missing.txt"),
|
||||||
}
|
})
|
||||||
if _, err := reader.Read(ctx, ref); err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected missing file error")
|
t.Fatal("expected missing file error")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("directory rejected before open", func(t *testing.T) {
|
||||||
|
artifact, err := reader.Read(context.Background(), domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefFile,
|
||||||
|
URI: t.TempDir(),
|
||||||
|
})
|
||||||
|
if artifact != nil || !errors.Is(err, ErrUnsupportedFile) {
|
||||||
|
t.Fatalf("artifact=%#v err=%v, want nil/ErrUnsupportedFile", artifact, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-regular opened target rejected", func(t *testing.T) {
|
||||||
|
filePath := filepath.Join(t.TempDir(), "artifact.txt")
|
||||||
|
if err := os.WriteFile(filePath, []byte("content"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
directoryInfo, err := os.Stat(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fileReader := &fileReader{open: func(path string) (artifactFile, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &reportedInfoFile{artifactFile: file, info: directoryInfo}, nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
artifact, err := fileReader.Read(context.Background(), domain.ArtifactRef{
|
||||||
|
Type: domain.ArtifactRefFile,
|
||||||
|
URI: filePath,
|
||||||
|
})
|
||||||
|
if artifact != nil || !errors.Is(err, ErrUnsupportedFile) {
|
||||||
|
t.Fatalf("artifact=%#v err=%v, want nil/ErrUnsupportedFile", artifact, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("unknown extension uses text fallback", func(t *testing.T) {
|
t.Run("unknown extension uses text fallback", func(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "artifact.unknownextension")
|
filePath := filepath.Join(t.TempDir(), "artifact.unknownextension")
|
||||||
if err := os.WriteFile(path, content, 0o600); err != nil {
|
if err := os.WriteFile(filePath, []byte("content"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
art, err := reader.Read(ctx, domain.ArtifactRef{
|
artifact, err := reader.Read(context.Background(), domain.ArtifactRef{
|
||||||
Type: domain.ArtifactRefFile,
|
Type: domain.ArtifactRefFile,
|
||||||
URI: path,
|
URI: filePath,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("read artifact: %v", err)
|
||||||
}
|
}
|
||||||
if art.ContentType != "text/plain" {
|
if artifact.ContentType != "text/plain" {
|
||||||
t.Errorf("expected text/plain fallback, got %q", art.ContentType)
|
t.Fatalf("content type = %q", artifact.ContentType)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileReaderCancelsAfterReadProgress(t *testing.T) {
|
||||||
|
filePath := filepath.Join(t.TempDir(), "artifact.bin")
|
||||||
|
content := bytes.Repeat([]byte("x"), fileReadChunkSize*2)
|
||||||
|
if err := os.WriteFile(filePath, content, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
var opened *cancelAfterProgressFile
|
||||||
|
reader := &fileReader{open: func(path string) (artifactFile, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
opened = &cancelAfterProgressFile{artifactFile: file, cancel: cancel}
|
||||||
|
return opened, nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
artifact, err := reader.Read(ctx, domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: filePath})
|
||||||
|
if artifact != nil || !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("artifact=%#v err=%v, want nil/context.Canceled", artifact, err)
|
||||||
|
}
|
||||||
|
if opened == nil || opened.reads != 1 {
|
||||||
|
t.Fatalf("read count = %v, want one progressing read", opened)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type reportedInfoFile struct {
|
||||||
|
artifactFile
|
||||||
|
info os.FileInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *reportedInfoFile) Stat() (os.FileInfo, error) {
|
||||||
|
return f.info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type cancelAfterProgressFile struct {
|
||||||
|
artifactFile
|
||||||
|
cancel context.CancelFunc
|
||||||
|
reads int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *cancelAfterProgressFile) Read(buffer []byte) (int, error) {
|
||||||
|
n, err := f.artifactFile.Read(buffer)
|
||||||
|
if n > 0 {
|
||||||
|
f.reads++
|
||||||
|
f.cancel()
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package backend
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -19,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.
|
||||||
@@ -37,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 {
|
||||||
@@ -62,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
|
||||||
}
|
}
|
||||||
@@ -108,11 +101,13 @@ 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.
|
||||||
definition.Endpoint = strings.TrimSpace(definition.Endpoint)
|
func NormalizeDefinition(definition domain.Backend) (domain.Backend, error) {
|
||||||
if err := validateEndpoint(definition.Endpoint); err != nil {
|
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(definition.Endpoint)
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
definition.Endpoint = endpoint
|
||||||
|
|
||||||
definition.APIKeyEnv = strings.TrimSpace(definition.APIKeyEnv)
|
definition.APIKeyEnv = strings.TrimSpace(definition.APIKeyEnv)
|
||||||
if definition.APIKeyEnv != "" && !environmentVariableName.MatchString(definition.APIKeyEnv) {
|
if definition.APIKeyEnv != "" && !environmentVariableName.MatchString(definition.APIKeyEnv) {
|
||||||
@@ -182,31 +177,3 @@ func normalizeBackend(definition domain.Backend) (domain.Backend, error) {
|
|||||||
definition.ExtraParams = extraParams
|
definition.ExtraParams = extraParams
|
||||||
return definition, nil
|
return definition, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateEndpoint(endpoint string) error {
|
|
||||||
if endpoint == "" {
|
|
||||||
return errors.New("must not be blank")
|
|
||||||
}
|
|
||||||
if strings.Contains(endpoint, "#") {
|
|
||||||
return errors.New("must not contain a fragment")
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := url.Parse(endpoint)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("must be a valid URL: %w", err)
|
|
||||||
}
|
|
||||||
scheme := strings.ToLower(parsed.Scheme)
|
|
||||||
if scheme != "http" && scheme != "https" {
|
|
||||||
return errors.New("must use http or https")
|
|
||||||
}
|
|
||||||
if !parsed.IsAbs() || parsed.Hostname() == "" {
|
|
||||||
return errors.New("must be absolute and include a host")
|
|
||||||
}
|
|
||||||
if parsed.User != nil {
|
|
||||||
return errors.New("must not contain user information")
|
|
||||||
}
|
|
||||||
if parsed.RawQuery != "" || parsed.ForceQuery {
|
|
||||||
return errors.New("must not contain a query string")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
@@ -124,6 +144,11 @@ func TestManagerAdmissionHonorsContextAndUnlimitedBackends(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("construct manager: %v", err)
|
t.Fatalf("construct manager: %v", err)
|
||||||
}
|
}
|
||||||
|
release, err := manager.Admit(context.Background(), "limited")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fill limited pool: %v", err)
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancel()
|
cancel()
|
||||||
|
|||||||
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()
|
||||||
@@ -14,21 +14,12 @@ const (
|
|||||||
ContentTypeApplicationJSON = "application/json"
|
ContentTypeApplicationJSON = "application/json"
|
||||||
OpenAIChatCompletionsPath = "/chat/completions"
|
OpenAIChatCompletionsPath = "/chat/completions"
|
||||||
|
|
||||||
ExecutionDefaultTemperature = 0.0
|
|
||||||
ExecutionDefaultMaxTokens = 0
|
|
||||||
ExecutionDefaultTopP = 1.0
|
|
||||||
ExecutionDefaultTimeoutSeconds = 600
|
ExecutionDefaultTimeoutSeconds = 600
|
||||||
)
|
LLMRequestTimeoutDefault = 10 * time.Minute
|
||||||
|
|
||||||
var (
|
|
||||||
LLMRequestTimeoutDefault = 10 * time.Minute
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func ExecutionTargetDefault() domain.ExecutionTarget {
|
func ExecutionTargetDefault() domain.ExecutionTarget {
|
||||||
return domain.ExecutionTarget{
|
return domain.ExecutionTarget{
|
||||||
Temperature: ExecutionDefaultTemperature,
|
|
||||||
MaxTokens: ExecutionDefaultMaxTokens,
|
|
||||||
TopP: ExecutionDefaultTopP,
|
|
||||||
TimeoutSeconds: ExecutionDefaultTimeoutSeconds,
|
TimeoutSeconds: ExecutionDefaultTimeoutSeconds,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -97,22 +100,22 @@ type RunResult struct {
|
|||||||
// PreparedRun contains pre-LLM execution state from the prepare/render phase.
|
// PreparedRun contains pre-LLM execution state from the prepare/render phase.
|
||||||
// It must never include resolved API key values, model output, or validation data.
|
// It must never include resolved API key values, model output, or validation data.
|
||||||
type PreparedRun struct {
|
type PreparedRun struct {
|
||||||
PromptID string `json:"prompt_id"`
|
PromptID string
|
||||||
PromptVersion string `json:"prompt_version,omitempty"`
|
PromptVersion string
|
||||||
PromptHash string `json:"prompt_hash,omitempty"`
|
PromptHash string
|
||||||
SelectedProfileID string `json:"selected_profile_id"`
|
SelectedProfileID string
|
||||||
SelectedBackendID string `json:"selected_backend_id,omitempty"`
|
SelectedBackendID string
|
||||||
EffectiveModelParams ExecutionTarget `json:"effective_model_params"`
|
EffectiveModelParams ExecutionTarget
|
||||||
TargetPresence ExecutionTargetPresence `json:"-"`
|
TargetPresence ExecutionTargetPresence
|
||||||
OutputContract OutputContract `json:"output_contract"`
|
OutputContract OutputContract
|
||||||
StructuredOutput *StructuredOutputSpec `json:"structured_output,omitempty"`
|
StructuredOutput *StructuredOutputSpec
|
||||||
InputHashes map[string]string `json:"input_hashes,omitempty"`
|
InputHashes map[string]string
|
||||||
SessionID string `json:"session_id,omitempty"`
|
SessionID string
|
||||||
RenderedPromptHash string `json:"rendered_prompt_hash"`
|
RenderedPromptHash string
|
||||||
Messages []RenderedMessage `json:"messages"`
|
Messages []RenderedMessage
|
||||||
StartTime time.Time `json:"start_time,omitempty"`
|
StartTime time.Time
|
||||||
EndTime time.Time `json:"end_time,omitempty"`
|
EndTime time.Time
|
||||||
DurationMS int64 `json:"duration_ms,omitempty"`
|
DurationMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArtifactRef represents a reference to an input artifact.
|
// ArtifactRef represents a reference to an input artifact.
|
||||||
@@ -145,6 +148,16 @@ type PromptDefinition struct {
|
|||||||
Validation OutputContract `yaml:"validation"`
|
Validation OutputContract `yaml:"validation"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PromptInspection is the resolved result of exact prompt inspection.
|
||||||
|
type PromptInspection struct {
|
||||||
|
PromptID string
|
||||||
|
PromptVersion string
|
||||||
|
PromptHash string
|
||||||
|
DefaultProfileID string
|
||||||
|
Inputs []PromptInput
|
||||||
|
OutputContract OutputContract
|
||||||
|
}
|
||||||
|
|
||||||
// PromptInput describes one named input expected by a prompt definition.
|
// PromptInput describes one named input expected by a prompt definition.
|
||||||
type PromptInput struct {
|
type PromptInput struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
@@ -182,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"`
|
||||||
@@ -236,6 +250,13 @@ type ExecutionTarget struct {
|
|||||||
ExtraParams map[string]any `yaml:"extra_params" json:"extra_params"`
|
ExtraParams map[string]any `yaml:"extra_params" json:"extra_params"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProfileInspection is the resolved result of exact profile inspection.
|
||||||
|
type ProfileInspection struct {
|
||||||
|
ProfileID string
|
||||||
|
EffectiveModelParams ExecutionTarget
|
||||||
|
APIKeyRequired bool
|
||||||
|
}
|
||||||
|
|
||||||
// OutputContract defines the requirements for the output artifact.
|
// OutputContract defines the requirements for the output artifact.
|
||||||
type OutputContract struct {
|
type OutputContract struct {
|
||||||
Format OutputFormat `yaml:"format"`
|
Format OutputFormat `yaml:"format"`
|
||||||
@@ -263,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.
|
||||||
@@ -287,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.
|
||||||
|
|||||||
39
internal/domain/endpoint.go
Normal file
39
internal/domain/endpoint.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NormalizeOpenAICompatibleBaseEndpoint trims and validates a source-neutral
|
||||||
|
// OpenAI-compatible provider base endpoint.
|
||||||
|
func NormalizeOpenAICompatibleBaseEndpoint(endpoint string) (string, error) {
|
||||||
|
endpoint = strings.TrimSpace(endpoint)
|
||||||
|
if endpoint == "" {
|
||||||
|
return "", errors.New("endpoint must not be blank")
|
||||||
|
}
|
||||||
|
if strings.Contains(endpoint, "#") {
|
||||||
|
return "", errors.New("endpoint must not contain a fragment")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("endpoint must be a valid URL")
|
||||||
|
}
|
||||||
|
parsed.Scheme = strings.ToLower(parsed.Scheme)
|
||||||
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
|
return "", errors.New("endpoint must use http or https")
|
||||||
|
}
|
||||||
|
if !parsed.IsAbs() || parsed.Hostname() == "" {
|
||||||
|
return "", errors.New("endpoint must be absolute and include a host")
|
||||||
|
}
|
||||||
|
if parsed.User != nil {
|
||||||
|
return "", errors.New("endpoint must not contain user information")
|
||||||
|
}
|
||||||
|
if parsed.RawQuery != "" || parsed.ForceQuery {
|
||||||
|
return "", errors.New("endpoint must not contain a query string")
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.String(), nil
|
||||||
|
}
|
||||||
47
internal/domain/endpoint_test.go
Normal file
47
internal/domain/endpoint_test.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeOpenAICompatibleBaseEndpoint(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
endpoint string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "http host", endpoint: "http://provider.example", want: "http://provider.example"},
|
||||||
|
{name: "https nested path and whitespace", endpoint: " HTTPS://provider.example/api/openai/v1 ", want: "https://provider.example/api/openai/v1"},
|
||||||
|
{name: "IPv4 host and port", endpoint: "http://127.0.0.1:8080/v1", want: "http://127.0.0.1:8080/v1"},
|
||||||
|
{name: "IPv6 host and port", endpoint: "https://[::1]:8443/v1", want: "https://[::1]:8443/v1"},
|
||||||
|
{name: "repeated trailing slashes", endpoint: "https://provider.example/v1///", want: "https://provider.example/v1///"},
|
||||||
|
{name: "blank", endpoint: " \t\n ", wantErr: true},
|
||||||
|
{name: "relative path", endpoint: "/api/v1", wantErr: true},
|
||||||
|
{name: "scheme relative", endpoint: "//provider.example/v1", wantErr: true},
|
||||||
|
{name: "missing host", endpoint: "https:///v1", wantErr: true},
|
||||||
|
{name: "unsupported scheme", endpoint: "ftp://provider.example/v1", wantErr: true},
|
||||||
|
{name: "user information", endpoint: "https://user:secret@provider.example/v1", wantErr: true},
|
||||||
|
{name: "query", endpoint: "https://provider.example/v1?mode=chat", wantErr: true},
|
||||||
|
{name: "empty query", endpoint: "https://provider.example/v1?", wantErr: true},
|
||||||
|
{name: "fragment", endpoint: "https://provider.example/v1#chat", wantErr: true},
|
||||||
|
{name: "empty fragment", endpoint: "https://provider.example/v1#", wantErr: true},
|
||||||
|
{name: "malformed URL", endpoint: "https://provider.example/%zz", wantErr: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := NormalizeOpenAICompatibleBaseEndpoint(tc.endpoint)
|
||||||
|
if tc.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected endpoint error, got %q", got)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize endpoint: %v", err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("normalized endpoint = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
34
internal/domain/execution_settings.go
Normal file
34
internal/domain/execution_settings.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxExecutionTimeoutSeconds int64 = math.MaxInt64 / int64(time.Second)
|
||||||
|
|
||||||
|
// ValidateExecutionTargetSettings validates source-neutral execution-setting
|
||||||
|
// invariants on a resolved target.
|
||||||
|
func ValidateExecutionTargetSettings(target ExecutionTarget) error {
|
||||||
|
if !isFinite(target.Temperature) || target.Temperature < 0 || target.Temperature > 2 {
|
||||||
|
return errors.New("temperature must be finite and between 0 and 2")
|
||||||
|
}
|
||||||
|
if target.MaxTokens < 0 {
|
||||||
|
return errors.New("max_tokens must be greater than or equal to 0")
|
||||||
|
}
|
||||||
|
if !isFinite(target.TopP) || target.TopP < 0 || target.TopP > 1 {
|
||||||
|
return errors.New("top_p must be finite and between 0 and 1")
|
||||||
|
}
|
||||||
|
if target.TimeoutSeconds < 0 {
|
||||||
|
return errors.New("timeout_seconds must be greater than or equal to 0")
|
||||||
|
}
|
||||||
|
if int64(target.TimeoutSeconds) > maxExecutionTimeoutSeconds {
|
||||||
|
return errors.New("timeout_seconds exceeds the maximum supported duration")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFinite(value float64) bool {
|
||||||
|
return !math.IsNaN(value) && !math.IsInf(value, 0)
|
||||||
|
}
|
||||||
73
internal/domain/execution_settings_test.go
Normal file
73
internal/domain/execution_settings_test.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateExecutionTargetSettings(t *testing.T) {
|
||||||
|
valid := ExecutionTarget{
|
||||||
|
Temperature: 1,
|
||||||
|
MaxTokens: 1,
|
||||||
|
TopP: 0.5,
|
||||||
|
TimeoutSeconds: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
type testCase struct {
|
||||||
|
name string
|
||||||
|
change func(*ExecutionTarget)
|
||||||
|
wantErr string
|
||||||
|
}
|
||||||
|
tests := []testCase{
|
||||||
|
{name: "temperature lower boundary", change: func(v *ExecutionTarget) { v.Temperature = 0 }},
|
||||||
|
{name: "temperature finite lower neighbor", change: func(v *ExecutionTarget) { v.Temperature = math.Nextafter(0, 1) }},
|
||||||
|
{name: "temperature finite upper neighbor", change: func(v *ExecutionTarget) { v.Temperature = math.Nextafter(2, 0) }},
|
||||||
|
{name: "temperature upper boundary", change: func(v *ExecutionTarget) { v.Temperature = 2 }},
|
||||||
|
{name: "temperature below lower boundary", change: func(v *ExecutionTarget) { v.Temperature = math.Nextafter(0, math.Inf(-1)) }, wantErr: "temperature"},
|
||||||
|
{name: "temperature above upper boundary", change: func(v *ExecutionTarget) { v.Temperature = math.Nextafter(2, math.Inf(1)) }, wantErr: "temperature"},
|
||||||
|
{name: "temperature NaN", change: func(v *ExecutionTarget) { v.Temperature = math.NaN() }, wantErr: "temperature"},
|
||||||
|
{name: "temperature positive infinity", change: func(v *ExecutionTarget) { v.Temperature = math.Inf(1) }, wantErr: "temperature"},
|
||||||
|
{name: "temperature negative infinity", change: func(v *ExecutionTarget) { v.Temperature = math.Inf(-1) }, wantErr: "temperature"},
|
||||||
|
{name: "max tokens lower boundary", change: func(v *ExecutionTarget) { v.MaxTokens = 0 }},
|
||||||
|
{name: "max tokens finite neighbor", change: func(v *ExecutionTarget) { v.MaxTokens = 1 }},
|
||||||
|
{name: "max tokens below lower boundary", change: func(v *ExecutionTarget) { v.MaxTokens = -1 }, wantErr: "max_tokens"},
|
||||||
|
{name: "top p lower boundary", change: func(v *ExecutionTarget) { v.TopP = 0 }},
|
||||||
|
{name: "top p finite lower neighbor", change: func(v *ExecutionTarget) { v.TopP = math.Nextafter(0, 1) }},
|
||||||
|
{name: "top p finite upper neighbor", change: func(v *ExecutionTarget) { v.TopP = math.Nextafter(1, 0) }},
|
||||||
|
{name: "top p upper boundary", change: func(v *ExecutionTarget) { v.TopP = 1 }},
|
||||||
|
{name: "top p below lower boundary", change: func(v *ExecutionTarget) { v.TopP = math.Nextafter(0, math.Inf(-1)) }, wantErr: "top_p"},
|
||||||
|
{name: "top p above upper boundary", change: func(v *ExecutionTarget) { v.TopP = math.Nextafter(1, math.Inf(1)) }, wantErr: "top_p"},
|
||||||
|
{name: "top p NaN", change: func(v *ExecutionTarget) { v.TopP = math.NaN() }, wantErr: "top_p"},
|
||||||
|
{name: "top p positive infinity", change: func(v *ExecutionTarget) { v.TopP = math.Inf(1) }, wantErr: "top_p"},
|
||||||
|
{name: "top p negative infinity", change: func(v *ExecutionTarget) { v.TopP = math.Inf(-1) }, wantErr: "top_p"},
|
||||||
|
{name: "timeout lower boundary", change: func(v *ExecutionTarget) { v.TimeoutSeconds = 0 }},
|
||||||
|
{name: "timeout finite neighbor", change: func(v *ExecutionTarget) { v.TimeoutSeconds = 1 }},
|
||||||
|
{name: "timeout below lower boundary", change: func(v *ExecutionTarget) { v.TimeoutSeconds = -1 }, wantErr: "timeout_seconds"},
|
||||||
|
}
|
||||||
|
if strconv.IntSize == 64 {
|
||||||
|
durationLimit := maxExecutionTimeoutSeconds
|
||||||
|
tests = append(tests,
|
||||||
|
testCase{name: "timeout duration boundary", change: func(v *ExecutionTarget) { v.TimeoutSeconds = int(durationLimit) }},
|
||||||
|
testCase{name: "timeout above duration boundary", change: func(v *ExecutionTarget) { v.TimeoutSeconds = int(durationLimit) + 1 }, wantErr: "timeout_seconds"},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
target := valid
|
||||||
|
tt.change(&target)
|
||||||
|
err := ValidateExecutionTargetSettings(target)
|
||||||
|
if tt.wantErr == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validate execution settings: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("error = %v, want diagnostic containing %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
38
internal/domain/output_contract.go
Normal file
38
internal/domain/output_contract.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxOutputRepairAttempts = 3
|
||||||
|
|
||||||
|
// ValidateOutputContract validates source-neutral output-contract invariants.
|
||||||
|
func ValidateOutputContract(contract OutputContract) error {
|
||||||
|
switch contract.Format {
|
||||||
|
case FormatText, FormatMarkdown, FormatJSON:
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid output format: %q", contract.Format)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch contract.ValidationMode {
|
||||||
|
case ValidationNone, ValidationBasic, ValidationJSON, ValidationJSONSchema:
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid validation mode: %q", contract.ValidationMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if contract.ValidationMode == ValidationJSONSchema && strings.TrimSpace(contract.SchemaPath) == "" {
|
||||||
|
return errors.New("schema_path is required when validation_mode is json_schema")
|
||||||
|
}
|
||||||
|
if contract.RepairAttempts < 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
|
||||||
|
}
|
||||||
87
internal/domain/output_contract_test.go
Normal file
87
internal/domain/output_contract_test.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateOutputContract(t *testing.T) {
|
||||||
|
valid := OutputContract{
|
||||||
|
Format: FormatText,
|
||||||
|
ValidationMode: ValidationNone,
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
change func(*OutputContract)
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "text format", change: func(c *OutputContract) { c.Format = FormatText }},
|
||||||
|
{name: "markdown format", change: func(c *OutputContract) { c.Format = FormatMarkdown }},
|
||||||
|
{name: "json format", change: func(c *OutputContract) { c.Format = FormatJSON }},
|
||||||
|
{name: "empty format", change: func(c *OutputContract) { c.Format = "" }, wantErr: "format"},
|
||||||
|
{name: "unsupported format", change: func(c *OutputContract) { c.Format = OutputFormat("binary") }, wantErr: "format"},
|
||||||
|
{name: "none validation", change: func(c *OutputContract) { c.ValidationMode = ValidationNone }},
|
||||||
|
{name: "basic validation", change: func(c *OutputContract) { c.ValidationMode = ValidationBasic }},
|
||||||
|
{name: "json validation", change: func(c *OutputContract) { c.ValidationMode = ValidationJSON }},
|
||||||
|
{name: "json schema validation", change: func(c *OutputContract) {
|
||||||
|
c.ValidationMode = ValidationJSONSchema
|
||||||
|
c.SchemaPath = "schema.json"
|
||||||
|
}},
|
||||||
|
{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: "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: "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) {
|
||||||
|
c.ValidationMode = ValidationJSONSchema
|
||||||
|
c.SchemaPath = ""
|
||||||
|
}, wantErr: "schema_path"},
|
||||||
|
{name: "json schema whitespace path", change: func(c *OutputContract) {
|
||||||
|
c.ValidationMode = ValidationJSONSchema
|
||||||
|
c.SchemaPath = " \t "
|
||||||
|
}, wantErr: "schema_path"},
|
||||||
|
{name: "json schema nonblank path", change: func(c *OutputContract) {
|
||||||
|
c.ValidationMode = ValidationJSONSchema
|
||||||
|
c.SchemaPath = " schema.json "
|
||||||
|
}},
|
||||||
|
{name: "non-schema empty path", change: func(c *OutputContract) { c.SchemaPath = "" }},
|
||||||
|
{name: "non-schema populated path", change: func(c *OutputContract) { c.SchemaPath = "ignored.json" }},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
contract := valid
|
||||||
|
tt.change(&contract)
|
||||||
|
err := ValidateOutputContract(contract)
|
||||||
|
if tt.wantErr == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validate output contract: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("error = %v, want diagnostic containing %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPreparedRunJSONDoesNotIncludeSecretValues(t *testing.T) {
|
|
||||||
const envName = "PROMPTKIT_TEST_API_KEY"
|
|
||||||
const secret = "super-secret-value"
|
|
||||||
t.Setenv(envName, secret)
|
|
||||||
|
|
||||||
prepared := PreparedRun{
|
|
||||||
PromptID: "prompt.id",
|
|
||||||
PromptVersion: "v1",
|
|
||||||
PromptHash: "prompt-hash",
|
|
||||||
SelectedProfileID: "local-fast",
|
|
||||||
EffectiveModelParams: ExecutionTarget{
|
|
||||||
Endpoint: "http://llm/v1",
|
|
||||||
Model: "gpt-test",
|
|
||||||
APIKeyEnv: envName,
|
|
||||||
APIKey: secret,
|
|
||||||
},
|
|
||||||
InputHashes: map[string]string{"transcript": "hash-1"},
|
|
||||||
RenderedPromptHash: "rendered-hash",
|
|
||||||
Messages: []RenderedMessage{
|
|
||||||
{Role: "system", Content: "You are helpful."},
|
|
||||||
{Role: "user", Content: "Summarize this."},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
b, err := json.Marshal(prepared)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := string(b)
|
|
||||||
if strings.Contains(out, secret) {
|
|
||||||
t.Fatalf("prepared run JSON unexpectedly contains secret value: %s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, `"api_key_env":"`+envName+`"`) {
|
|
||||||
t.Fatalf("prepared run JSON should include api_key_env name: %s", out)
|
|
||||||
}
|
|
||||||
|
|
||||||
var top map[string]any
|
|
||||||
if err := json.Unmarshal(b, &top); err != nil {
|
|
||||||
t.Fatalf("unmarshal failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, forbidden := range []string{"raw_output", "validation", "artifact"} {
|
|
||||||
if _, ok := top[forbidden]; ok {
|
|
||||||
t.Fatalf("prepared run JSON should not include %q", forbidden)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPreparedRunJSONIncludesMessageCacheControlOnlyWhenPresent(t *testing.T) {
|
|
||||||
prepared := PreparedRun{
|
|
||||||
PromptID: "prompt.id",
|
|
||||||
SelectedProfileID: "local-fast",
|
|
||||||
EffectiveModelParams: ExecutionTarget{
|
|
||||||
Endpoint: "http://llm/v1",
|
|
||||||
Model: "gpt-test",
|
|
||||||
},
|
|
||||||
RenderedPromptHash: "rendered-hash",
|
|
||||||
Messages: []RenderedMessage{
|
|
||||||
{
|
|
||||||
Role: "system",
|
|
||||||
Content: "You are helpful.",
|
|
||||||
CacheControl: &CacheControl{
|
|
||||||
Type: CacheControlEphemeral,
|
|
||||||
TTL: "1h",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{Role: "user", Content: "Summarize this."},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
b, err := json.Marshal(prepared)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var decoded struct {
|
|
||||||
Messages []map[string]any `json:"messages"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(b, &decoded); err != nil {
|
|
||||||
t.Fatalf("unmarshal failed: %v", err)
|
|
||||||
}
|
|
||||||
if len(decoded.Messages) != 2 {
|
|
||||||
t.Fatalf("expected 2 messages, got %d", len(decoded.Messages))
|
|
||||||
}
|
|
||||||
|
|
||||||
cacheControl, ok := decoded.Messages[0]["cache_control"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected cache_control on first message, got %#v", decoded.Messages[0])
|
|
||||||
}
|
|
||||||
if cacheControl["type"] != string(CacheControlEphemeral) || cacheControl["ttl"] != "1h" {
|
|
||||||
t.Fatalf("unexpected cache_control payload: %#v", cacheControl)
|
|
||||||
}
|
|
||||||
if _, ok := decoded.Messages[1]["cache_control"]; ok {
|
|
||||||
t.Fatalf("expected second message to omit cache_control, got %#v", decoded.Messages[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPreparedRunJSONIncludesSessionIDOnlyWhenPresent(t *testing.T) {
|
|
||||||
prepared := PreparedRun{
|
|
||||||
PromptID: "prompt.id",
|
|
||||||
SelectedProfileID: "local-fast",
|
|
||||||
EffectiveModelParams: ExecutionTarget{
|
|
||||||
Endpoint: "http://llm/v1",
|
|
||||||
Model: "gpt-test",
|
|
||||||
},
|
|
||||||
SessionID: "session-123",
|
|
||||||
RenderedPromptHash: "rendered-hash",
|
|
||||||
Messages: []RenderedMessage{{Role: "user", Content: "Summarize this."}},
|
|
||||||
}
|
|
||||||
|
|
||||||
b, err := json.Marshal(prepared)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var decoded map[string]any
|
|
||||||
if err := json.Unmarshal(b, &decoded); err != nil {
|
|
||||||
t.Fatalf("unmarshal failed: %v", err)
|
|
||||||
}
|
|
||||||
if decoded["session_id"] != "session-123" {
|
|
||||||
t.Fatalf("expected session_id in prepared run JSON, got %#v", decoded["session_id"])
|
|
||||||
}
|
|
||||||
|
|
||||||
prepared.SessionID = ""
|
|
||||||
b, err = json.Marshal(prepared)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal failed: %v", err)
|
|
||||||
}
|
|
||||||
if strings.Contains(string(b), "session_id") {
|
|
||||||
t.Fatalf("expected empty session_id to be omitted, got %s", b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ import (
|
|||||||
|
|
||||||
// NormalizeSessionID applies the shared session identifier rule.
|
// NormalizeSessionID applies the shared session identifier rule.
|
||||||
func NormalizeSessionID(raw string) (string, error) {
|
func NormalizeSessionID(raw string) (string, error) {
|
||||||
|
if !utf8.ValidString(raw) {
|
||||||
|
return "", fmt.Errorf("session_id must contain valid UTF-8")
|
||||||
|
}
|
||||||
normalized := strings.TrimSpace(raw)
|
normalized := strings.TrimSpace(raw)
|
||||||
if normalized == "" {
|
if normalized == "" {
|
||||||
return "", nil
|
return "", nil
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
|
|
||||||
func TestNormalizeSessionID(t *testing.T) {
|
func TestNormalizeSessionID(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
raw string
|
raw string
|
||||||
want string
|
want string
|
||||||
wantErr bool
|
wantErrContains string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "trims surrounding Unicode whitespace",
|
name: "trims surrounding Unicode whitespace",
|
||||||
@@ -28,21 +28,24 @@ func TestNormalizeSessionID(t *testing.T) {
|
|||||||
want: strings.Repeat("界", SessionIDMaxLength),
|
want: strings.Repeat("界", SessionIDMaxLength),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "one Unicode code point over maximum is rejected",
|
name: "one Unicode code point over maximum is rejected",
|
||||||
raw: strings.Repeat("界", SessionIDMaxLength+1),
|
raw: strings.Repeat("界", SessionIDMaxLength+1),
|
||||||
wantErr: true,
|
wantErrContains: "exceeds maximum",
|
||||||
},
|
},
|
||||||
|
{name: "invalid UTF-8 before valid content", raw: string([]byte{0xff}) + "session", wantErrContains: "valid UTF-8"},
|
||||||
|
{name: "invalid UTF-8 within valid content", raw: "ses" + string([]byte{0xff}) + "sion", wantErrContains: "valid UTF-8"},
|
||||||
|
{name: "invalid UTF-8 after valid content", raw: "session" + string([]byte{0xff}), wantErrContains: "valid UTF-8"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := NormalizeSessionID(tt.raw)
|
got, err := NormalizeSessionID(tt.raw)
|
||||||
if tt.wantErr {
|
if tt.wantErrContains != "" {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected normalization error")
|
t.Fatal("expected normalization error")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "exceeds maximum") {
|
if !strings.Contains(err.Error(), tt.wantErrContains) {
|
||||||
t.Fatalf("expected useful length diagnostic, got %v", err)
|
t.Fatalf("expected diagnostic containing %q, got %v", tt.wantErrContains, err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -36,7 +36,8 @@ func FindYAMLFiles(ctx context.Context, root string) ([]string, error) {
|
|||||||
return files, err
|
return files, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindFSYAMLFiles returns sorted paths for .yaml and .yml files under root in fsys.
|
// FindFSYAMLFiles returns root itself when it names a file. For a directory
|
||||||
|
// root, it returns sorted paths for .yaml and .yml files beneath that root.
|
||||||
func FindFSYAMLFiles(ctx context.Context, fsys fs.FS, root string) ([]string, error) {
|
func FindFSYAMLFiles(ctx context.Context, fsys fs.FS, root string) ([]string, error) {
|
||||||
cleanRoot := CleanFSRoot(root)
|
cleanRoot := CleanFSRoot(root)
|
||||||
var files []string
|
var files []string
|
||||||
@@ -52,6 +53,10 @@ func FindFSYAMLFiles(ctx context.Context, fsys fs.FS, root string) ([]string, er
|
|||||||
if d.IsDir() {
|
if d.IsDir() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if name == cleanRoot {
|
||||||
|
files = append(files, name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if !IsYAMLFile(d.Name()) {
|
if !IsYAMLFile(d.Name()) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -71,10 +76,10 @@ func RelativePath(root string, filePath string) string {
|
|||||||
return filepath.Clean(rel)
|
return filepath.Clean(rel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanFSRoot normalizes a root path for use with fs.FS.
|
// CleanFSRoot normalizes a root path for use with fs.FS while preserving
|
||||||
|
// nonblank leading and trailing whitespace.
|
||||||
func CleanFSRoot(root string) string {
|
func CleanFSRoot(root string) string {
|
||||||
root = strings.TrimSpace(root)
|
if strings.TrimSpace(root) == "" || root == "." {
|
||||||
if root == "" || root == "." {
|
|
||||||
return "."
|
return "."
|
||||||
}
|
}
|
||||||
return path.Clean(root)
|
return path.Clean(root)
|
||||||
@@ -97,22 +102,21 @@ func DisplayPath(root string, name string) string {
|
|||||||
// ResolveFSPath resolves userPath from baseDir and keeps it inside root.
|
// ResolveFSPath resolves userPath from baseDir and keeps it inside root.
|
||||||
func ResolveFSPath(root string, baseDir string, userPath string) (string, string, error) {
|
func ResolveFSPath(root string, baseDir string, userPath string) (string, string, error) {
|
||||||
cleanRoot := CleanFSRoot(root)
|
cleanRoot := CleanFSRoot(root)
|
||||||
cleanBase := path.Clean(strings.TrimSpace(baseDir))
|
cleanBase := path.Clean(baseDir)
|
||||||
if cleanBase == "" {
|
if strings.TrimSpace(baseDir) == "" {
|
||||||
cleanBase = cleanRoot
|
cleanBase = cleanRoot
|
||||||
}
|
}
|
||||||
if !containsFSPath(cleanRoot, cleanBase) {
|
if !containsFSPath(cleanRoot, cleanBase) {
|
||||||
return "", "", fmt.Errorf("base path %q is outside source root %q", cleanBase, cleanRoot)
|
return "", "", fmt.Errorf("base path %q is outside source root %q", cleanBase, cleanRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanUserPath := strings.TrimSpace(userPath)
|
if strings.TrimSpace(userPath) == "" {
|
||||||
if cleanUserPath == "" {
|
|
||||||
return "", "", fmt.Errorf("path is required")
|
return "", "", fmt.Errorf("path is required")
|
||||||
}
|
}
|
||||||
cleanUserPath = path.Clean(cleanUserPath)
|
if path.IsAbs(userPath) {
|
||||||
if path.IsAbs(cleanUserPath) {
|
|
||||||
return "", "", fmt.Errorf("path %q must be relative", userPath)
|
return "", "", fmt.Errorf("path %q must be relative", userPath)
|
||||||
}
|
}
|
||||||
|
cleanUserPath := path.Clean(userPath)
|
||||||
|
|
||||||
resolved := path.Clean(path.Join(cleanBase, cleanUserPath))
|
resolved := path.Clean(path.Join(cleanBase, cleanUserPath))
|
||||||
if !containsFSPath(cleanRoot, resolved) {
|
if !containsFSPath(cleanRoot, resolved) {
|
||||||
@@ -130,13 +134,6 @@ func containsFSPath(root string, name string) bool {
|
|||||||
return name == root || strings.HasPrefix(name, strings.TrimSuffix(root, "/")+"/")
|
return name == root || strings.HasPrefix(name, strings.TrimSuffix(root, "/")+"/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stem strips .yaml or .yml from a file name.
|
|
||||||
func Stem(name string) string {
|
|
||||||
name = strings.TrimSuffix(name, ".yaml")
|
|
||||||
name = strings.TrimSuffix(name, ".yml")
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsYAMLFile(name string) bool {
|
func IsYAMLFile(name string) bool {
|
||||||
return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml")
|
return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func TestFindFSYAMLFilesNestedSortedAndFiltered(t *testing.T) {
|
|||||||
"other/ignored.yaml": &fstest.MapFile{Data: []byte("id: ignored")},
|
"other/ignored.yaml": &fstest.MapFile{Data: []byte("id: ignored")},
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := FindFSYAMLFiles(context.Background(), fsys, " prompts ")
|
got, err := FindFSYAMLFiles(context.Background(), fsys, "prompts")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -98,8 +98,11 @@ func TestCleanFSRoot(t *testing.T) {
|
|||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{name: "empty", root: "", want: "."},
|
{name: "empty", root: "", want: "."},
|
||||||
|
{name: "whitespace only", root: " \t ", want: "."},
|
||||||
{name: "dot", root: ".", want: "."},
|
{name: "dot", root: ".", want: "."},
|
||||||
{name: "trimmed", root: " prompts/../profiles ", want: "profiles"},
|
{name: "cleaned", root: "prompts/../profiles", want: "profiles"},
|
||||||
|
{name: "leading whitespace preserved", root: " profiles", want: " profiles"},
|
||||||
|
{name: "trailing whitespace preserved", root: "profiles ", want: "profiles "},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
@@ -158,6 +161,22 @@ func TestResolveFSPath(t *testing.T) {
|
|||||||
wantPath: "prompts/shared/user.tmpl",
|
wantPath: "prompts/shared/user.tmpl",
|
||||||
wantDisplay: "shared/user.tmpl",
|
wantDisplay: "shared/user.tmpl",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "leading whitespace preserved",
|
||||||
|
root: "prompts",
|
||||||
|
baseDir: "prompts/nested",
|
||||||
|
userPath: " user.tmpl",
|
||||||
|
wantPath: "prompts/nested/ user.tmpl",
|
||||||
|
wantDisplay: "nested/ user.tmpl",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trailing whitespace preserved",
|
||||||
|
root: "prompts",
|
||||||
|
baseDir: "prompts/nested",
|
||||||
|
userPath: "user.tmpl ",
|
||||||
|
wantPath: "prompts/nested/user.tmpl ",
|
||||||
|
wantDisplay: "nested/user.tmpl ",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "escape rejected",
|
name: "escape rejected",
|
||||||
root: "prompts",
|
root: "prompts",
|
||||||
@@ -218,26 +237,6 @@ func TestResolveFSPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStemStripsYAMLExtensions(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
in string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{name: "yaml", in: "prompt.yaml", want: "prompt"},
|
|
||||||
{name: "yml", in: "profile.yml", want: "profile"},
|
|
||||||
{name: "other", in: "file.txt", want: "file.txt"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
if got := Stem(tc.in); got != tc.want {
|
|
||||||
t.Fatalf("expected %q, got %q", tc.want, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsYAMLFile(t *testing.T) {
|
func TestIsYAMLFile(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package jsonvalue validates and defensively copies JSON-compatible value
|
// Package jsonvalue validates and defensively copies bounded JSON-compatible
|
||||||
// trees used by public configuration and request boundaries.
|
// value trees used by configuration, request, and prepared-state boundaries.
|
||||||
package jsonvalue
|
package jsonvalue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,23 +8,39 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxSafeJSONInteger = 1<<53 - 1
|
const (
|
||||||
|
maxContainerDepth = 100
|
||||||
|
maxProducedNodes = 100_000
|
||||||
|
)
|
||||||
|
|
||||||
type visit struct {
|
type visit struct {
|
||||||
typ reflect.Type
|
typ reflect.Type
|
||||||
ptr uintptr
|
ptr uintptr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type traversalState struct {
|
||||||
|
active map[visit]struct{}
|
||||||
|
producedNodes int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy validates and deeply copies a JSON-compatible value while preserving
|
||||||
|
// compatible concrete map, slice, array, scalar, and number types. It rejects
|
||||||
|
// cycles and values that exceed the package's traversal limits.
|
||||||
|
func Copy(src any) (any, error) {
|
||||||
|
return copyValue(reflect.ValueOf(src), "value", newTraversalState(), true, 0)
|
||||||
|
}
|
||||||
|
|
||||||
// CopyMap validates and deeply copies an extra-parameter map while preserving
|
// CopyMap validates and deeply copies an extra-parameter map while preserving
|
||||||
// compatible concrete map, slice, array, scalar, and number types.
|
// compatible concrete map, slice, array, scalar, and number types. It rejects
|
||||||
|
// empty object keys, cycles, and values that exceed the package's traversal
|
||||||
|
// limits.
|
||||||
func CopyMap(src map[string]any) (map[string]any, error) {
|
func CopyMap(src map[string]any) (map[string]any, error) {
|
||||||
if src == nil {
|
if src == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
copied, err := copyValue(reflect.ValueOf(src), "extra_params", make(map[visit]struct{}))
|
copied, err := copyValue(reflect.ValueOf(src), "extra_params", newTraversalState(), false, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -35,88 +51,121 @@ func CopyMap(src map[string]any) (map[string]any, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyValue(value reflect.Value, path string, seen map[visit]struct{}) (any, error) {
|
func copyValue(
|
||||||
if !value.IsValid() {
|
value reflect.Value,
|
||||||
|
path string,
|
||||||
|
state *traversalState,
|
||||||
|
allowEmptyMapKeys bool,
|
||||||
|
containerDepth int,
|
||||||
|
) (any, error) {
|
||||||
|
resolved, cleanup, isNull, err := state.resolveIndirection(value, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
if isNull {
|
||||||
|
if err := state.produceNode(path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if value.Kind() == reflect.Interface {
|
value = resolved
|
||||||
if value.IsNil() {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return copyValue(value.Elem(), path, seen)
|
|
||||||
}
|
|
||||||
if !value.CanInterface() {
|
if !value.CanInterface() {
|
||||||
return nil, fmt.Errorf("%s: value cannot be copied", path)
|
return nil, fmt.Errorf("%s: value cannot be copied", path)
|
||||||
}
|
}
|
||||||
if number, ok := value.Interface().(json.Number); ok {
|
if number, ok := value.Interface().(json.Number); ok {
|
||||||
if _, err := json.Marshal(number); err != nil {
|
if !validJSONNumber(number) {
|
||||||
return nil, fmt.Errorf("%s: invalid JSON number", path)
|
return nil, fmt.Errorf("%s: invalid JSON number", path)
|
||||||
}
|
}
|
||||||
f, err := strconv.ParseFloat(number.String(), 64)
|
if err := state.produceNode(path); err != nil {
|
||||||
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
|
return nil, err
|
||||||
return nil, fmt.Errorf("%s: invalid JSON number", path)
|
|
||||||
}
|
}
|
||||||
return number, nil
|
return number, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
switch value.Kind() {
|
switch value.Kind() {
|
||||||
case reflect.Bool, reflect.String:
|
case reflect.Bool, reflect.String:
|
||||||
|
if err := state.produceNode(path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return value.Interface(), nil
|
return value.Interface(), nil
|
||||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
if value.Int() < -maxSafeJSONInteger || value.Int() > maxSafeJSONInteger {
|
if err := state.produceNode(path); err != nil {
|
||||||
return nil, fmt.Errorf("%s: integer is outside the JSON-safe range", path)
|
return nil, err
|
||||||
}
|
}
|
||||||
return value.Interface(), nil
|
return value.Interface(), nil
|
||||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||||
if value.Uint() > maxSafeJSONInteger {
|
if err := state.produceNode(path); err != nil {
|
||||||
return nil, fmt.Errorf("%s: integer is outside the JSON-safe range", path)
|
return nil, err
|
||||||
}
|
}
|
||||||
return value.Interface(), nil
|
return value.Interface(), nil
|
||||||
case reflect.Float32, reflect.Float64:
|
case reflect.Float32, reflect.Float64:
|
||||||
number := value.Convert(reflect.TypeOf(float64(0))).Float()
|
number := value.Float()
|
||||||
if math.IsNaN(number) || math.IsInf(number, 0) {
|
if math.IsNaN(number) || math.IsInf(number, 0) {
|
||||||
return nil, fmt.Errorf("%s: floating-point value must be finite", path)
|
return nil, fmt.Errorf("%s: floating-point value must be finite", path)
|
||||||
}
|
}
|
||||||
|
if err := state.produceNode(path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return value.Interface(), nil
|
return value.Interface(), nil
|
||||||
case reflect.Pointer:
|
case reflect.Map:
|
||||||
if value.IsNil() {
|
if value.IsNil() {
|
||||||
|
if err := state.produceNode(path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
current := visit{typ: value.Type(), ptr: value.Pointer()}
|
nextDepth, err := state.enterContainer(path, containerDepth)
|
||||||
if _, ok := seen[current]; ok {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
|
return nil, err
|
||||||
}
|
}
|
||||||
seen[current] = struct{}{}
|
return copyMapValue(value, path, state, allowEmptyMapKeys, nextDepth)
|
||||||
defer delete(seen, current)
|
|
||||||
return copyValue(value.Elem(), path, seen)
|
|
||||||
case reflect.Map:
|
|
||||||
return copyMapValue(value, path, seen)
|
|
||||||
case reflect.Slice:
|
case reflect.Slice:
|
||||||
if value.IsNil() {
|
if value.IsNil() {
|
||||||
|
if err := state.produceNode(path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return copySequenceValue(value, path, seen)
|
nextDepth, err := state.enterContainer(path, containerDepth)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return copySequenceValue(value, path, state, allowEmptyMapKeys, nextDepth)
|
||||||
case reflect.Array:
|
case reflect.Array:
|
||||||
return copySequenceValue(value, path, seen)
|
nextDepth, err := state.enterContainer(path, containerDepth)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return copySequenceValue(value, path, state, allowEmptyMapKeys, nextDepth)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("%s: unsupported JSON value type %s", path, value.Type())
|
return nil, fmt.Errorf("%s: unsupported JSON value type %s", path, value.Type())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyMapValue(value reflect.Value, path string, seen map[visit]struct{}) (any, error) {
|
func copyMapValue(
|
||||||
if value.IsNil() {
|
value reflect.Value,
|
||||||
return nil, nil
|
path string,
|
||||||
}
|
state *traversalState,
|
||||||
|
allowEmptyMapKeys bool,
|
||||||
|
containerDepth int,
|
||||||
|
) (any, error) {
|
||||||
if value.Type().Key().Kind() != reflect.String {
|
if value.Type().Key().Kind() != reflect.String {
|
||||||
return nil, fmt.Errorf("%s: map key type %s is not supported", path, value.Type().Key())
|
return nil, fmt.Errorf("%s: map key type %s is not supported", path, value.Type().Key())
|
||||||
}
|
}
|
||||||
|
if err := state.produceNode(path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := state.ensureChildCapacity(path, value.Len()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
current := visit{typ: value.Type(), ptr: value.Pointer()}
|
current := visit{typ: value.Type(), ptr: value.Pointer()}
|
||||||
if _, ok := seen[current]; ok {
|
if _, ok := state.active[current]; ok {
|
||||||
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
|
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
|
||||||
}
|
}
|
||||||
seen[current] = struct{}{}
|
state.active[current] = struct{}{}
|
||||||
defer delete(seen, current)
|
defer delete(state.active, current)
|
||||||
|
|
||||||
keys := value.MapKeys()
|
keys := value.MapKeys()
|
||||||
sort.Slice(keys, func(i, j int) bool {
|
sort.Slice(keys, func(i, j int) bool {
|
||||||
@@ -133,10 +182,16 @@ func copyMapValue(value reflect.Value, path string, seen map[visit]struct{}) (an
|
|||||||
elementType := value.Type().Elem()
|
elementType := value.Type().Elem()
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
name := key.String()
|
name := key.String()
|
||||||
if name == "" {
|
if name == "" && !allowEmptyMapKeys {
|
||||||
return nil, fmt.Errorf("%s: map key must not be empty", path)
|
return nil, fmt.Errorf("%s: map key must not be empty", path)
|
||||||
}
|
}
|
||||||
copied, err := copyValue(value.MapIndex(key), path+"."+name, seen)
|
copied, err := copyValue(
|
||||||
|
value.MapIndex(key),
|
||||||
|
path+"."+name,
|
||||||
|
state,
|
||||||
|
allowEmptyMapKeys,
|
||||||
|
containerDepth,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -171,22 +226,41 @@ func copyMapValue(value reflect.Value, path string, seen map[visit]struct{}) (an
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func copySequenceValue(value reflect.Value, path string, seen map[visit]struct{}) (any, error) {
|
func copySequenceValue(
|
||||||
|
value reflect.Value,
|
||||||
|
path string,
|
||||||
|
state *traversalState,
|
||||||
|
allowEmptyMapKeys bool,
|
||||||
|
containerDepth int,
|
||||||
|
) (any, error) {
|
||||||
|
if err := state.produceNode(path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := state.ensureChildCapacity(path, value.Len()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
var current visit
|
var current visit
|
||||||
if value.Kind() == reflect.Slice {
|
if value.Kind() == reflect.Slice {
|
||||||
current = visit{typ: value.Type(), ptr: value.Pointer()}
|
current = visit{typ: value.Type(), ptr: value.Pointer()}
|
||||||
if _, ok := seen[current]; ok {
|
if _, ok := state.active[current]; ok {
|
||||||
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
|
return nil, fmt.Errorf("%s: cyclic value is not supported", path)
|
||||||
}
|
}
|
||||||
seen[current] = struct{}{}
|
state.active[current] = struct{}{}
|
||||||
defer delete(seen, current)
|
defer delete(state.active, current)
|
||||||
}
|
}
|
||||||
|
|
||||||
values := make([]any, value.Len())
|
values := make([]any, value.Len())
|
||||||
preserveType := true
|
preserveType := true
|
||||||
elementType := value.Type().Elem()
|
elementType := value.Type().Elem()
|
||||||
for i := 0; i < value.Len(); i++ {
|
for i := 0; i < value.Len(); i++ {
|
||||||
copied, err := copyValue(value.Index(i), fmt.Sprintf("%s[%d]", path, i), seen)
|
copied, err := copyValue(
|
||||||
|
value.Index(i),
|
||||||
|
fmt.Sprintf("%s[%d]", path, i),
|
||||||
|
state,
|
||||||
|
allowEmptyMapKeys,
|
||||||
|
containerDepth,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -222,6 +296,73 @@ func copySequenceValue(value reflect.Value, path string, seen map[visit]struct{}
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validJSONNumber(number json.Number) bool {
|
||||||
|
var parsed json.Number
|
||||||
|
if err := json.Unmarshal([]byte(number.String()), &parsed); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return parsed.String() == number.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTraversalState() *traversalState {
|
||||||
|
return &traversalState{active: make(map[visit]struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *traversalState) produceNode(path string) error {
|
||||||
|
if state.producedNodes >= maxProducedNodes {
|
||||||
|
return fmt.Errorf("%s: JSON value work limit exceeded", path)
|
||||||
|
}
|
||||||
|
state.producedNodes++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *traversalState) enterContainer(path string, depth int) (int, error) {
|
||||||
|
depth++
|
||||||
|
if depth > maxContainerDepth {
|
||||||
|
return 0, fmt.Errorf("%s: JSON container depth limit exceeded", path)
|
||||||
|
}
|
||||||
|
return depth, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *traversalState) ensureChildCapacity(path string, count int) error {
|
||||||
|
if count > maxProducedNodes-state.producedNodes {
|
||||||
|
return fmt.Errorf("%s: JSON value work limit exceeded", path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (state *traversalState) resolveIndirection(
|
||||||
|
value reflect.Value,
|
||||||
|
path string,
|
||||||
|
) (reflect.Value, func(), bool, error) {
|
||||||
|
var visits []visit
|
||||||
|
cleanup := func() {
|
||||||
|
for _, current := range visits {
|
||||||
|
delete(state.active, current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for value.IsValid() && (value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer) {
|
||||||
|
if value.IsNil() {
|
||||||
|
return reflect.Value{}, cleanup, true, nil
|
||||||
|
}
|
||||||
|
if value.Kind() == reflect.Pointer {
|
||||||
|
current := visit{typ: value.Type(), ptr: value.Pointer()}
|
||||||
|
if _, ok := state.active[current]; ok {
|
||||||
|
cleanup()
|
||||||
|
return reflect.Value{}, nil, false, fmt.Errorf("%s: cyclic value is not supported", path)
|
||||||
|
}
|
||||||
|
state.active[current] = struct{}{}
|
||||||
|
visits = append(visits, current)
|
||||||
|
}
|
||||||
|
value = value.Elem()
|
||||||
|
}
|
||||||
|
if !value.IsValid() {
|
||||||
|
return reflect.Value{}, cleanup, true, nil
|
||||||
|
}
|
||||||
|
return value, cleanup, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
func canAssignNil(typ reflect.Type) bool {
|
func canAssignNil(typ reflect.Type) bool {
|
||||||
switch typ.Kind() {
|
switch typ.Kind() {
|
||||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||||
|
|||||||
@@ -1,97 +1,332 @@
|
|||||||
package jsonvalue_test
|
package jsonvalue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"math"
|
"math"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCopyMapPreservesTypesAndIsolatesMutations(t *testing.T) {
|
type (
|
||||||
nested := map[string]int{"limit": 2}
|
namedBool bool
|
||||||
sequence := []string{"one", "two"}
|
namedString string
|
||||||
input := map[string]any{
|
namedInt64 int64
|
||||||
"count": int64(7),
|
namedUint64 uint64
|
||||||
"number": json.Number("-1.25e+2"),
|
namedFloat32 float32
|
||||||
"nested": nested,
|
namedFloat64 float64
|
||||||
"sequence": sequence,
|
namedKey string
|
||||||
}
|
namedMap map[namedKey]namedInt64
|
||||||
|
namedSlice []namedString
|
||||||
copied, err := jsonvalue.CopyMap(input)
|
namedArray [1]map[string]int
|
||||||
if err != nil {
|
)
|
||||||
t.Fatalf("copy map: %v", err)
|
|
||||||
}
|
|
||||||
nested["limit"] = 99
|
|
||||||
sequence[0] = "changed"
|
|
||||||
input["added"] = true
|
|
||||||
|
|
||||||
if got, ok := copied["count"].(int64); !ok || got != 7 {
|
|
||||||
t.Fatalf("integer type or value changed: %#v", copied["count"])
|
|
||||||
}
|
|
||||||
if got, ok := copied["number"].(json.Number); !ok || got != "-1.25e+2" {
|
|
||||||
t.Fatalf("JSON number type or value changed: %#v", copied["number"])
|
|
||||||
}
|
|
||||||
if got := copied["nested"].(map[string]int)["limit"]; got != 2 {
|
|
||||||
t.Fatalf("nested map was not isolated: %d", got)
|
|
||||||
}
|
|
||||||
if got := copied["sequence"].([]string)[0]; got != "one" {
|
|
||||||
t.Fatalf("sequence was not isolated: %q", got)
|
|
||||||
}
|
|
||||||
if _, ok := copied["added"]; ok {
|
|
||||||
t.Fatalf("top-level map was not isolated: %#v", copied)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCopyMapRejectsInvalidValues(t *testing.T) {
|
|
||||||
cyclicMap := map[string]any{}
|
|
||||||
cyclicMap["self"] = cyclicMap
|
|
||||||
cyclicSlice := []any{nil}
|
|
||||||
cyclicSlice[0] = cyclicSlice
|
|
||||||
|
|
||||||
|
func TestCopyPreservesSupportedScalarAndNumberTypes(t *testing.T) {
|
||||||
|
maxInt := int(^uint(0) >> 1)
|
||||||
|
minInt := -maxInt - 1
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
value any
|
value any
|
||||||
}{
|
}{
|
||||||
{name: "empty nested key", value: map[string]int{"": 1}},
|
{name: "bool", value: true},
|
||||||
{name: "non-string map key", value: map[int]string{1: "one"}},
|
{name: "named bool", value: namedBool(true)},
|
||||||
{name: "unsupported value", value: make(chan int)},
|
{name: "string", value: "value"},
|
||||||
{name: "cyclic map", value: cyclicMap},
|
{name: "named string", value: namedString("value")},
|
||||||
{name: "cyclic slice", value: cyclicSlice},
|
{name: "int", value: minInt},
|
||||||
{name: "NaN", value: math.NaN()},
|
{name: "int8", value: int8(-1 << 7)},
|
||||||
{name: "positive infinity", value: math.Inf(1)},
|
{name: "int16", value: int16(-1 << 15)},
|
||||||
{name: "unsafe signed integer", value: int64(1 << 53)},
|
{name: "int32", value: int32(-1 << 31)},
|
||||||
{name: "unsafe unsigned integer", value: uint64(1 << 53)},
|
{name: "int64", value: int64(-1 << 63)},
|
||||||
|
{name: "named int64", value: namedInt64(1<<63 - 1)},
|
||||||
|
{name: "uint", value: ^uint(0)},
|
||||||
|
{name: "uint8", value: ^uint8(0)},
|
||||||
|
{name: "uint16", value: ^uint16(0)},
|
||||||
|
{name: "uint32", value: ^uint32(0)},
|
||||||
|
{name: "uint64", value: ^uint64(0)},
|
||||||
|
{name: "uintptr", value: ^uintptr(0)},
|
||||||
|
{name: "named uint64", value: namedUint64(^uint64(0))},
|
||||||
|
{name: "float32", value: float32(1.25)},
|
||||||
|
{name: "float64", value: float64(-2.5e100)},
|
||||||
|
{name: "named float32", value: namedFloat32(3.5)},
|
||||||
|
{name: "named float64", value: namedFloat64(-4.5e200)},
|
||||||
|
{name: "JSON number integer", value: json.Number("18446744073709551615")},
|
||||||
|
{name: "JSON number fraction", value: json.Number("-1.25e+2")},
|
||||||
|
{name: "JSON number beyond float64", value: json.Number("1e9999")},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
if _, err := jsonvalue.CopyMap(map[string]any{"value": tc.value}); err == nil {
|
got, err := Copy(tc.value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("copy value: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, tc.value) {
|
||||||
|
t.Fatalf("value or concrete type changed: got %#v (%T), want %#v (%T)", got, got, tc.value, tc.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyRejectsInvalidNumbers(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value any
|
||||||
|
}{
|
||||||
|
{name: "float32 NaN", value: float32(math.NaN())},
|
||||||
|
{name: "float64 NaN", value: math.NaN()},
|
||||||
|
{name: "named float NaN", value: namedFloat64(math.NaN())},
|
||||||
|
{name: "positive infinity", value: math.Inf(1)},
|
||||||
|
{name: "negative infinity", value: math.Inf(-1)},
|
||||||
|
{name: "empty JSON number", value: json.Number("")},
|
||||||
|
{name: "leading zero JSON number", value: json.Number("01")},
|
||||||
|
{name: "leading plus JSON number", value: json.Number("+1")},
|
||||||
|
{name: "trailing decimal JSON number", value: json.Number("1.")},
|
||||||
|
{name: "leading decimal JSON number", value: json.Number(".1")},
|
||||||
|
{name: "non-number JSON number", value: json.Number("NaN")},
|
||||||
|
{name: "spaced JSON number", value: json.Number(" 1")},
|
||||||
|
{name: "quoted JSON number", value: json.Number(`"1"`)},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if _, err := Copy(tc.value); err == nil {
|
||||||
t.Fatal("expected validation error")
|
t.Fatal("expected validation error")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCopyMapValidatesJSONNumberSyntaxAndRange(t *testing.T) {
|
func TestCopyPreservesCompatibleCollectionsAndNilEmptyDistinctions(t *testing.T) {
|
||||||
for _, number := range []json.Number{"0", "-1", "1.25", "-1.25e+2"} {
|
collections := []struct {
|
||||||
t.Run("valid "+number.String(), func(t *testing.T) {
|
name string
|
||||||
got, err := jsonvalue.CopyMap(map[string]any{"value": number})
|
value any
|
||||||
|
}{
|
||||||
|
{name: "unnamed map", value: map[string]int{"limit": 2}},
|
||||||
|
{name: "named map", value: namedMap{"limit": 2}},
|
||||||
|
{name: "unnamed slice", value: []string{"one", "two"}},
|
||||||
|
{name: "named slice", value: namedSlice{"one", "two"}},
|
||||||
|
{name: "unnamed array", value: [2]int{1, 2}},
|
||||||
|
{name: "named array", value: namedArray{{"limit": 2}}},
|
||||||
|
{name: "empty map", value: map[string]int{}},
|
||||||
|
{name: "empty named map", value: namedMap{}},
|
||||||
|
{name: "empty slice", value: []string{}},
|
||||||
|
{name: "empty named slice", value: namedSlice{}},
|
||||||
|
{name: "empty array", value: [0]string{}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range collections {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := Copy(tc.value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("copy valid JSON number: %v", err)
|
t.Fatalf("copy collection: %v", err)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(got["value"], number) {
|
if !reflect.DeepEqual(got, tc.value) || reflect.TypeOf(got) != reflect.TypeOf(tc.value) {
|
||||||
t.Fatalf("JSON number changed: got %#v want %#v", got["value"], number)
|
t.Fatalf("collection changed: got %#v (%T), want %#v (%T)", got, got, tc.value, tc.value)
|
||||||
|
}
|
||||||
|
kind := reflect.ValueOf(got).Kind()
|
||||||
|
if (kind == reflect.Map || kind == reflect.Slice) && reflect.ValueOf(got).IsNil() {
|
||||||
|
t.Fatal("non-nil collection became nil")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, number := range []json.Number{"", "01", "+1", "1.", ".1", "1e9999", "not-a-number"} {
|
var nilMap map[string]int
|
||||||
t.Run("invalid "+number.String(), func(t *testing.T) {
|
var nilSlice []string
|
||||||
if _, err := jsonvalue.CopyMap(map[string]any{"value": number}); err == nil {
|
var nilPointer *namedInt64
|
||||||
t.Fatal("expected invalid JSON number error")
|
for _, value := range []any{nil, nilMap, nilSlice, nilPointer} {
|
||||||
|
got, err := Copy(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("copy null value: %v", err)
|
||||||
|
}
|
||||||
|
if got != nil {
|
||||||
|
t.Fatalf("null value became %#v (%T)", got, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gotNil, err := CopyMap(nil)
|
||||||
|
if err != nil || gotNil != nil {
|
||||||
|
t.Fatalf("nil CopyMap result = %#v, %v", gotNil, err)
|
||||||
|
}
|
||||||
|
gotEmpty, err := CopyMap(map[string]any{})
|
||||||
|
if err != nil || gotEmpty == nil || len(gotEmpty) != 0 {
|
||||||
|
t.Fatalf("empty CopyMap result = %#v, %v", gotEmpty, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyHandlesIndirectionAndIsolatesNestedMutations(t *testing.T) {
|
||||||
|
integer := namedInt64(7)
|
||||||
|
nestedMap := namedMap{"limit": 2}
|
||||||
|
nestedSlice := namedSlice{"original"}
|
||||||
|
nestedArray := namedArray{{"limit": 3}}
|
||||||
|
shared := []any{map[string]int{"value": 4}}
|
||||||
|
input := map[string]any{
|
||||||
|
"integer": &integer,
|
||||||
|
"map": nestedMap,
|
||||||
|
"slice": nestedSlice,
|
||||||
|
"array": nestedArray,
|
||||||
|
"first": shared,
|
||||||
|
"second": shared,
|
||||||
|
}
|
||||||
|
|
||||||
|
copiedValue, err := Copy(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("copy mixed tree: %v", err)
|
||||||
|
}
|
||||||
|
copied := copiedValue.(map[string]any)
|
||||||
|
nestedMap["limit"] = 20
|
||||||
|
nestedSlice[0] = "changed"
|
||||||
|
nestedArray[0]["limit"] = 30
|
||||||
|
shared[0].(map[string]int)["value"] = 40
|
||||||
|
|
||||||
|
if got, ok := copied["integer"].(namedInt64); !ok || got != 7 {
|
||||||
|
t.Fatalf("pointer target changed: %#v", copied["integer"])
|
||||||
|
}
|
||||||
|
if got := copied["map"].(namedMap)["limit"]; got != 2 {
|
||||||
|
t.Fatalf("nested map aliased input: %d", got)
|
||||||
|
}
|
||||||
|
if got := copied["slice"].(namedSlice)[0]; got != "original" {
|
||||||
|
t.Fatalf("nested slice aliased input: %q", got)
|
||||||
|
}
|
||||||
|
if got := copied["array"].(namedArray)[0]["limit"]; got != 3 {
|
||||||
|
t.Fatalf("nested array aliased input: %d", got)
|
||||||
|
}
|
||||||
|
first := copied["first"].([]any)
|
||||||
|
second := copied["second"].([]any)
|
||||||
|
if got := first[0].(map[string]int)["value"]; got != 4 {
|
||||||
|
t.Fatalf("shared child aliased input: %d", got)
|
||||||
|
}
|
||||||
|
first[0].(map[string]int)["value"] = 99
|
||||||
|
if got := second[0].(map[string]int)["value"]; got != 4 {
|
||||||
|
t.Fatalf("repeated acyclic value shared copied output: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyAndCopyMapApplyDistinctEmptyKeyRules(t *testing.T) {
|
||||||
|
nested := map[string]any{"": []any{"original"}}
|
||||||
|
copiedValue, err := Copy(nested)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Copy rejected empty schema key: %v", err)
|
||||||
|
}
|
||||||
|
nested[""].([]any)[0] = "changed"
|
||||||
|
if got := copiedValue.(map[string]any)[""].([]any)[0]; got != "original" {
|
||||||
|
t.Fatalf("copied schema value was not isolated: %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = CopyMap(map[string]any{"nested": map[string]any{"": true}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "extra_params.nested") {
|
||||||
|
t.Fatalf("CopyMap empty-key error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyRejectsUnsupportedValuesAndActiveCycles(t *testing.T) {
|
||||||
|
cyclicMap := map[string]any{}
|
||||||
|
cyclicMap["self"] = cyclicMap
|
||||||
|
cyclicSlice := []any{nil}
|
||||||
|
cyclicSlice[0] = cyclicSlice
|
||||||
|
var cyclicPointer any
|
||||||
|
cyclicPointer = &cyclicPointer
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value any
|
||||||
|
wantPath string
|
||||||
|
}{
|
||||||
|
{name: "non-string map key", value: map[int]string{1: "one"}, wantPath: "value"},
|
||||||
|
{name: "unsupported channel", value: make(chan int), wantPath: "value"},
|
||||||
|
{name: "deterministic map path", value: map[string]any{"z": make(chan int), "a": make(chan int)}, wantPath: "value.a"},
|
||||||
|
{name: "cyclic map", value: cyclicMap, wantPath: "value.self"},
|
||||||
|
{name: "cyclic slice", value: cyclicSlice, wantPath: "value[0]"},
|
||||||
|
{name: "cyclic pointer", value: cyclicPointer, wantPath: "value"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := Copy(tc.value)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.wantPath) {
|
||||||
|
t.Fatalf("error = %v, want structural path %q", err, tc.wantPath)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCopyEnforcesContainerDepth(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
depth int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "just below", depth: maxContainerDepth - 1},
|
||||||
|
{name: "at limit", depth: maxContainerDepth},
|
||||||
|
{name: "over limit", depth: maxContainerDepth + 1, wantErr: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := Copy(alternatingContainers(tc.depth))
|
||||||
|
if tc.wantErr {
|
||||||
|
if err == nil || !strings.HasPrefix(err.Error(), "value") || !strings.Contains(err.Error(), "container depth limit") {
|
||||||
|
t.Fatalf("depth error = %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("copy depth %d: %v", tc.depth, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyEnforcesProducedNodeBudgetForRepeatedAcyclicValues(t *testing.T) {
|
||||||
|
shared := []any{true}
|
||||||
|
sharedOccurrences := (maxProducedNodes - 2) / 2
|
||||||
|
justBelow := repeatedValues(shared, sharedOccurrences, 0)
|
||||||
|
atLimit := repeatedValues(shared, sharedOccurrences, 1)
|
||||||
|
overLimit := repeatedValues(shared, sharedOccurrences, 2)
|
||||||
|
|
||||||
|
for name, value := range map[string]any{
|
||||||
|
"just below": justBelow,
|
||||||
|
"at limit": atLimit,
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if _, err := Copy(value); err != nil {
|
||||||
|
t.Fatalf("copy value within work budget: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := Copy(overLimit)
|
||||||
|
if err == nil || !strings.HasPrefix(err.Error(), "value[") || !strings.Contains(err.Error(), "value work limit") {
|
||||||
|
t.Fatalf("work-budget error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = Copy(make([]any, maxProducedNodes))
|
||||||
|
if err == nil || !strings.HasPrefix(err.Error(), "value:") || !strings.Contains(err.Error(), "value work limit") {
|
||||||
|
t.Fatalf("flat work-budget error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func alternatingContainers(depth int) any {
|
||||||
|
var value any = true
|
||||||
|
for level := 0; level < depth; level++ {
|
||||||
|
switch level % 3 {
|
||||||
|
case 0:
|
||||||
|
value = map[string]any{"child": value}
|
||||||
|
case 1:
|
||||||
|
value = []any{value}
|
||||||
|
default:
|
||||||
|
value = [1]any{value}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func repeatedValues(shared []any, occurrences, leadingScalars int) []any {
|
||||||
|
values := make([]any, 0, leadingScalars+occurrences)
|
||||||
|
for i := 0; i < leadingScalars; i++ {
|
||||||
|
values = append(values, false)
|
||||||
|
}
|
||||||
|
for i := 0; i < occurrences; i++ {
|
||||||
|
values = append(values, shared)
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,10 +23,25 @@ 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")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxOpenAIChatResponseBytes int64 = 16 << 20
|
||||||
|
|
||||||
|
type requestFailedError struct {
|
||||||
|
cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *requestFailedError) Error() string {
|
||||||
|
return ErrRequestFailed.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *requestFailedError) Unwrap() []error {
|
||||||
|
return []error{ErrRequestFailed, ErrTransport, e.cause}
|
||||||
|
}
|
||||||
|
|
||||||
type OpenAICompatibleConfig struct {
|
type OpenAICompatibleConfig struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
Model string
|
Model string
|
||||||
@@ -39,9 +56,11 @@ type OpenAICompatibleClient struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewOpenAICompatibleClient(cfg OpenAICompatibleConfig) (*OpenAICompatibleClient, error) {
|
func NewOpenAICompatibleClient(cfg OpenAICompatibleConfig) (*OpenAICompatibleClient, error) {
|
||||||
baseURL := strings.TrimSpace(cfg.BaseURL)
|
baseURL := ""
|
||||||
if baseURL != "" {
|
if strings.TrimSpace(cfg.BaseURL) != "" {
|
||||||
if _, err := url.ParseRequestURI(baseURL); err != nil {
|
var err error
|
||||||
|
baseURL, err = domain.NormalizeOpenAICompatibleBaseEndpoint(cfg.BaseURL)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: invalid base URL: %v", ErrInvalidConfig, err)
|
return nil, fmt.Errorf("%w: invalid base URL: %v", ErrInvalidConfig, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,25 +82,63 @@ func NewOpenAICompatibleClient(cfg OpenAICompatibleConfig) (*OpenAICompatibleCli
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &OpenAICompatibleClient{
|
return &OpenAICompatibleClient{
|
||||||
baseURL: strings.TrimRight(baseURL, "/"),
|
baseURL: baseURL,
|
||||||
defaultModel: cfg.Model,
|
defaultModel: cfg.Model,
|
||||||
httpClient: client,
|
httpClient: client,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
if req.Target.TimeoutSeconds < 0 {
|
prepared, err := c.PrepareGeneration(req)
|
||||||
return nil, fmt.Errorf("%w: timeout_seconds must be greater than or equal to 0", ErrInvalidRequest)
|
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 {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint := strings.TrimSpace(req.Target.Endpoint)
|
selectedEndpoint := req.Target.Endpoint
|
||||||
if endpoint == "" {
|
if strings.TrimSpace(selectedEndpoint) == "" {
|
||||||
endpoint = c.baseURL
|
selectedEndpoint = c.baseURL
|
||||||
}
|
}
|
||||||
if endpoint == "" {
|
endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(selectedEndpoint)
|
||||||
return nil, fmt.Errorf("%w: endpoint is required", ErrInvalidRequest)
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: invalid endpoint: %v", ErrInvalidRequest, err)
|
||||||
|
}
|
||||||
|
endpoint, err = url.JoinPath(endpoint, defaults.OpenAIChatCompletionsPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: invalid endpoint path: %v", ErrInvalidRequest, err)
|
||||||
}
|
}
|
||||||
endpoint = strings.TrimRight(endpoint, "/") + defaults.OpenAIChatCompletionsPath
|
|
||||||
|
|
||||||
wireReq, err := openAIChatRequestFromGenerateRequest(req, c.defaultModel)
|
wireReq, err := openAIChatRequestFromGenerateRequest(req, c.defaultModel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -97,71 +154,197 @@ 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, fmt.Errorf("%w: %v", ErrRequestFailed, 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 {
|
||||||
|
outcome.Err = openAIChatResponseTooLargeError()
|
||||||
|
return outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
var wireResp openAIChatResponse
|
wireResp, err := decodeOpenAIChatResponse(httpResp.Body)
|
||||||
if err := json.NewDecoder(httpResp.Body).Decode(&wireResp); err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: failed to decode response: %v", ErrMalformedResponse, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func observedContextError(attempt, caller context.Context, cause error) error {
|
||||||
|
if err := caller.Err(); err != nil {
|
||||||
|
return &requestFailedError{cause: err}
|
||||||
|
}
|
||||||
|
if err := attempt.Err(); err != nil {
|
||||||
|
return &requestFailedError{cause: err}
|
||||||
|
}
|
||||||
|
if errors.Is(cause, context.Canceled) || errors.Is(cause, context.DeadlineExceeded) {
|
||||||
|
return &requestFailedError{cause: cause}
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
limited := &io.LimitedReader{
|
||||||
|
R: body,
|
||||||
|
N: maxOpenAIChatResponseBytes + 1,
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(limited)
|
||||||
|
|
||||||
|
var response openAIChatResponse
|
||||||
|
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 {
|
||||||
|
return openAIChatResponse{}, openAIChatResponseTooLargeError()
|
||||||
|
}
|
||||||
|
return openAIChatResponse{}, fmt.Errorf("%w: failed to decode response", ErrMalformedResponse)
|
||||||
|
}
|
||||||
|
if limited.N == 0 {
|
||||||
|
return openAIChatResponse{}, openAIChatResponseTooLargeError()
|
||||||
}
|
}
|
||||||
|
|
||||||
return &domain.GenerateResponse{
|
var trailing any
|
||||||
Content: content,
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||||
Usage: domain.TokenUsage{
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
PromptTokens: wireResp.Usage.PromptTokens,
|
return openAIChatResponse{}, &requestFailedError{cause: err}
|
||||||
CompletionTokens: wireResp.Usage.CompletionTokens,
|
}
|
||||||
TotalTokens: wireResp.Usage.TotalTokens,
|
if limited.N == 0 {
|
||||||
CachedTokens: wireResp.Usage.PromptTokensDetails.CachedTokens,
|
return openAIChatResponse{}, openAIChatResponseTooLargeError()
|
||||||
CacheWriteTokens: wireResp.Usage.CacheWriteTokens,
|
}
|
||||||
},
|
return openAIChatResponse{}, fmt.Errorf("%w: response contains trailing data", ErrMalformedResponse)
|
||||||
}, nil
|
}
|
||||||
|
if limited.N == 0 {
|
||||||
|
return openAIChatResponse{}, openAIChatResponseTooLargeError()
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openAIChatResponseTooLargeError() error {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: response exceeds %d-byte limit",
|
||||||
|
ErrMalformedResponse,
|
||||||
|
maxOpenAIChatResponseBytes,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func openAIChatRequestFromGenerateRequest(req domain.GenerateRequest, defaultModel string) (openAIChatRequest, error) {
|
func openAIChatRequestFromGenerateRequest(req domain.GenerateRequest, defaultModel string) (openAIChatRequest, error) {
|
||||||
@@ -308,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 {
|
||||||
@@ -317,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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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()
|
||||||
|
}()
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user