Skip to content

inference: resolve credentials through the company identity, instead of copying it into a vendor slot #2266

Description

@graycyrus

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:

  1. resolve_effective_scoped normalizes first — normalize_provider("managed") == "openrouter" (src/company/inference.rs:358-361)
  2. so is_managed_choice (:375) is already false when resolve_endpoint sees it (:602)
  3. with has_key == true, both managed branches are skipped
  4. 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

  • inference consults tinyhumans/key only 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/finish no longer writes inference/key or inference/config. Connecting TinyHumans sets the identity; inference resolves through it.
  • Rotating tinyhumans/key changes the credential inference presents on the next turn, with no second write. A test pins it.
  • A managed/TinyHumans provider resolves to the TinyHumans endpoint, not openrouter.ai. A test asserts the base_url, not only the bearer — the gap that let Defect 3 ship.
  • Switching provider cannot strand a credential; probe_failure's "a key for another vendor" paragraph is deleted along with the state it described.
  • The billing/spend surface reports which account each surface bills to, so a split — if one is ever legitimate — is visible rather than silent.
  • Every existing write-only guarantee holds: no Serialize on anything holding a credential, key_never_leaks_across_any_response extended to any new route, Debug still redacts.
  • docs/modules/inference/credentials.md updated 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), a delete on the SecretStore port, an audit trail for credential writes, and capability-scoped tokens. Each is real; none is this issue.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions