metis

Credentials Specification

Status: Shipped v1 Last updated: 2026-05-20

Defines how Metis resolves LLM-provider API keys at runtime. Today the CLI and gateway each call os.environ.get("ANTHROPIC_API_KEY") etc. directly from their bootstrap path (cli/runtime.py:112-118, gateway/runtime.py:81-85); adding a fourth provider means editing both call sites. This spec replaces the direct lookups with a CredentialResolver that walks a documented priority chain. Env vars keep working — they sit on the chain — but a new structured file at ~/.metis/credentials.yaml becomes the discoverable default UX. An OS-keychain tier is specified as an opt-in hook for the future without committing to a runtime dependency in v1.


1. Purpose

Three problems with the current env-var-only approach:

  1. Discoverability. A new user installs Metis, runs metis chat, and sees “set ANTHROPIC_API_KEY”. They don’t know which providers are required vs optional, how to verify their key works, or where multiple keys would live.
  2. Per-session pain. API keys live in shell sessions, .envrc, .env, or ~/.zshrc. Each shell reload is a small friction tax; adding a fifth provider doubles the surface.
  3. Extensibility. Adding a new LLM provider today touches cli/runtime.py and gateway/runtime.py and the README. The resolver lets the runtime discover new providers through a single registry table.

The fix is not a config file (which alone solves discoverability) or a CLI wizard (which alone solves per-session pain) but a resolution chain that admits both, plus a small metis auth CLI surface for setup and diagnostics.

2. Scope

2.1 In scope

2.2 Out of scope (deferred)

3. Resolution chain

The resolver walks this order, returning the first match:

Order Source Use case
1 --api-key provider=<value> Per-invocation override (rare; CI / scripts)
2 ${PROVIDER}_API_KEY env var Today’s path; 12-factor; CI; Docker
3 ~/.metis/credentials.yaml New default UX; one-time setup
4 ~/.metis/.env Legacy dotenv support; existing users
5 OS keychain (opt-in) Future tier; deferred

If all sources miss for a required provider, the resolver raises CredentialNotFoundError with a message that tells the user how to fix it:

no credentials configured for anthropic. Add via:
  metis auth add anthropic
or set ANTHROPIC_API_KEY in your environment / .env file

3.1 Ordering rationale

4. File format

~/.metis/credentials.yaml (mode 0o600):

# Schema version. Resolver refuses to load a file whose version it doesn't
# understand. Forward-only migration; no v0 fallback needed in v1.
schema_version: 1

providers:
  anthropic:
    api_key: sk-ant-...
  openai:
    api_key: sk-...
  openrouter:
    api_key: sk-or-...

# Which provider to prefer when the routing engine has multiple candidates
# and a slot doesn't pin one explicitly. Optional; defaults to the first
# provider listed.
default_provider: anthropic

# Reserved for future opt-in keychain integration. Setting this to true
# moves keychain ahead of `~/.metis/.env` in the resolution chain (still
# behind env vars and CLI flag).
prefer_keychain: false

4.1 Multi-key per provider (v1.1)

v1.0 ships single-key-per-provider. v1.1 adds optional named keys:

providers:
  openrouter:
    api_key: sk-or-...           # default key for this provider
    keys:                         # additional named keys
      personal:
        api_key: sk-or-personal-...
      work:
        api_key: sk-or-work-...
default_provider_key: openrouter.work

The resolver returns the default_provider_key when set, falling back to the provider’s top-level api_key otherwise. Named keys are accessed by <provider>.<key_name> everywhere a provider argument is accepted (CLI flag, routing rule, etc.).

4.2 File operations

5. CLI surface

All subcommands live under metis auth. The CLI uses the same resolver internally — metis auth add writes to the credentials file; metis auth test walks the resolution chain.

5.1 metis auth add <provider>

Interactive: prompts for the API key, optionally validates by pinging a free endpoint on the provider, then writes to ~/.metis/credentials.yaml.

$ metis auth add anthropic
API key for anthropic: sk-ant-***************
Validating... ✓ (responded in 142ms)
Added to ~/.metis/credentials.yaml

Flags:

5.2 metis auth list

Shows configured providers and resolution source. Never prints the full key.

$ metis auth list
PROVIDER        SOURCE                              KEY
anthropic       ~/.metis/credentials.yaml          sk-ant-1234...wxyz
openai          ANTHROPIC_API_KEY (env)            sk-1234...wxyz
openrouter      (not configured)

The display shows first 8 + last 4 characters of the key (sk-ant-1234...wxyz). This is enough for the user to recognize their own key without leaking it to screen-share viewers.

5.3 metis auth remove <provider>

Removes the provider’s entry from the credentials file. Idempotent. Doesn’t touch env vars or the legacy .env.

5.4 metis auth test [provider]

Pings each configured provider’s free endpoint to verify the key works.

$ metis auth test
anthropic    ✓ (87ms)
openai       ✓ (104ms)
openrouter   ✗ AUTH error — key may be revoked

Validation endpoints:

5.5 metis auth doctor

Full diagnostic: which providers configured, last successful call timestamp from the trace, recent AUTH errors. Buyer-trial debugging surface.

$ metis auth doctor
Credential resolver:
  ~/.metis/credentials.yaml         ✓ readable (mode 0o600)
  ~/.metis/.env                     (not present)
  Keychain support                  (opt-in; not active)

Providers:
  anthropic        ✓ configured (~/.metis/credentials.yaml)
                   last successful call: 2026-05-20T14:32:11Z
                   recent AUTH errors:    0 (last 24h)
  openai           ✓ configured (env: OPENAI_API_KEY)
                   last successful call: 2026-05-19T09:11:43Z
                   recent AUTH errors:    1 (last 24h) — see trace event 01HZ...
  openrouter       ✗ not configured
                   Add via: metis auth add openrouter

Default provider: anthropic

6. Implementation

6.1 Protocol

# packages/metis/src/metis/core/credentials/protocol.py

@runtime_checkable
class CredentialResolver(Protocol):
    """Returns API keys for LLM providers. Walks the resolution chain per §3."""

    def get(self, provider: str) -> str | None:
        """Return the API key for `provider`, or None if not configured.

        `provider` is the canonical name ("anthropic", "openai", "openrouter").
        Never raises on missing — callers decide whether absence is fatal.
        """
        ...

    def list_configured(self) -> list[ConfiguredCredential]:
        """Return one entry per configured provider, with source provenance
        but never the full key. Used by `metis auth list` and `doctor`."""
        ...

6.2 Provider registry

Adding a new LLM provider means one row in packages/metis/src/metis/core/credentials/providers.py:

KNOWN_PROVIDERS: dict[str, ProviderSpec] = {
    "anthropic": ProviderSpec(
        env_var="ANTHROPIC_API_KEY",
        validate_endpoint=("POST", "https://api.anthropic.com/v1/messages",
                           {"model": "claude-haiku-4-5", "max_tokens": 1, ...}),
    ),
    "openai": ProviderSpec(
        env_var="OPENAI_API_KEY",
        validate_endpoint=("GET", "https://api.openai.com/v1/models", None),
    ),
    "openrouter": ProviderSpec(
        env_var="OPENROUTER_API_KEY",
        validate_endpoint=("GET", "https://openrouter.ai/api/v1/auth/key", None),
    ),
    # Adding "groq" / "mistral" / "deepseek" is one new entry here.
}

The resolver consults this table to map provider → env var name and to implement metis auth test.

6.3 Migration

7. Security posture

  1. File mode 0o600. Enforced on every read; the resolver REFUSES to load a credentials file with insecure permissions and tells the user how to fix it. Mirrors ~/.ssh/id_* and ~/.aws/credentials.
  2. Never log the full key. The resolver returns keys to callers; logs, trace events, and CLI output only show truncated forms (sk-ant-1234...wxyz).
  3. No key in error messages. A failed metis auth test shows the provider name and HTTP status, never the key.
  4. Atomic writes. Write-temp-then-rename; on partial-write failure the existing file stays intact.
  5. Resolver returns str, not a wrapper. Callers (provider adapters) are responsible for not echoing the key. Wrapping in a “secret” type adds friction without preventing the dominant leak path (logs from exception traces in the adapter).
  6. Keychain integration (future) is the upgrade path for users who want the file-on-disk plaintext gone.

8. OS keychain hook (Option B placeholder)

The CredentialResolver Protocol is implementation-agnostic. A future KeychainCredentialResolver would:

  1. Implement the same Protocol
  2. Use the keyring library (cross-platform: macOS Keychain / Windows Credential Manager / Linux Secret Service)
  3. Be opt-in via prefer_keychain: true in credentials.yaml OR a new --keychain flag

The v1.0 file resolver MUST be designed so that the keychain resolver can compose on top of it without breaking compatibility — meaning:

v1.0 acceptance criterion: the resolver code has a clean injection point for an additional source between steps 4 and 5 of the chain, and the ConfiguredCredential.source enum accepts a KEYCHAIN value even though v1.0 never emits it.

9. Open questions

  1. First-run wizard. Should metis chat on a fresh install detect zero configured providers and offer to run metis auth add interactively? Or should the error message be sufficient? Default lean: error message only; wizards have a reputation for being annoying. Resolved v1 (2026-05-20): error message only. setup_runtime raises SetupError with the canonical “Run metis auth add anthropic (or set ANTHROPIC_API_KEY in env / .env).” string. Revisit if the buyer-trial funnel shows the message is missed.
  2. Validation endpoint costs. Anthropic’s messages is a paid endpoint; 1-token validation costs ~$0.000001 per metis auth test call. OpenAI’s /v1/models is free. Worth noting in the user-facing message? “Validating Anthropic key costs ~$0.000001” feels pedantic but transparent. Resolved v1: no pre-call disclosure. --no-validate is the explicit opt-out.
  3. Per-team credentials in metis-pro. The Pro tier may eventually want per-team or per-customer upstream credentials (so each customer’s gateway requests use their own Anthropic key, not the operator’s). Out of scope for OSS v1.0 — but the Protocol should not preclude it. The metis-pro overlay could implement a TeamScopedCredentialResolver that wraps the OSS resolver. v1 surface: CredentialResolver Protocol is structural; Pro overlay can decorate it without touching OSS code.
  4. Schema_version migrations. v1.0 ships at schema_version 1. When v1.1 adds multi-key support (named keys), the schema bumps to 2 with a forward-only migration (v1 files load cleanly under v2 code; v2 files refuse to load under v1 code). Confirm policy. v1 enforcement: CredentialsFileSchemaUnknown rejects any schema_version outside SUPPORTED_SCHEMA_VERSIONS = (1,).

v1 implementation deviations from earlier drafts

10. References