feat: add AuthenticationContext ambient primitive#259
Merged
Kamilbenkirane merged 6 commits intomainfrom Apr 15, 2026
Merged
Conversation
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.
Code reviewNo 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
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.
6 tasks
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a
ContextVar-backed ambient authentication primitive so multi-tenant servers can bind per-request credentials without threadingauth=through every call site.celeste.authexposingAuthenticationContext(frozen pydantic, keyed byProvider), anauthentication_scope()context manager, and aresolve_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. Explicitauth=/api_key=kwargs still win.MissingAuthenticationError(subclass ofCredentialsError) raised when a scope is bound but the requested provider has no entry — distinct fromMissingCredentialsErrorso a multi-tenant caller can distinguish "scoped auth has no entry for this provider" from "env credentials are missing." MirrorsMissingCredentialsError's positionalprovidersignature.from celeste import AuthenticationContext, authentication_scope, MissingAuthenticationError.Usage
Backward compatibility
The module-level
celeste.text.generate(...)API is unchanged. Namespaces stay stateless singletons. Existing callers that passauth=orapi_key=explicitly observe identical behavior — ambient is additive and only kicks in when both kwargs areNoneand 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, ...]incredentials.pyare both keyed byProvider.Authenticationinstances are provider-bound by construction (AuthHeaderproduces provider-specific headers), soProvideris 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, WerkzeugLocal, structlogbind_contextvars, OpenTelemetry'sContextVarsRuntimeContext, and DSPy'sdspy.context(lm=...). This PR adopts the same pattern for celeste authentication.Implementation notes
AuthenticationContextis frozen becauseasyncio.gathersiblings share a context snapshot by reference — mutability would cause silent cross-task credential bleed under concurrent requests.resolve_authenticationraisesMissingAuthenticationError— 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.run_in_executor/ThreadPoolExecutor/concurrent.futurescall sites, so ContextVar propagates naturally across the SDK without anycopy_context()wrappers.create_client()runs afterresolved_provideris populated from_resolve_model().provider, so the provider is always known when the ambient is consulted (BYOA paths withresolved_provider is Noneskip the ambient entirely and use explicit kwargs).Test plan
tests/unit_tests/test_auth.pycovering: existingAuthHeader/APIKey/ registry tests (unchanged), frozen invariant, scope enter/exit, nested scopes, explicit-None-clears, scope-bound-but-provider-missing raises,asyncio.create_taskpropagation,asyncio.gathersiblings,asyncio.to_threadpropagation, rawThreadPoolExecutordoes-not-propagate (negative test),ThreadPoolExecutor+copy_context()does propagate.uv run mypy src/celesteclean (331 source files).uv run ruff checkclean, pre-commit hooks (ruff, format, mypy src+tests, bandit) all pass.