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
|
// 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
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ if err != nil {
|
|||||||
|
|
||||||
target := inspection.EffectiveModelParams
|
target := inspection.EffectiveModelParams
|
||||||
if target.APIKeyEnv != "" {
|
if target.APIKeyEnv != "" {
|
||||||
// Apply application policy for the named environment variable.
|
// This is a configured optional environment lookup source.
|
||||||
} else if inspection.APIKeyRequired {
|
} else if inspection.APIKeyRequired {
|
||||||
// Arrange a direct credential before later execution.
|
// 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
|
Use this configuration-time boundary when only the profile and its target need
|
||||||
checking. Use `Prepare` when the application also needs prompt, input, schema,
|
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
|
or rendering work; use prepared execution when that work must remain tied to a
|
||||||
later execution. Inspection reports credential requirements but leaves the
|
later execution. A reported `APIKeyEnv` is a configured optional source, while
|
||||||
timing of credential enforcement to the application. The method's
|
`APIKeyRequired` is the explicit local requirement. The
|
||||||
[GoDoc](../../engine.go) owns its exact result and error contract.
|
[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
|
||||||
|
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ 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
|
||||||
@@ -318,16 +318,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.
|
||||||
|
|||||||
@@ -31,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
|
||||||
|
|||||||
@@ -55,12 +55,14 @@ the target, rendered messages, and structured-output constraint retained by
|
|||||||
executable preparation. Execution does not reopen or rerender consumer
|
executable preparation. Execution does not reopen or rerender consumer
|
||||||
sources.
|
sources.
|
||||||
|
|
||||||
Before backend admission, the runner rechecks that the frozen credential
|
Before backend admission, the runner rechecks a frozen credential
|
||||||
environment-variable name is available. The handle does not retain the
|
environment-variable name only when the target explicitly requires a
|
||||||
environment value; the model client resolves the value visible when generation
|
credential. The handle does not retain the environment value; the model client
|
||||||
begins. A direct request key remains in private execution state only until the
|
resolves the value visible when generation begins. For optional sources with no
|
||||||
claimed execution finishes or an unclaimed handle is discarded. Exact public
|
usable value, the built-in client omits `Authorization` and continues to the
|
||||||
ownership and redaction semantics belong 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).
|
[`PreparedExecution` GoDoc](../../prepared_execution.go).
|
||||||
|
|
||||||
## Failure Categories
|
## 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
|
invalid final selected endpoint is an invalid generation request and is
|
||||||
rejected before transport.
|
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
|
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
|
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,
|
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
|
Stage 3 is complete when the public workflow, documentation, and full
|
||||||
maintainer validation all match the target outcome.
|
maintainer validation all match the target outcome.
|
||||||
|
|
||||||
|
**Status:** Complete.
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
None. Optional environment lookup, explicit requirement behavior, precedence,
|
None. Optional environment lookup, explicit requirement behavior, precedence,
|
||||||
|
|||||||
@@ -53,9 +53,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.
|
||||||
|
|||||||
@@ -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 missingEnv = "PROMPTKIT_PUBLIC_AUTH_MISSING"
|
||||||
|
const providerBody = `{"error":{"message":"authentication failed","type":"authentication_error","code":"invalid_api_key"}}`
|
||||||
t.Setenv(missingEnv, "")
|
t.Setenv(missingEnv, "")
|
||||||
|
|
||||||
engine, err := promptkit.NewEngine(promptkit.Config{
|
called := false
|
||||||
|
config := promptkit.Config{
|
||||||
PromptDir: frameworkPromptDir,
|
PromptDir: frameworkPromptDir,
|
||||||
SchemaDir: frameworkSchemaDir,
|
SchemaDir: frameworkSchemaDir,
|
||||||
}, promptkit.WithProfiles(promptkit.Profile{
|
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
ID: "requires-auth",
|
called = true
|
||||||
Endpoint: "http://localhost:8000/v1",
|
if values := req.Header.Values("Authorization"); len(values) != 0 {
|
||||||
Model: "test-model",
|
t.Fatalf("Authorization values = %q, want absent", values)
|
||||||
APIKeyRequired: true,
|
}
|
||||||
}))
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("expected engine construction to succeed, got %v", err)
|
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,
|
PromptID: frameworkMarkdownSummaryPromptID,
|
||||||
ProfileID: "requires-auth",
|
ProfileID: "optional-auth-profile",
|
||||||
Execution: &promptkit.ExecutionTargetOverride{APIKeyEnv: missingEnv},
|
|
||||||
Inputs: map[string]promptkit.ArtifactRef{
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
"transcript": promptkit.Inline("Rin opens the gate."),
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
||||||
"glossary": promptkit.Inline("gate: A guarded passage."),
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if !errors.Is(err, promptkit.ErrInvalidRequest) {
|
if !called {
|
||||||
t.Fatalf("expected invalid request for missing credentials, got %v", err)
|
t.Fatal("optional missing credential did not reach upstream")
|
||||||
}
|
}
|
||||||
if !errors.Is(err, promptkit.ErrAPIKeyEnvMissing) {
|
if result != nil {
|
||||||
t.Fatalf("expected missing credential environment error, got %v", err)
|
t.Fatalf("result = %+v, want nil", result)
|
||||||
}
|
}
|
||||||
if err == nil || !strings.Contains(err.Error(), missingEnv) {
|
if errors.Is(err, promptkit.ErrInvalidRequest) || errors.Is(err, promptkit.ErrAPIKeyEnvMissing) {
|
||||||
t.Fatalf("expected missing env name in error, got %v", err)
|
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
|
// ReasoningEffort is the effective opaque provider-specific reasoning
|
||||||
// setting. An empty value instructs model clients to omit reasoning.
|
// setting. An empty value instructs model clients to omit reasoning.
|
||||||
ReasoningEffort string `json:"reasoning_effort"`
|
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"`
|
APIKeyEnv string `json:"api_key_env"`
|
||||||
// ExtraParams contains copied JSON-compatible provider parameters.
|
// ExtraParams contains copied JSON-compatible provider parameters.
|
||||||
ExtraParams map[string]any `json:"extra_params"`
|
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
|
// inherited value and disables reasoning for this run. Non-blank values
|
||||||
// are opaque and are not validated against a fixed vocabulary.
|
// are opaque and are not validated against a fixed vocabulary.
|
||||||
ReasoningEffort *string
|
ReasoningEffort *string
|
||||||
// APIKeyEnv replaces the profile or backend environment-variable name when
|
// APIKeyEnv replaces the profile or backend optional environment lookup
|
||||||
// non-blank. A direct RunRequest.APIKey still takes precedence over
|
// source when non-blank. A direct RunRequest.APIKey still takes precedence.
|
||||||
// environment lookup.
|
// The built-in client omits Authorization when neither source has a usable
|
||||||
|
// value; injected clients may resolve this metadata differently.
|
||||||
APIKeyEnv string
|
APIKeyEnv string
|
||||||
// ExtraParams, when non-empty, replaces the complete profile or backend map.
|
// ExtraParams, when non-empty, replaces the complete profile or backend map.
|
||||||
// Values must be JSON-compatible: nil, booleans, finite numbers, strings,
|
// Values must be JSON-compatible: nil, booleans, finite numbers, strings,
|
||||||
@@ -481,7 +485,8 @@ type Profile struct {
|
|||||||
ReasoningEffort string
|
ReasoningEffort string
|
||||||
// APIKeyRequired clears a backend's inherited API-key environment name and
|
// APIKeyRequired clears a backend's inherited API-key environment name and
|
||||||
// requires a non-blank RunRequest.APIKey unless the request explicitly
|
// 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
|
APIKeyRequired bool
|
||||||
// ExtraParams contains provider-specific JSON-compatible values. An empty
|
// ExtraParams contains provider-specific JSON-compatible values. An empty
|
||||||
// map inherits backend request defaults, when any. WithProfiles validates
|
// map inherits backend request defaults, when any. WithProfiles validates
|
||||||
|
|||||||
Reference in New Issue
Block a user