Document optional API key environment behavior
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
15
types.go
15
types.go
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user