Credential connection unification (one Credential, one resolution point)#
Everything a request needs lives in one credential structure, and one
resolution point hands it to the wire layer. A credential is never squeezed down
to a bare api_key string along the way, base_url no longer belongs to the
model alone, and "is this OAuth?" is not guessed from a token prefix.
As a result, a single key can carry its own base_url: under the same
openai-completions protocol, one key hits the official endpoint while another
hits Alibaba Cloud Bailian's compatible-mode/v1, with no need to pre-build a
model list for every compatible endpoint.
This document makes unified-auth-storage.md concrete. That document sets the overall direction of "one store, one login flow, unified across interfaces"; this one narrows in on the payload structure layer only, and does not touch the login registry, storage paths, or refresh ownership.
A request needs two things#
Sending an LLM request requires knowing which address to hit (base_url) and which auth value to use (key/token). When these two travel on separate tracks, a credential can only contribute half of the answer:
- The base_url track: every model in the model list hardcodes a
base_url→ loaded intoModel→ the wire layer readsmodel.base_urldirectly. It is bound to the model throughout and never passes through the credential. - The auth value track: AuthStore's
CredentialPoolstores credentials → the wire layer callsauth.usage.acquire_pooled(provider)→ internallymgr.acquire_sync()obtains the completeCredentialobject.
If the last step squeezes Credential into a str before handing it over
(pulling one string out of each of the 6 payloads: ApiKeyPayload→api_key,
OAuth/DeviceCode→access_token, CliDelegated→read external file,
credential_process→run the helper, sso→unsupported), then the base_url, headers, and kind the
credential knows about are all lost. Two consequences follow: even when a
credential stores a base_url, the wire layer cannot read it; and the anthropic
wire can only guess whether a token is OAuth via "sk-ant-oat" in key, because
the kind information is gone too.
So the resolution point hands over not a string, but the complete connection information a request needs.
One credential structure#
openprogram/auth/types.py uses one CredentialData (carried in the
Credential.payload slot) to cover every authentication method, instead of a
separate subtype per method. The differences in how much information each method
carries are real — simple authentication carries little, complex authentication
carries more — so the shared fields are fixed and the varying part goes into a
data dictionary:
@dataclass
class CredentialData:
# -- Shared fields: every auth method answers "what does a request use"
# in the same place --
kind: str # "api_key" | "oauth" | "device_code" |
# "cli_delegated" | "credential_process" | "sso"
auth_value: str = "" # the auth value that ends up in Authorization/x-api-key:
# api_key kinds -> the key itself
# oauth/device -> access_token
# cli_delegated -> empty (read from an external file at
# runtime, see data)
base_url: str = "" # endpoint specified by this credential; empty => use the
# list default (see resolution rules)
headers: dict = field(default_factory=dict) # extra request headers carried by this
# credential; usually empty
# -- Variation container: everything specific to one auth method goes here --
data: dict = field(default_factory=dict)
data holds the fields private to each kind rather than reserving them as
formal fields, so an api_key credential does not carry a pile of empty oauth
fields:
| kind | auth_value |
what goes in data |
|---|---|---|
api_key |
the key itself | (usually empty) |
oauth |
access_token | refresh_token / expires_at_ms / client_id / token_endpoint / scope / id_token |
device_code |
access_token | refresh_token / expires_at_ms / device_code_flow_id |
cli_delegated |
empty | store_path / access_key_path / refresh_key_path / expires_key_path |
credential_process |
empty | command / parses / json_key_path / cache_seconds |
sso |
empty | broker / subject |
Display information stays out of the payload. Account email, display name,
org id and the like remain in Credential.metadata (the UI renders it, the
manager does not interpret it). They do not affect how a request is sent; they
are consumed by the UI and usage statistics. Mixing them into connection
information only re-entangles "what to use" with "what to show".
One resolution point#
A single function translates a credential into the connection information the wire layer actually uses:
@dataclass
class ResolvedConnection:
kind: str # credential type -- the wire no longer guesses OAuth
# from a key prefix
auth_value: str # the resolved auth value (cli_delegated is already
# filled in by reading the external file)
base_url: str | None # endpoint specified by the credential; None => let the
# wire fall back to model.base_url
headers: dict # extra request headers carried by the credential
# (empty by default)
def resolve_connection(cred: Credential) -> ResolvedConnection | None:
"""Translate one Credential into the connection information for a request.
cli_delegated reads the external file here to obtain the token (preserving its
"the external CLI is authoritative" semantics).
credential_process runs its helper here, reusing a token cached for
cache_seconds; a helper failure raises AuthCredentialProcessError, which the
resolver ladder re-raises instead of falling through, because a helper the
user configured deliberately must not be silently replaced by another layer.
sso raises AuthConfigError -- the kind is reserved and no flow implements it."""
auth.usage.acquire_pooled returns (conn: ResolvedConnection, profile, cred_id) instead of (token: str, profile, cred_id), so the connection
information the credential knows about is not lost midway. It already holds the
complete cred internally; only the final string extraction is replaced by
resolve_connection.
Wire layer: credential first, model list as fallback#
Every wire (openai_completions / openai_responses / anthropic) reads
values by the same rules:
conn = <ResolvedConnection from acquire_pooled, or None>
api_key = conn.auth_value if conn else opts.api_key
base_url = (conn.base_url if conn and conn.base_url else None) or model.base_url
headers = { **(model.headers or {}), **(conn.headers if conn else {}), **(opts.headers or {}) }
is_oauth = bool(conn and conn.kind in ("oauth", "device_code"))
The rule in one sentence: if the credential carries a base_url, use the
credential's; otherwise use the model's default. So official openai / deepseek
/ anthropic keep working as before (their credentials leave base_url empty),
while connecting to Bailian only requires filling in
base_url = https://…maas.aliyuncs.com/compatible-mode/v1 when storing the key.
That credential's requests then go to Bailian, and nothing else is affected.
Credential(CredentialData{auth_value, base_url, headers, kind, data})
│
└─(resolve_connection)─► ResolvedConnection{kind, auth_value, base_url, headers}
│
└─ wire: base_url = conn.base_url or model.base_url ─► AsyncClient(...)
the model list's base_url is only the fallback default
when conn.base_url is empty
Scope of impact#
Changes:
openprogram/auth/types.py: the 6 payload classes merge into oneCredentialData;_payload_to_dict/_payload_from_dictsimplify accordingly into single-type serialization (kind+ flat fields +data).openprogram/auth/resolver.py:resolve_connectionreplaces the path that returned a bare str.openprogram/auth/usage.py:acquire_pooledreturns aResolvedConnectiontriple.- The wires (
openai_completions.py/openai_responses/*/anthropic/anthropic.py): handle auth, base_url, headers, and the oauth decision by the rules above. - Places that read specific payload fields (the manager's OAuth refresh reading
refresh_token/expires, delegated reading external file paths, and so on): switch to reading fromCredentialData.data[...].
Retained:
- The
base_urlin the model list stays, as the default when a credential does not specify one. Built-in providers work out of the box as before. Credential.metadataand display information such as the OAuth email are read by the UI and usage statistics as before.- The login registry, storage paths, refresh ownership, and cross-interface unification are out of scope here.
Old format: one-time migration#
The runtime (_payload_from_dict / resolve_connection) recognizes only the
new structure. Reading old 6-payload JSON (carrying the __type__
discriminator) is an error, not a fallback path. Existing credentials are moved
to the new structure by a one-time migrator, and users do not need to log in
again.
openprogram/auth/_migrate_payload.py converts the old payload in each
~/.openprogram/auth/<provider>/<profile>.json in place into the new
CredentialData and writes it back atomically (reusing the store's
write→fsync→replace). Conversion rules:
old __type__ |
→ new kind |
auth_value |
data (remaining fields moved over wholesale) |
|---|---|---|---|
ApiKeyPayload |
api_key |
api_key |
{} (base_url/headers empty if absent in the old form) |
OAuthPayload |
oauth |
access_token |
refresh_token expires_at_ms scope client_id token_endpoint id_token extra |
DeviceCodePayload |
device_code |
access_token |
refresh_token expires_at_ms device_code_flow_id |
CliDelegatedPayload |
cli_delegated |
"" |
store_path access_key_path refresh_key_path expires_key_path |
ExternalProcessPayload |
credential_process |
"" |
command parses json_key_path cache_seconds |
SsoPayload |
sso |
"" |
broker subject |
The migrator is idempotent: a payload already in the new structure (top-level
kind field present, no __type__) is skipped. It runs automatically on the
first AuthStore load, and can also be triggered manually with
openprogram auth migrate. Management files that contain no credentials, such
as _rotation/_active/_disabled/_order.json, have no payload, and the migrator
skips them.
Tests#
resolve_connection: one case per kind — api_key with and without base_url, oauth yielding access_token, cli_delegated reading the external file live, credential_process running a fake helper script (json and text parsing, the cache window de-duplicating forks, and every failure mode raising rather than falling through), sso raising.- Serialization round trip:
CredentialData→ dict →CredentialDatawith identical fields (includingdata). - Wire value rules: a credential with base_url wins, without one
model.base_urlis used; withkind=oauth,is_oauthis true without depending on any prefix. - End to end: store an
api_keycredential withbase_url=Bailian, runopenai-completions, and verify the client's base_url points at Bailian while the official openai credential still points at the default endpoint. - Migrator: one old
ApiKeyPayload/OAuthPayload/CliDelegatedPayloadJSON each, with correctkind/auth_value/dataand no__type__after migration; idempotent skip for files already in the new structure; management files (_rotation.jsonand friends) left untouched.