diff --git a/backends.go b/backends.go index c3efd4c..9b1d1e2 100644 --- a/backends.go +++ b/backends.go @@ -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 diff --git a/docs/consumers/pkg-promptkit.md b/docs/consumers/pkg-promptkit.md index 6617841..2cf8bd5 100644 --- a/docs/consumers/pkg-promptkit.md +++ b/docs/consumers/pkg-promptkit.md @@ -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 diff --git a/docs/formats.md b/docs/formats.md index a2dc9d1..fa5d1a4 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -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. diff --git a/docs/integrations/openai-compatible-chat.md b/docs/integrations/openai-compatible-chat.md index a9f4fa7..77e84cf 100644 --- a/docs/integrations/openai-compatible-chat.md +++ b/docs/integrations/openai-compatible-chat.md @@ -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 `. 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 diff --git a/docs/internal/llm.md b/docs/internal/llm.md index b1c817a..8277c88 100644 --- a/docs/internal/llm.md +++ b/docs/internal/llm.md @@ -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, diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index dc3ce41..bb8ec42 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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, diff --git a/engine.go b/engine.go index 0ff88d5..9b717b6 100644 --- a/engine.go +++ b/engine.go @@ -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. diff --git a/engine_test.go b/engine_test.go index 9a091c1..24b5167 100644 --- a/engine_test.go +++ b/engine_test.go @@ -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) } } diff --git a/types.go b/types.go index 68545c7..92524dd 100644 --- a/types.go +++ b/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