Document optional API key environment behavior

This commit is contained in:
2026-08-25 00:44:26 +00:00
parent a11c80291e
commit 2d44305a8a
9 changed files with 111 additions and 48 deletions

View File

@@ -30,9 +30,12 @@ type Backend struct {
// requires an absolute HTTP or HTTPS URL with a host and without user
// information, a query string, or a fragment. Paths are allowed.
Endpoint string
// APIKeyEnv optionally names the environment variable containing the API
// key. NewEngine trims it and requires the portable form
// [A-Za-z_][A-Za-z0-9_]*. Store only the name, never a credential value.
// APIKeyEnv optionally names an environment lookup source for an API key.
// NewEngine trims it and requires the portable form [A-Za-z_][A-Za-z0-9_]*.
// 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
// ExtraParams contains backend-wide request defaults. Values must be
// JSON-compatible, finite, acyclic, and keyed by non-empty strings. Keys

View File

@@ -231,7 +231,7 @@ if err != nil {
target := inspection.EffectiveModelParams
if target.APIKeyEnv != "" {
// Apply application policy for the named environment variable.
// This is a configured optional environment lookup source.
} else if inspection.APIKeyRequired {
// Arrange a direct credential before later execution.
}
@@ -240,9 +240,11 @@ if target.APIKeyEnv != "" {
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. Inspection reports credential requirements but leaves the
timing of credential enforcement to the application. The method's
[GoDoc](../../engine.go) owns its exact result and error contract.
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

View File

@@ -185,7 +185,7 @@ extra_params:
| `timeout_seconds` | no | Per-generation deadline in whole seconds; integer zero or greater. |
| `service_tier` | no | Provider-specific request tier. |
| `reasoning_effort` | no | Provider-specific reasoning setting. |
| `api_key_env` | no | Name of an environment variable containing the API key. |
| `api_key_env` | no | Optional environment-variable lookup source for an API key. |
| `extra_params` | no | JSON-compatible provider-specific outbound fields. |
Raw `api_key` is prohibited in profile YAML. Store only an environment
@@ -318,16 +318,25 @@ schema produces a failed validation result.
Credential values belong at the request or environment boundary, never in
prompt, profile, schema, or example files:
- a file profile names an environment variable with `api_key_env`;
- an in-memory profile may set `APIKeyRequired`;
- a request can provide a direct `APIKey` or override `APIKeyEnv`; and
- a backend or file profile can name an optional environment lookup source
with `APIKeyEnv` or `api_key_env`;
- 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.
After a direct request key, the credential-source precedence is request
`APIKeyEnv`, profile `api_key_env`, then the backend default. An in-memory
profile with `APIKeyRequired` clears an inherited backend environment name and
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
formatters. Environment-variable names may appear in prepared metadata, but
their values do not.

View File

@@ -31,11 +31,13 @@ does not serialize it in the provider request.
## Authentication
A non-empty API key supplied directly on the execution target takes
precedence. Otherwise, when an API-key environment-variable name is supplied,
the client reads that variable and requires a non-empty value. The selected
key is sent as `Authorization: Bearer <key>`. No authorization header is sent
when neither mechanism is configured.
A usable API key supplied directly on the execution target takes precedence.
Otherwise, when an API-key environment-variable name is supplied, the client
reads and trims that variable. A bearer header is sent only when the resolved
direct or environment credential is non-empty. When neither source is usable,
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
explicit request override takes precedence over profile metadata, which takes

View File

@@ -55,12 +55,14 @@ 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 that the frozen credential
environment-variable name is available. The handle does not retain the
environment value; the model client resolves the value visible when generation
begins. 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
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
@@ -74,6 +76,12 @@ Invalid nonempty configured endpoints are configuration failures. A missing or
invalid final selected endpoint is an invalid generation request and is
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,

View File

@@ -323,6 +323,8 @@ validation, repository-hygiene checks, ignored-file check, credential scan, and
Stage 3 is complete when the public workflow, documentation, and full
maintainer validation all match the target outcome.
**Status:** Complete.
## Open Questions
None. Optional environment lookup, explicit requirement behavior, precedence,

View File

@@ -53,9 +53,9 @@ var (
// an execution profile or resolve its backend, except for the profile
// not-found case represented by ErrProfileNotFound.
ErrProfileLoad = errors.New("failed to load execution profile")
// ErrAPIKeyEnvMissing identifies an APIKeyEnv whose environment variable is
// unset or empty when no direct RunRequest.APIKey takes precedence. Such an
// error also matches ErrInvalidRequest.
// ErrAPIKeyEnvMissing identifies an explicitly required APIKeyEnv whose
// environment variable is unset or empty after direct RunRequest.APIKey
// precedence is applied. Such an error also matches ErrInvalidRequest.
ErrAPIKeyEnvMissing = errors.New("api_key_env points to an unset environment variable")
// ErrArtifactLoad identifies a failure to resolve an input artifact. Errors
// returned by an injected ArtifactReader remain available through errors.Is.

View File

@@ -977,40 +977,72 @@ func TestPrepareDirectAPIKeyBypassesMissingEnvWithoutLeakingOrHashing(t *testing
}
}
func TestMissingCredentialsFailClearlyWhenProfileRequiresAuth(t *testing.T) {
func TestOptionalMissingCredentialsReachUpstream(t *testing.T) {
const missingEnv = "PROMPTKIT_PUBLIC_AUTH_MISSING"
const providerBody = `{"error":{"message":"authentication failed","type":"authentication_error","code":"invalid_api_key"}}`
t.Setenv(missingEnv, "")
engine, err := promptkit.NewEngine(promptkit.Config{
called := false
config := promptkit.Config{
PromptDir: frameworkPromptDir,
SchemaDir: frameworkSchemaDir,
}, promptkit.WithProfiles(promptkit.Profile{
ID: "requires-auth",
Endpoint: "http://localhost:8000/v1",
Model: "test-model",
APIKeyRequired: true,
}))
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
called = true
if values := req.Header.Values("Authorization"); len(values) != 0 {
t.Fatalf("Authorization values = %q, want absent", values)
}
return &http.Response{
StatusCode: http.StatusUnauthorized,
ContentLength: int64(len(providerBody)),
Body: io.NopCloser(strings.NewReader(providerBody)),
}, nil
})},
}
engine, err := promptkit.NewEngine(config,
promptkit.WithBackend(promptkit.Backend{
ID: "optional-auth",
Endpoint: "http://provider.test/v1",
APIKeyEnv: missingEnv,
}),
promptkit.WithProfiles(promptkit.Profile{
ID: "optional-auth-profile",
BackendID: "optional-auth",
Model: "test-model",
}),
)
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
_, err = engine.Prepare(context.Background(), promptkit.RunRequest{
result, err := engine.Run(context.Background(), promptkit.RunRequest{
PromptID: frameworkMarkdownSummaryPromptID,
ProfileID: "requires-auth",
Execution: &promptkit.ExecutionTargetOverride{APIKeyEnv: missingEnv},
ProfileID: "optional-auth-profile",
Inputs: map[string]promptkit.ArtifactRef{
"transcript": promptkit.Inline("Rin opens the gate."),
"glossary": promptkit.Inline("gate: A guarded passage."),
},
})
if !errors.Is(err, promptkit.ErrInvalidRequest) {
t.Fatalf("expected invalid request for missing credentials, got %v", err)
if !called {
t.Fatal("optional missing credential did not reach upstream")
}
if !errors.Is(err, promptkit.ErrAPIKeyEnvMissing) {
t.Fatalf("expected missing credential environment error, got %v", err)
if result != nil {
t.Fatalf("result = %+v, want nil", result)
}
if err == nil || !strings.Contains(err.Error(), missingEnv) {
t.Fatalf("expected missing env name in error, got %v", err)
if errors.Is(err, promptkit.ErrInvalidRequest) || errors.Is(err, promptkit.ErrAPIKeyEnvMissing) {
t.Fatalf("error = %v, want upstream generation error without credential identities", err)
}
if !errors.Is(err, promptkit.ErrLLMGenerate) {
t.Fatalf("error = %v, want ErrLLMGenerate", err)
}
var generationErr *promptkit.GenerationError
if !errors.As(err, &generationErr) {
t.Fatalf("error = %v, want GenerationError", err)
}
if generationErr.StatusCode() != http.StatusUnauthorized ||
generationErr.ProviderType() != "authentication_error" ||
generationErr.ProviderCode() != "invalid_api_key" ||
generationErr.ProviderMessage() != "authentication failed" {
t.Fatalf("GenerationError = %+v, want structured upstream authentication failure", generationErr)
}
}

View File

@@ -322,7 +322,10 @@ type ExecutionTarget struct {
// ReasoningEffort is the effective opaque provider-specific reasoning
// setting. An empty value instructs model clients to omit reasoning.
ReasoningEffort string `json:"reasoning_effort"`
// APIKeyEnv is an environment-variable name, not its credential value.
// APIKeyEnv is the resolved name of an optional environment lookup source,
// not its credential value. The built-in client omits Authorization when no
// usable direct or environment credential is available; injected clients may
// resolve this metadata differently.
APIKeyEnv string `json:"api_key_env"`
// ExtraParams contains copied JSON-compatible provider parameters.
ExtraParams map[string]any `json:"extra_params"`
@@ -426,9 +429,10 @@ type ExecutionTargetOverride struct {
// inherited value and disables reasoning for this run. Non-blank values
// are opaque and are not validated against a fixed vocabulary.
ReasoningEffort *string
// APIKeyEnv replaces the profile or backend environment-variable name when
// non-blank. A direct RunRequest.APIKey still takes precedence over
// environment lookup.
// APIKeyEnv replaces the profile or backend optional environment lookup
// source when non-blank. A direct RunRequest.APIKey still takes precedence.
// The built-in client omits Authorization when neither source has a usable
// value; injected clients may resolve this metadata differently.
APIKeyEnv string
// ExtraParams, when non-empty, replaces the complete profile or backend map.
// Values must be JSON-compatible: nil, booleans, finite numbers, strings,
@@ -481,7 +485,8 @@ type Profile struct {
ReasoningEffort string
// APIKeyRequired clears a backend's inherited API-key environment name and
// requires a non-blank RunRequest.APIKey unless the request explicitly
// supplies ExecutionTargetOverride.APIKeyEnv. It does not store a credential.
// supplies ExecutionTargetOverride.APIKeyEnv. When false, a named
// environment source remains optional. It does not store a credential.
APIKeyRequired bool
// ExtraParams contains provider-specific JSON-compatible values. An empty
// map inherits backend request defaults, when any. WithProfiles validates