Repository navigation
auth: per-project credential activation (provider profile per folder) #51152
Description
Summary
Credential activation (auth switch, /connect account list) is global to the shared background server: switching the active account for an integration in one project silently re-bills background agents running in unrelated projects. There is no way to pin a provider account (e.g. work vs personal API key) to a project/folder.
Environment
- opencode version: 2.0.12
- OS: Linux 7.1.8-arch1-3 (x64)
- Terminal: TERM=xterm-256color, COLORTERM=truecolor
- Shell: /usr/bin/bash
- Install/channel: latest (release binary via
~/.opencode/bin/opencode serve --service) - Active plugins: none
Reproduction
- Connect two API-key accounts to the same integration (e.g. two OpenCode Go keys via
/connectoropencode auth login), label them e.g.WorkandPersonal. - Start a session with background agents in project A using
provider/*models. - In project B, run
opencode auth switch <integration> Work(or pick the account in/connect). - Observe via
opencode auth list(active account sorts first) that the switch applies machine-wide: project A's background agents now resolve the sameprovider/*model IDs against theWorkcredential.
Verified at the store level: the credential table in opencode.db holds a single active flag per credential row with no directory/project column, e.g. SELECT label, active FROM credential WHERE integration_id='opencode-go' returns exactly one row with active=1. The credential PATCH/activate API endpoints take no location parameter, and GET /api/integration/{id} connection order does not vary by location[directory].
Expected Behavior
A project/folder config (e.g. opencode.json) can select which stored credential of an integration is active for sessions under that directory, with the global active account as fallback. Switching accounts in one project never affects sessions rooted elsewhere. Documented precedence rule needed for the existing cases too (saved account vs settings.apiKey vs env connection).
Actual Behavior
One active credential per integration per server. Any auth switch (CLI or TUI) instantly changes which key backs provider/* model IDs for every session on the shared server, including background agents in unrelated directories, with no warning.
Additional Context
- Frequency: consistent.
- Impact: accidental cross-billing between work/personal accounts; switching is unsafe on a machine with agents running in multiple projects.
- Related: stored credentials silently override explicit provider config (upstream report:
anomalyco/opencode#10950); both issues stem from credential resolution ignoring project config. Workaround attempted: namespaced custom provider (insieme-gowith{env:...}key) per project, but Go serves models over different routes (/chat/completionsvs/responses), so each route needs its own provider ID with hand-mirrored model metadata — fragile and high-maintenance.
Activity
jakoss commentedon Sep 29, 2026
It would be great to get some movement here, this would be the biggest gamechanger for people like me, where i have 4 copilot licenses from each customer and juggling between those is a nightmare
jakoss commentedon Sep 30, 2026
I went and did some investigation.
Short version: V2 already stores several credentials per integration with one
activeflag. I want to keep that table exactly as it is and make the selection Location-scoped instead of the activation. Proposal below, we need to push on the two decisions I'm least sure about.The shape
The binding lives in the project file that already describes the project, and Core resolves it per Location at request time.
Decision 1: a label, not a credential ID
The value names a stored account by label, matched case-insensitively.
Credential IDs are machine-local, so a committed
opencode.jsonnaming one is meaningless on a teammate's machine. Labels are already user-facing and already printed byopencode auth list.Cost of this choice: labels are not unique and have no rename history. Renaming an account breaks the binding. I think that is the right trade, see Decision 2, but it is a real cost and I would rather you agree with it than discover it later.
Decision 2: no fallback, fail loudly
If the named account is missing or matches more than one credential, the request fails with the provider name, the label you configured, the labels that do exist, and the fix. It does not fall back to the active account. Same reasoning covers a renamed account. It fails rather than retargeting.
If the account is NOT set explicitly - we keep current behavior (use globally active account), so unless user sets the account for workspace - nothing changes.
Selection is per Location, activation stays global
auth switchand/connectkeep working exactly as they do today, and they stay global. Opening a project never activates anything.Nothing about the stored credential changes, each Location just resolves a different one.
providers.<id>.accountfolds ontoProvider.InfoinConfigProviderPlugin. Project config folds after global config, so a project file overrides a global binding for the same provider with no merge change.ModelResolverreads onlyProvider, neverConfig, which is why the fold happens there.What else has to follow selection
The agent step, title generation, and compaction all resolve through
SessionRunnerModelintoModelResolver, so one change covers all three. They cannot disagree about which account is in use.Two things do not follow automatically:
Discovery. GitHub Copilot derives its model list, its endpoint, and its utility token from the OAuth credential. Its endpoint is per-credential, because
copilotBaseURLreadsmetadata.apiEndpointormetadata.enterpriseUrl. A work project discovering through the personal account sends personal-issued requests to the wrong endpoint. Discovery has to run against the Location's selected connection, and it has to re-run when the binding changes. Note that a config reload re-runs integration and provider registration but not plugin discovery, so the binding edit needs its own trigger.Provider availability. Core drops a provider whose discovered state came from a connection that is no longer the one in use, so one account's endpoints never get combined with another's. That comparison has to be against the Location's selected connection. Left as-is, a bound project loses its discovered models every time you switch accounts anywhere else, which would defeat the feature.
Precedence
Current order, highest first, for a model request:
providers.<id>.account, if setsettings.apiKeyin the provider configShape of the change
packages/schema(the field onConfigProvider.InfoandProvider.Info),packages/core/src/integration.ts(connection.selectnext toconnection.active, plus the two tagged errors),packages/core/src/model-resolver.ts(one selection point, plus an early account check so a missing account does not first surface as a missing model),packages/core/src/provider.ts(the availability comparison), the Copilot plugin (discovery and its triggers), and the plugin host surface. Roughly a day's work, and it needs a real per-Location test harness, which the existing model-resolver tests do not have.Happy to give a shot at this and open a PR.