> ## Documentation Index
> Fetch the complete documentation index at: https://openworklabs.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Per-member LLM credentials

> Bind a separate write-only provider credential to each organization member

Per-member credential bindings let an organization grant one managed LLM provider to many people without sharing one upstream key. Use this when an external gateway, such as LiteLLM, issues a distinct virtual key for each member.

This page describes an API integration pattern. LiteLLM provisioning and metadata discovery are not built into Den; run your own provisioner against Den's generic provider and credential-binding routes plus the LiteLLM API.

## Choose a credential mode

Set `credentialMode` when you create or update an organization LLM provider:

* `shared` stores one provider credential. Every granted member receives that credential from the connect route.
* `per_member` resolves a separate binding for the calling organization member. The provider can be granted before a binding exists.

Create a per-member custom provider with `POST /v1/llm-providers`:

```json theme={null}
{
  "name": "Per-member gateway",
  "source": "custom",
  "customConfig": {
    "id": "per-member-gateway",
    "name": "Per-member gateway",
    "npm": "@ai-sdk/openai-compatible",
    "env": ["GATEWAY_API_KEY"],
    "api": "https://gateway.example.com/v1",
    "models": [{ "id": "team-model", "name": "Team model" }]
  },
  "credentialMode": "per_member",
  "allMembers": true
}
```

Send the organization bearer token and `x-openwork-org-id` header on these requests.

## Member flow

A granted member can manage only their own write-only material:

* `PUT /v1/llm-providers/:id/my-credential` with exactly one of `{ "apiKey": "..." }` or `{ "apiKeys": { "ENV_NAME": "..." } }`
* `DELETE /v1/llm-providers/:id/my-credential`
* `GET /v1/llm-providers/:id/connect` to obtain the provider configuration and their resolved credential

The connect route always returns HTTP `200` for a granted member. When the member has no active binding, it keeps the standard provider payload shape, leaves both credential fields null, and reports the member-specific state:

```json theme={null}
{
  "llmProvider": {
    "apiKey": null,
    "apiKeys": null,
    "memberCredential": { "state": "missing" }
  }
}
```

`memberCredential.state` is one of `missing`, `active`, `blocked`, `stale`, or `error`. An active response carries that member's resolved `apiKey` or `apiKeys`; every other state carries null credentials. The missing or blocked state is payload data rather than an error status because published desktop clients fail the whole provider sync on any non-OK connect response. Older clients already degrade safely by skipping a provider whose connect payload has no usable credential.

## Admin and provisioner flow

Organization owners and admins can operate a central provisioner with these routes:

* `GET /v1/llm-providers/:id/member-credentials` lists every granted membership's state, version, and external identifiers. It never returns credential material.
* `PUT /v1/llm-providers/:id/member-credentials/:orgMembershipId` stores one member's credential. The body accepts `apiKey` or `apiKeys`, plus optional `externalPrincipalId`, `externalCredentialId`, and `expectedVersion`.
* `POST /v1/llm-providers/:id/member-credentials/:orgMembershipId/block` marks an existing binding blocked.

Use `expectedVersion` when multiple provisioner workers may update the same binding. A mismatch returns HTTP `409` with `{ "error": "version_conflict" }`.

A blocked binding is admin-owned. A member cannot overwrite or delete it: member writes and deletes return HTTP `409` with `{ "error": "credential_blocked" }`. An admin `PUT` is the explicit unblock and replacement path.

## LiteLLM example

The runnable example at [`examples/litellm-per-member-keys`](https://github.com/different-ai/openwork/tree/dev/examples/litellm-per-member-keys) uses LiteLLM virtual keys. Configure the Den and LiteLLM URLs, admin tokens, provider ID, and model IDs listed in its README, then run:

```bash theme={null}
node provision.mjs reconcile
```

The example first reads the exact provider from `GET /v1/llm-providers?scope=manageable`, verifies that it is a custom `per_member` provider, then queries LiteLLM `GET /model_group/info` with the master key. Every configured model must have an exact `model_group` match and finite, positive `max_input_tokens` and `max_output_tokens`. The provisioner uses those facts to update each Den model's context, input, and output limits. When present, it maps LiteLLM's function-calling, reasoning, vision, response-schema, and temperature facts to `tool_call`, `reasoning`, `attachment`, `structured_output`, and `temperature`. The replacement preserves the complete provider config, existing model fields, credential mode, and all member/team access.

This step fails closed before key creation if metadata or limits are missing. It never guesses a token limit or falls back to a generic value. The full-replacement Den PATCH omits `apiKey` and `apiKeys`, so Den preserves the write-only stored credential, and the example skips the PATCH entirely when the provider is already synchronized.

It then reconciles missing member credentials:

```js theme={null}
const { memberCredentials } = await den.get(
  `/v1/llm-providers/${providerId}/member-credentials`,
)

for (const binding of memberCredentials) {
  if (binding.state !== "missing") continue

  const keyAlias = `openwork-${binding.orgMembershipId}`
  const minted = await liteLlm.post("/key/generate", {
    models,
    key_alias: keyAlias,
    metadata: {
      openwork_org_membership_id: binding.orgMembershipId,
    },
  })

  await den.put(
    `/v1/llm-providers/${providerId}/member-credentials/${binding.orgMembershipId}`,
    {
      apiKey: minted.key,
      externalCredentialId: minted.token_id,
    },
  )
}
```

LiteLLM v1.97 returns a `token_id` that can address the virtual key without retaining its plaintext value. The example stores that identifier in `externalCredentialId`; Den's admin list can return it safely to the provisioner later.

The `/model_group/info` call and field mapping are deliberately implemented in this LiteLLM-specific example. Den core remains vendor-neutral: its provider PATCH and per-member credential APIs accept the resulting generic model configuration without hardcoding LiteLLM behavior.

### Offboard in the safe order

Always revoke or block the upstream credential **before** removing its local materialization. If the upstream call fails, leave the Den binding active so the failure remains visible and retryable rather than reporting a false local block.

For the example:

1. Read the member's `externalCredentialId` from `GET /v1/llm-providers/:id/member-credentials`.
2. Call LiteLLM `POST /key/block` with `{ "key": "<token_id>" }` and verify success.
3. Call Den `POST /v1/llm-providers/:id/member-credentials/:orgMembershipId/block`.
4. Verify the member's connect request returns HTTP `200`, null credentials, and `memberCredential.state: "blocked"`.

Run `node provision.mjs offboard <orgMembershipId>` to perform that sequence. The integration is proved against a real LiteLLM database and a cold Den by `evals/specs/litellm-per-member-credentials.e2e.test.ts`.
