Problem
inference/key holds two different kinds of thing in one slot:
- a vendor credential — "my OpenRouter key", "my BYOK token"
- an identity — "my TinyHumans key, being used for inference"
These have different lifecycles. A vendor key is replaced when you change vendor. An identity is rotated, and a rotation should reach everywhere it is used. One slot cannot serve both, and three live defects follow from trying.
Defect 1 — billing splits silently
src/company/inference.rs contains no reference to company_key (verified by grep). There is no path by which tinyhumans/key reaches a chat completion.
Composio does consult it — composio::resolve_credential falls through to company_key::resolve (src/company/composio.rs:113).
So a company that sets its own TinyHumans key gets:
|
Billed to |
| App connections (Gmail, GitHub…) |
the company ✅ |
| Every agent turn |
whoever runs the server ❌ |
Thinking is the expensive half, and nothing on screen says this happened. ops/company_key.rs:20-24 is honest about the gap in-tree ("inference and embeddings still resolve from the environment (#585)"), but the module docs of company_key.rs:24-38 promise every brokered surface rides one resolution — which is not true today.
Defect 2 — the one place the slots meet is a copy, and it goes stale
POST {scope}/credential/link/finish writes the hub-minted key into both tinyhumans/key (src/server/ops/company_key.rs:437) and inference/key (:440), plus an inference/config of { provider: "managed", base_url: None } (:448-457).
That makes one key appear to serve both surfaces. It is a copy at write time, not a shared resolution — so rotating the account key afterwards through PUT {scope}/credential does not update the inference copy. It keeps presenting the previous value until somebody replaces it separately, with nothing indicating the two have diverged.
Defect 3 — that copy is bearer'd to the wrong vendor
link/finish stores provider: "managed". On resolve:
resolve_effective_scoped normalizes first — normalize_provider("managed") == "openrouter" (src/company/inference.rs:358-361)
- so
is_managed_choice (:375) is already false when resolve_endpoint sees it (:602)
- with
has_key == true, both managed branches are skipped
- the final arm returns
effective_base_url("openrouter", None) = OPENROUTER_BASE_URL (:697-711), proxied = false
Net effect: a th_… TinyHumans key is presented as a bearer to https://openrouter.ai/api/v1.
The handler's own comment (src/server/ops/company_key.rs:450-455) claims declaring managed is "what makes the stored key the one its turns are billed to". The code does not do that.
Why no test caught it: a_console_key_and_the_platform_default_are_distinguishable (src/company/inference.rs:1398) stores exactly this shape and asserts the bearer, never the base_url.
Defect 4 — switching provider strands a credential
One slot per company means saving a key and then changing provider leaves a credential for the wrong vendor in the only slot there is. The first turn fails with a 401 the host has to explain in prose (src/server/ops/inference.rs, probe_failure):
A key is stored against the provider selected when it was saved, so a key for another vendor fails here even while this card reports one is set. Re-save the key under {provider}, or Remove key to fall back.
The console repeats the warning in help text because it cannot prevent the state. When an error message compensates for a modelling gap, the model is wrong.
Proposed resolution
Full write-up: docs/modules/inference/credentials.md (PR #2262).
INFERENCE
1. the provider entry's own credential ← a vendor key
2. if that provider IS the managed/TinyHumans one:
tinyhumans/key ← this company's account
else the instance identity
3. nothing → agents cannot think
COMPOSIO
1. composio/token
2. tinyhumans/key
3. the instance identity
4. nothing → no app tools
The rule, stated once: an identity flows to a surface only when the vendor at the other end is the identity's own vendor.
- provider is TinyHumans/managed → the account key is the right credential for that endpoint, so consult it
- provider is any other vendor → the account key means nothing to them; never send it, require a vendor credential or fail closed
Composio needs no equivalent check: it is always brokered through TinyHumans, so there is only ever one vendor at the other end. That is why its chain is already correct and inference's is not.
What this fixes, structurally rather than by handling
- Defect 1 — one account key moves both surfaces, which is what an operator expects
- Defect 2 — no copy exists, so nothing can go stale; rotation propagates through the seam
- Defect 3 — nothing stores an identity in a vendor slot, so
managed → openrouter normalisation has nothing to misroute
- Defect 4 — the credential lives on the provider entry, so switching provider switches which credential is in play. Nothing is stranded because nothing was ever shared. The 401 paragraph can be deleted, not reworded.
Relationship to #2262
The provider-list rework already gives each provider entry its own credential slot, which is the precondition. This issue is the resolution-order half: making the identity participate, under the vendor check, and removing the copy in link/finish.
Sequence: land the per-provider credential slots (#2262 Stage 3), then this.
Acceptance criteria
Out of scope
Named so they are decisions, not omissions: encryption at rest (self-admitted follow-up in src/store/fs.rs), a delete on the SecretStore port, an audit trail for credential writes, and capability-scoped tokens. Each is real; none is this issue.
Problem
inference/keyholds two different kinds of thing in one slot:These have different lifecycles. A vendor key is replaced when you change vendor. An identity is rotated, and a rotation should reach everywhere it is used. One slot cannot serve both, and three live defects follow from trying.
Defect 1 — billing splits silently
src/company/inference.rscontains no reference tocompany_key(verified by grep). There is no path by whichtinyhumans/keyreaches a chat completion.Composio does consult it —
composio::resolve_credentialfalls through tocompany_key::resolve(src/company/composio.rs:113).So a company that sets its own TinyHumans key gets:
Thinking is the expensive half, and nothing on screen says this happened.
ops/company_key.rs:20-24is honest about the gap in-tree ("inference and embeddings still resolve from the environment (#585)"), but the module docs ofcompany_key.rs:24-38promise every brokered surface rides one resolution — which is not true today.Defect 2 — the one place the slots meet is a copy, and it goes stale
POST {scope}/credential/link/finishwrites the hub-minted key into bothtinyhumans/key(src/server/ops/company_key.rs:437) andinference/key(:440), plus aninference/configof{ provider: "managed", base_url: None }(:448-457).That makes one key appear to serve both surfaces. It is a copy at write time, not a shared resolution — so rotating the account key afterwards through
PUT {scope}/credentialdoes not update the inference copy. It keeps presenting the previous value until somebody replaces it separately, with nothing indicating the two have diverged.Defect 3 — that copy is bearer'd to the wrong vendor
link/finishstoresprovider: "managed". On resolve:resolve_effective_scopednormalizes first —normalize_provider("managed") == "openrouter"(src/company/inference.rs:358-361)is_managed_choice(:375) is alreadyfalsewhenresolve_endpointsees it (:602)has_key == true, both managed branches are skippedeffective_base_url("openrouter", None)=OPENROUTER_BASE_URL(:697-711),proxied = falseNet effect: a
th_…TinyHumans key is presented as a bearer tohttps://openrouter.ai/api/v1.The handler's own comment (
src/server/ops/company_key.rs:450-455) claims declaringmanagedis "what makes the stored key the one its turns are billed to". The code does not do that.Why no test caught it:
a_console_key_and_the_platform_default_are_distinguishable(src/company/inference.rs:1398) stores exactly this shape and asserts the bearer, never the base_url.Defect 4 — switching provider strands a credential
One slot per company means saving a key and then changing provider leaves a credential for the wrong vendor in the only slot there is. The first turn fails with a 401 the host has to explain in prose (
src/server/ops/inference.rs,probe_failure):The console repeats the warning in help text because it cannot prevent the state. When an error message compensates for a modelling gap, the model is wrong.
Proposed resolution
Full write-up:
docs/modules/inference/credentials.md(PR #2262).The rule, stated once: an identity flows to a surface only when the vendor at the other end is the identity's own vendor.
Composio needs no equivalent check: it is always brokered through TinyHumans, so there is only ever one vendor at the other end. That is why its chain is already correct and inference's is not.
What this fixes, structurally rather than by handling
managed→openrouternormalisation has nothing to misrouteRelationship to #2262
The provider-list rework already gives each provider entry its own credential slot, which is the precondition. This issue is the resolution-order half: making the identity participate, under the vendor check, and removing the copy in
link/finish.Sequence: land the per-provider credential slots (#2262 Stage 3), then this.
Acceptance criteria
inferenceconsultstinyhumans/keyonly when the resolved provider is the managed/TinyHumans one; a test pins that an OpenRouter or BYOK provider never receives it.POST {scope}/credential/link/finishno longer writesinference/keyorinference/config. Connecting TinyHumans sets the identity; inference resolves through it.tinyhumans/keychanges the credential inference presents on the next turn, with no second write. A test pins it.openrouter.ai. A test asserts thebase_url, not only the bearer — the gap that let Defect 3 ship.probe_failure's "a key for another vendor" paragraph is deleted along with the state it described.Serializeon anything holding a credential,key_never_leaks_across_any_responseextended to any new route,Debugstill redacts.docs/modules/inference/credentials.mdupdated to describe what shipped, if it diverges from the plan.Out of scope
Named so they are decisions, not omissions: encryption at rest (self-admitted follow-up in
src/store/fs.rs), adeleteon theSecretStoreport, an audit trail for credential writes, and capability-scoped tokens. Each is real; none is this issue.