Skip to content

feat: add AuthenticationContext ambient primitive#259

Merged
Kamilbenkirane merged 6 commits intomainfrom
feat/authentication-context
Apr 15, 2026
Merged

feat: add AuthenticationContext ambient primitive#259
Kamilbenkirane merged 6 commits intomainfrom
feat/authentication-context

Conversation

@Kamilbenkirane
Copy link
Copy Markdown
Member

@Kamilbenkirane Kamilbenkirane commented Apr 14, 2026

Summary

Adds a ContextVar-backed ambient authentication primitive so multi-tenant servers can bind per-request credentials without threading auth= through every call site.

  • New module-level state in celeste.auth exposing AuthenticationContext (frozen pydantic, keyed by Provider), an authentication_scope() context manager, and a resolve_authentication() helper.
  • create_client() consults the ambient context as a third resolution step between explicit kwargs and env-based credentials, using the resolved model's provider as the lookup key. Explicit auth= / api_key= kwargs still win.
  • New MissingAuthenticationError (subclass of CredentialsError) raised when a scope is bound but the requested provider has no entry — distinct from MissingCredentialsError so a multi-tenant caller can distinguish "scoped auth has no entry for this provider" from "env credentials are missing." Mirrors MissingCredentialsError's positional provider signature.
  • Top-level re-exports: from celeste import AuthenticationContext, authentication_scope, MissingAuthenticationError.

Usage

from pydantic import SecretStr
from celeste import AuthenticationContext, authentication_scope
from celeste.core import Provider
from celeste.auth import AuthHeader

ctx = AuthenticationContext(entries={
    Provider.OPENAI: AuthHeader(secret=SecretStr(user_openai_key)),
    Provider.ANTHROPIC: AuthHeader(secret=SecretStr(user_anthropic_key)),
})

with authentication_scope(ctx):
    # Calls into openai/* models use the OpenAI auth.
    # Calls into anthropic/* models use the Anthropic auth.
    # The provider is resolved from the model id at call time.
    ...

Backward compatibility

The module-level celeste.text.generate(...) API is unchanged. Namespaces stay stateless singletons. Existing callers that pass auth= or api_key= explicitly observe identical behavior — ambient is additive and only kicks in when both kwargs are None and the resolved model's provider is known.

Why per-Provider keying

The ambient context mirrors celeste's existing internal auth model: Credentials.get_auth(provider, ...) and _auth_registry: dict[Provider, ...] in credentials.py are both keyed by Provider. Authentication instances are provider-bound by construction (AuthHeader produces provider-specific headers), so Provider is the natural identity of an auth object. Per-Provider also maps cleanly to user mental models ("my OpenAI key, my Anthropic key") and eliminates the redundancy of storing the same auth under multiple (Modality, Operation) slots when one provider serves several modalities.

For the rare edge case where a user has two different auth strategies for the same provider, the existing explicit auth= kwarg on every celeste call provides the per-call escape hatch.

Pattern precedent

Ambient per-request state backed by a ContextVar is the dominant Python idiom for libraries with module-level APIs that need request-scoped behavior — Flask g / request, Werkzeug Local, structlog bind_contextvars, OpenTelemetry's ContextVarsRuntimeContext, and DSPy's dspy.context(lm=...). This PR adopts the same pattern for celeste authentication.

Implementation notes

  • AuthenticationContext is frozen because asyncio.gather siblings share a context snapshot by reference — mutability would cause silent cross-task credential bleed under concurrent requests.
  • When a scope is bound but has no entry for the resolved provider, resolve_authentication raises MissingAuthenticationError — it does not fall through to env credentials, so a multi-tenant server can't accidentally charge a user's request to the process-global env key.
  • Executor audit confirmed celeste has zero run_in_executor / ThreadPoolExecutor / concurrent.futures call sites, so ContextVar propagates naturally across the SDK without any copy_context() wrappers.
  • The lookup in create_client() runs after resolved_provider is populated from _resolve_model().provider, so the provider is always known when the ambient is consulted (BYOA paths with resolved_provider is None skip the ambient entirely and use explicit kwargs).

Test plan

  • 19 unit tests in tests/unit_tests/test_auth.py covering: existing AuthHeader / APIKey / registry tests (unchanged), frozen invariant, scope enter/exit, nested scopes, explicit-None-clears, scope-bound-but-provider-missing raises, asyncio.create_task propagation, asyncio.gather siblings, asyncio.to_thread propagation, raw ThreadPoolExecutor does-not-propagate (negative test), ThreadPoolExecutor + copy_context() does propagate.
  • Full unit test suite: 598 passing, 0 regressions.
  • uv run mypy src/celeste clean (331 source files).
  • uv run ruff check clean, pre-commit hooks (ruff, format, mypy src+tests, bandit) all pass.

Add celeste.authentication_context module exposing a frozen pydantic
AuthenticationContext keyed by (modality, operation), an
authentication_scope() context manager, and resolve_authentication()
consumed by create_client().

Wire ambient resolution into create_client(): when no explicit auth= or
api_key= was passed and the operation is known, fall back to the bound
AuthenticationContext before the existing BYOA / env-credential branches.
Explicit kwargs still win.

Add MissingAuthenticationError (subclass of CredentialsError): raised by
resolve_authentication() when a scope is bound but has no entry for the
requested (modality, operation). Distinct from MissingCredentialsError
so multi-tenant callers can distinguish scoped-but-uncovered from
env-missing.

Re-export AuthenticationContext, authentication_scope, and
MissingAuthenticationError from the top-level celeste package.

Backward compatible: module-level celeste.text.generate(...) API is
unchanged; namespaces stay stateless singletons; existing explicit auth=
callers see identical behavior.
celeste-python targets Python 3.12+, where PEP 604 union syntax (`X | Y`)
and PEP 585 generic collections (`list[int]`, `tuple[...]`) evaluate
natively. The future import is not used anywhere else in celeste-python
(0/332 files) and introduces friction with pydantic v2 forward-reference
resolution. Remove from the new authentication_context module and its
test file.
@claude
Copy link
Copy Markdown

claude bot commented Apr 14, 2026

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

Eliminate the one-line wrapper method in favor of direct dict access on
the frozen entries mapping. resolve_authentication now calls
context.entries.get((modality, operation)) directly, which removes the
public method and sidesteps the naming-convention question about get_for
vs the celeste verb_noun house style.

Rewrite two tests that were reaching into _current_context.get() to
verify scope state through resolve_authentication() instead — same
invariants, no coupling to module internals.

Drop the two trivial get_for tests that were only verifying dict.get
semantics; the resolution tests already cover the same ground end-to-end.

Trim the resolve_authentication docstring to a one-line summary plus a
Raises block, matching the celeste docstring style.
Consolidate the ambient-authentication primitive into auth.py where it
lives naturally alongside Authentication, AuthHeader, NoAuth, and the
existing auth registry. Delete the standalone authentication_context.py
(was 70 lines) — there is no meaningful "one concern per module" split
between the authentication types and the ambient scope machinery that
references them.

Merge the corresponding test_authentication_context.py tests into
test_auth.py so test layout mirrors source layout. The existing
clean_auth_registry autouse fixture is harmless for the new tests.

celeste.__init__ and __all__ now import AuthenticationContext,
authentication_scope, and resolve_authentication directly from
celeste.auth. Public API surface is unchanged — top-level imports still
work via `from celeste import authentication_scope, AuthenticationContext`.
Add Args/Returns blocks to resolve_authentication and authentication_scope
matching the celeste convention (full Args/Returns/Raises blocks for
functions with parameters, see Credentials.get_auth and get_auth_class).

Update create_client docstring to document the new ambient resolution
fallback path on the auth parameter, and add MissingAuthenticationError
to the Raises block.
Switch the ambient context from per-(Modality, Operation) keying to
per-Provider keying. Provider keying matches celeste's existing internal
auth model (Credentials.get_auth(provider, ...) and the _auth_registry
in credentials.py are both per-Provider), maps directly to user mental
models ("my OpenAI key, my Anthropic key"), and eliminates redundancy
when one provider serves multiple modalities.

create_client() now consults the ambient context with resolved_provider
(known immediately after _resolve_model). The lookup happens in the same
location as before, just with a different key.

MissingAuthenticationError now takes a single positional Provider, mirroring
MissingCredentialsError's positional-provider style.

AuthenticationContext.entries is now Mapping[Provider, Authentication]
(no None values — missing keys signal "no auth," consistent with
celeste's internal registry behavior).

Tests rewritten with provider-named fixtures (openai_auth, anthropic_auth,
elevenlabs_auth) keyed by Provider.OPENAI / .ANTHROPIC / .ELEVENLABS. The
redundant explicit-None test is dropped since the value type no longer
admits None — the missing-key case covers it.

Same-provider edge cases (e.g., one user with both a Gemini API key and
a Vertex OAuth token) are handled by the existing explicit auth= kwarg
escape hatch on each celeste call.
@Kamilbenkirane Kamilbenkirane merged commit ff60c19 into main Apr 15, 2026
11 checks passed
Kamilbenkirane added a commit that referenced this pull request Apr 15, 2026
This reverts commit ff60c19.

Reverting per YAGNI + design error found in downstream analysis.

The shipped AuthenticationContext was keyed per-Provider, assuming a
single Authentication is bound to a provider. Downstream analysis in a
multi-modal, multi-provider context revealed this collapses cases where
a single provider legitimately exposes DIFFERENT auth for different
(modality, operation) pairs — e.g. OAuth for one operation and API-key
for another. A per-Provider AuthenticationContext cannot represent both
in the same scope and silently drops one.

Independent of that correctness gap, the primitive was not actually
needed: primitive peer SDKs (openai-python, anthropic-sdk-python,
google-genai) have no ambient auth state and handle multi-tenancy via
per-request client construction. celeste-python's own create_client()
already supports this natively — it returns a ModalityClient with the
auth bound at construction, which is the primitive-tier "bind once,
call many" pattern. An ambient ContextVar-backed primitive is a
framework-tier idea grafted onto the primitive layer.

No downstream consumer actually adopted the primitive, so the revert
has zero external breakage. create_client(auth=...) remains the
supported path for reusable, auth-bound clients.
Kamilbenkirane added a commit that referenced this pull request Apr 15, 2026
This reverts commit ff60c19.

Reverting per YAGNI + design error found in downstream analysis.

The shipped AuthenticationContext was keyed per-Provider, assuming a
single Authentication is bound to a provider. Downstream analysis in a
multi-modal, multi-provider context revealed this collapses cases where
a single provider legitimately exposes DIFFERENT auth for different
(modality, operation) pairs — e.g. OAuth for one operation and API-key
for another. A per-Provider AuthenticationContext cannot represent both
in the same scope and silently drops one.

Independent of that correctness gap, the primitive was not actually
needed: primitive peer SDKs (openai-python, anthropic-sdk-python,
google-genai) have no ambient auth state and handle multi-tenancy via
per-request client construction. celeste-python's own create_client()
already supports this natively — it returns a ModalityClient with the
auth bound at construction, which is the primitive-tier "bind once,
call many" pattern. An ambient ContextVar-backed primitive is a
framework-tier idea grafted onto the primitive layer.

No downstream consumer actually adopted the primitive, so the revert
has zero external breakage. create_client(auth=...) remains the
supported path for reusable, auth-bound clients.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant