Compare commits

4 Commits

9 changed files with 101 additions and 59 deletions

View File

@@ -20,12 +20,17 @@ fields:
| `PromptDir` | Prompt-definition directory, required unless a prompt source option is supplied. |
| `ProfileDir` | Optional custom profile directory over built-ins. |
| `SchemaDir` | Schema directory; empty uses `.`. |
| `Timeout` | Default timeout for the built-in OpenAI-compatible client. |
| `HTTPClient` | Optional HTTP client for that built-in client. |
| `Timeout` | Base timeout for the built-in OpenAI-compatible client when `HTTPClient` is absent or has a zero timeout. A non-positive value uses the internal default. |
| `HTTPClient` | Optional HTTP client for that built-in client. It is cloned; a non-zero `Timeout` on it takes precedence over `Config.Timeout` as the base timeout. |
Nil options are ignored. Invalid construction, including
`WithLLMClient(nil)`, returns an error matching `ErrInvalidConfig`.
An effective positive `timeout_seconds` replaces the base timeout. An explicit
request override of zero disables the HTTP-client timeout. The timeout is
otherwise inherited from the supplied client, `Config.Timeout`, or the internal
default in that order.
Source options replace their matching directory source:
- prompts: `WithPromptFS(fsys, root)`, `WithPromptFile(path)`;
@@ -35,10 +40,13 @@ Source options replace their matching directory source:
- LLM client: `WithLLMClient(client)`.
`fs.FS` prompt-content and schema paths stay inside their configured roots.
A single-file option exposes that file by its base name. In-memory profiles take
precedence over an explicit or directory-backed profile source, which in turn
takes precedence over built-ins. File and filesystem sources use the format and
credential rules in the [configuration reference](../config.md).
Single-file prompt and profile sources are selected by their YAML `id`, not
their file names. `WithPromptFile` resolves relative `content_file` paths from
the prompt file's directory. `WithSchemaFile` exposes its schema by the schema
file's base name. In-memory profiles take precedence over an explicit or
directory-backed profile source, which in turn takes precedence over built-ins.
File and filesystem sources use the format and credential rules in the
[configuration reference](../config.md).
## Prepare And Run

View File

@@ -77,9 +77,12 @@ resolves the configured non-empty `api_key_env` at request time and sends the
same header. If neither mechanism supplies a key, it sends no
`Authorization` header.
The configured client timeout applies by default. A positive effective
`timeout_seconds` replaces it. An explicit request override of zero disables
the HTTP-client timeout; negative values are rejected before a request is sent.
The client base timeout is chosen at construction: a non-zero timeout on a
supplied `http.Client` takes precedence over `Config.Timeout`; otherwise a
positive `Config.Timeout` is used, then the internal default. A positive
effective `timeout_seconds` replaces that base. An explicit request override
of zero disables the HTTP-client timeout; negative values are rejected before a
request is sent.
## Response Subset And Failures

View File

@@ -10,13 +10,14 @@ format and protocol behavior.
## Construction
`NewOpenAICompatibleClient` validates a non-empty configured base URL, records
an optional default model, and establishes the default timeout. A non-positive
configured timeout uses the internal default.
an optional default model, and resolves one base timeout. A supplied client with
a non-zero timeout supplies that base; otherwise a positive configured timeout
is used, then the internal default.
When callers supply an `http.Client`, construction clones it rather than
mutating the caller's instance. A supplied client with no timeout receives the
resolved default in the clone; a supplied non-zero timeout is retained. The
client stores the trimmed base URL, default model, timeout, and cloned client.
resolved base timeout in the clone. The client stores the trimmed base URL,
default model, and cloned client.
## Generate Flow
@@ -28,7 +29,7 @@ client stores the trimmed base URL, default model, timeout, and cloned client.
request;
4. prefer a direct API key, otherwise resolve the configured key environment
variable;
5. derive a request HTTP client when an explicit timeout changes the configured
5. derive a request HTTP client only when an explicit timeout changes the base
client;
6. execute the request, reject non-success status responses without returning
provider response bodies; and

View File

@@ -18,7 +18,8 @@ Domain behavior is centralized in `internal/usecase` and `internal/domain`.
- Keep orchestration narrow: Scriptorium executes one prompt request; it is not a multi-step workflow engine.
- Keep adapter logic thin: adapters map external shapes to domain requests/results and should not hold domain decisions.
- Keep boundaries explicit: repositories/loaders/renderers/validators/LLM client stay behind package interfaces.
- Keep config strict: YAML/JSON decoding for external inputs should reject unknown fields.
- Keep external decoding strict: configuration, prompt, and profile YAML and
HTTP JSON should reject unknown fields.
- Keep secrets out of payloads: raw API key values must not be accepted or emitted.
## Dependency Direction
@@ -63,7 +64,6 @@ defined by the [documentation policy](documentation.md).
## Architectural Invariants
- `Runner.Run` reuses `Runner.Prepare` flow.
- Unknown input fields in config/prompt/profile/http JSON should be rejected by strict decoding.
- Raw API key values must not be accepted through external configuration or
request payloads, and resolved secret values must not be emitted.

View File

@@ -2,11 +2,9 @@
## Status
The initial documentation refresh was completed on 2026-07-26. A
post-completion review identified a small set of contract, test-maintainability,
and roadmap-status issues. The ordered follow-up stages below are pending and
should be completed before beginning Step 2 of the
[Promptkit migration roadmap](migration.md).
The initial documentation refresh and its follow-up compliance work were
completed on 2026-07-26. The documentation prerequisite for Step 2 of the
[Promptkit migration roadmap](migration.md) is satisfied.
## Objective
@@ -133,3 +131,11 @@ This follow-up is complete when:
- roadmap status accurately distinguishes completed and pending migration work;
and
- links, examples, tests, vet, and build validation pass.
## Completion Record
Completed on 2026-07-26. The timeout contract, single-file source contract,
strict-decoding boundary, and maintained HTTP example check were reconciled.
Final validation confirmed local links and paths, both maintained configuration
files through the real loader, the maintained render and Go package examples,
`go test ./...`, `go vet ./...`, and `go build ./cmd/scriptorium`.

View File

@@ -2,8 +2,8 @@
## Status
Accepted plan. This document describes proposed work that is not yet
implemented.
Accepted plan. Step 1, the documentation refresh, is complete. Steps 2 through
9 remain proposed and are not yet implemented.
## Objective
@@ -94,8 +94,8 @@ refresh and policy updates are merged and the repository has an agreed,
accurate baseline.
**Gate status:** Complete as of 2026-07-26. The completed documentation
refresh and its verification record are in the
[documentation compliance roadmap](documentation.md).
refresh and follow-up verification record are in the [documentation compliance
roadmap](documentation.md).
### Step 2: Record The Architectural Decision And Detailed Boundary

View File

@@ -52,22 +52,20 @@ func TestMaintainedHTTPRunExampleMatchesRequestContract(t *testing.T) {
if w.Code != http.StatusOK {
t.Fatalf("expected maintained HTTP request example to be accepted, got %d: %s", w.Code, w.Body.String())
}
if runner.last.PromptID != "generic.markdown_summary" {
t.Fatalf("unexpected prompt ID: %q", runner.last.PromptID)
var invalidExample map[string]json.RawMessage
if err := json.Unmarshal(body, &invalidExample); err != nil {
t.Fatalf("decode maintained HTTP request example: %v", err)
}
if runner.last.ProfileID != "local-fast" {
t.Fatalf("unexpected profile ID: %q", runner.last.ProfileID)
}
wantInputs := map[string]domain.ArtifactRef{
"transcript": {Type: domain.ArtifactRefFile, URI: "./examples/fixtures/transcript.md"},
"glossary": {Type: domain.ArtifactRefFile, URI: "./examples/fixtures/glossary.yml"},
}
if !reflect.DeepEqual(runner.last.Inputs, wantInputs) {
t.Fatalf("unexpected inputs: got %#v, want %#v", runner.last.Inputs, wantInputs)
}
if runner.last.Vars["session_date"] != "2026-05-04" {
t.Fatalf("unexpected session_date: %#v", runner.last.Vars)
invalidExample["unexpected"] = json.RawMessage(`true`)
invalidBody, err := json.Marshal(invalidExample)
if err != nil {
t.Fatalf("encode structurally invalid request example: %v", err)
}
invalidReq := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewReader(invalidBody))
invalidW := httptest.NewRecorder()
h.ServeHTTP(invalidW, invalidReq)
assertHTTPErrorCode(t, invalidW, http.StatusBadRequest, "invalid_json")
}
type handlerPromptRepo struct {

View File

@@ -36,7 +36,6 @@ type OpenAICompatibleConfig struct {
type OpenAICompatibleClient struct {
baseURL string
defaultModel string
timeout time.Duration
httpClient *http.Client
}
@@ -67,7 +66,6 @@ func NewOpenAICompatibleClient(cfg OpenAICompatibleConfig) (*OpenAICompatibleCli
return &OpenAICompatibleClient{
baseURL: strings.TrimRight(baseURL, "/"),
defaultModel: cfg.Model,
timeout: timeout,
httpClient: client,
}, nil
}
@@ -116,21 +114,22 @@ func (c *OpenAICompatibleClient) Generate(ctx context.Context, req domain.Genera
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
}
effectiveTimeout := c.timeout
if req.Target.TimeoutSeconds > 0 {
effectiveTimeout = time.Duration(req.Target.TimeoutSeconds) * time.Second
} else if req.TargetPresence.TimeoutSeconds {
effectiveTimeout = 0
}
httpClient := c.httpClient
if httpClient == nil {
httpClient = &http.Client{Timeout: effectiveTimeout}
} else if httpClient.Timeout != effectiveTimeout {
httpClient = &http.Client{Timeout: defaults.LLMRequestTimeoutDefault}
}
if req.Target.TimeoutSeconds > 0 {
effectiveTimeout := time.Duration(req.Target.TimeoutSeconds) * time.Second
if httpClient.Timeout != effectiveTimeout {
cloned := *httpClient
cloned.Timeout = effectiveTimeout
httpClient = &cloned
}
} else if req.TargetPresence.TimeoutSeconds && httpClient.Timeout != 0 {
cloned := *httpClient
cloned.Timeout = 0
httpClient = &cloned
}
httpResp, err := httpClient.Do(httpReq)
if err != nil {

View File

@@ -31,9 +31,6 @@ func TestNewOpenAICompatibleClientDoesNotMutateSuppliedZeroTimeoutClient(t *test
if client.httpClient == supplied {
t.Fatal("expected constructed client to use a cloned HTTP client")
}
if client.httpClient.Timeout != client.timeout {
t.Fatalf("expected cloned client timeout %v, got %v", client.timeout, client.httpClient.Timeout)
}
if client.httpClient.Timeout <= 0 {
t.Fatalf("expected constructed client to use a positive default timeout, got %v", client.httpClient.Timeout)
}
@@ -676,6 +673,7 @@ func TestOpenAICompatibleClientExplicitZeroTimeoutDisablesClientTimeout(t *testi
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: ts.URL + "/v1",
Timeout: time.Nanosecond,
HTTPClient: &http.Client{Timeout: time.Nanosecond},
})
if err != nil {
t.Fatal(err)
@@ -718,6 +716,34 @@ func TestOpenAICompatibleClientOmittedTimeoutUsesClientTimeout(t *testing.T) {
}
}
func TestOpenAICompatibleClientSuppliedHTTPClientTimeoutOverridesConfigTimeout(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(25 * time.Millisecond)
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
}))
defer ts.Close()
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: ts.URL + "/v1",
Model: "model",
Timeout: 5 * time.Millisecond,
HTTPClient: &http.Client{Timeout: 100 * time.Millisecond},
})
if err != nil {
t.Fatal(err)
}
resp, err := client.Generate(context.Background(), domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
})
if err != nil {
t.Fatalf("expected supplied client timeout to allow the request, got %v", err)
}
if resp.Content != "ok" {
t.Fatalf("unexpected response content: %q", resp.Content)
}
}
func TestOpenAICompatibleClientRejectsInvalidExtraParamsBeforeProviderCall(t *testing.T) {
tests := []struct {
name string
@@ -1013,7 +1039,8 @@ func TestOpenAICompatibleClientRequestTimeoutOverride(t *testing.T) {
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: ts.URL + "/v1",
Model: "m",
Timeout: 50 * time.Millisecond,
Timeout: 5 * time.Millisecond,
HTTPClient: &http.Client{Timeout: 50 * time.Millisecond},
})
if err != nil {
t.Fatal(err)