Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Architecture

## System Overview

This OpenCode plugin authenticates against the ChatGPT Codex backend, selects among configured OpenAI accounts, and retries rate-limited requests with eligible fallback accounts.

```mermaid
flowchart TD
OpenCode[OpenCode] --> Plugin[index.ts: OpenAIAuthPlugin]
CLI[multiauth: lib/cli.ts] --> Accounts[AccountManager]
Plugin --> Accounts
Plugin --> Sessions[SessionBindingStore]
Accounts --> AccountStorage[openai-accounts.json]
Sessions --> BindingStorage[openai-multi-auth-session-bindings.json]
Plugin --> Codex[ChatGPT Codex backend]
```

## Module Dependencies

```mermaid
flowchart LR
CLI[lib/cli.ts] --> Manager[lib/accounts/manager.ts]
Entry[index.ts] --> AccountIndex[lib/accounts/index.ts]
AccountIndex --> Manager
Entry --> Session[lib/session-bindings.ts]
Manager --> Auth[lib/auth/auth.ts]
Manager --> AccountTypes[lib/accounts/types.ts]
Manager --> Secure[lib/secure-file.ts]
Session --> Secure
Entry --> Fetch[lib/request/fetch-helpers.ts]
Entry --> Models[lib/models.ts]
Entry --> Status[lib/codex-status.ts]
```

References flow from entry points to orchestration, then storage and utility layers. The default-account feature introduces no circular dependency.

## Entry Points

| Entry point | Location | Contract |
|---|---|---|
| OpenCode plugin | `index.ts:70` | Initializes account and session state and returns OpenCode hooks |
| OpenAI loader | `index.ts:330` | Returns the backend URL and custom fetch implementation |
| Request executor | `index.ts:351` | Refreshes tokens, sends requests, and handles account retries |
| Request selection | `index.ts:628` | Extracts model/session data and selects the working account |
| CLI API | `lib/cli.ts:17` | `runCli(args, io)` returns exit status `0` or `1` |
| CLI executable | `lib/cli.ts:64` | Runs `runCli()` when invoked directly |
| Package command | `package.json:39` | Maps `multiauth` to `dist/lib/cli.js` |

## Modified Data Flow

```mermaid
sequenceDiagram
participant OC as OpenCode
participant P as index.ts
participant A as AccountManager
participant S as SessionBindingStore
participant API as Codex backend

OC->>P: OpenAI request with model and prompt_cache_key
P->>A: getDefaultAccount(model)
alt Eligible default on first process use
A-->>P: Default account
P->>S: Bind session to default index
else Default unavailable
P->>A: getNextAvailableAccountForNewSession(model)
A-->>P: Strategy-selected account
P->>S: Bind session to selected index
end
P->>API: Send request with account credentials
alt 429 response
P->>A: markRateLimited and saveToDisk
P->>A: getNextAvailableAccountExcluding
A-->>P: Fallback account
P->>S: Rebind before retry
P->>API: Retry with fallback
end
```

The first observable OpenAI request is the session initialization boundary because OpenCode exposes no `session.selected` hook. The process-local initialized-session set prevents a session from returning to its default after it has been rebound to a fallback.

## Interfaces and Contracts

| Interface | Location | Contract |
|---|---|---|
| `AccountsStorage.defaultAccountIndex` | `lib/accounts/types.ts:27` | Optional numeric index in the existing version-1 file |
| `AccountManager.getDefaultAccount()` | `lib/accounts/manager.ts:209` | Returns an eligible default or `null` |
| `AccountManager.getDefaultAccountIndex()` | `lib/accounts/manager.ts:217` | Distinguishes configured-but-unavailable from unconfigured |
| `AccountManager.setDefaultAccount()` | `lib/accounts/manager.ts:221` | Resolves exactly one trimmed, case-insensitive email and persists it |
| `SessionBindingStore.set()` | `lib/session-bindings.ts:47` | Persists a session key to account index binding |
| `runCli()` | `lib/cli.ts:17` | Writes user-safe output and returns a process exit code |

Existing version-1 account files without `defaultAccountIndex` remain valid. Invalid stored indexes are treated as no default. Removing the default clears it; removing an earlier account decrements it.

## Technical Debt

- The default and session bindings use positional account indexes rather than stable IDs. Manager-mediated removal maintains the default, while stale session bindings are repaired when encountered.
- The CLI cannot explicitly clear a default. Selecting another account replaces it, and removing the selected account clears it.
- `removeAccount()` starts persistence without awaiting completion, so callers cannot observe a write failure. This behavior predates the feature.
- Atomic rename protects individual JSON writes but does not prevent last-writer-wins races between a running plugin and the CLI.

No new TODO markers were introduced.
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@

All notable changes to this project are documented here. Dates use the ISO format (YYYY-MM-DD).

## [Unreleased]

### Added
- Added `multiauth -d <EMAIL_ADDRESS>` and `multiauth --default <EMAIL_ADDRESS>`.
- Added optional `defaultAccountIndex` support to version-1 account storage.
- Added first-use default-account selection for OpenAI sessions.

### Changed
- Default selection uses trimmed, case-insensitive email matching and requires exactly one matching account.
- An eligible default overrides a persisted binding once at the first observable OpenAI request in a plugin process.
- 429 cooldowns are persisted and sessions are rebound to the fallback before retry.
- Replaced the shell-specific build copy command with a cross-platform Node.js filesystem copy.
- Made the test harness portable across Windows `HOME` and `USERPROFILE` behavior.

### Fixed
- Parse both numeric and HTTP-date `Retry-After` headers and use a conservative cooldown for malformed values.

### Notes
- `multiauth` requires a global or locally linked installation to be directly available on `PATH`.
- Restart OpenCode after changing the default account.
- Selecting another account replaces the default; there is no clear-default command.
- Non-OpenAI providers are unaffected.

## [5.0.0] - 2026-01-15

**Major release**: Multi-account support with automatic rotation on rate limits.
Expand Down
40 changes: 33 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,37 @@ opencode auth login
# Repeat for as many accounts as you have
```

### Selecting a Default Account

Install the package globally so the `multiauth` command is available on `PATH`, then select one existing account by email:

```bash
npm install --global opencode-openai-multi-auth
multiauth -d user@example.com
# Equivalent: multiauth --default user@example.com
```

For a local checkout, build and install that checkout instead:

```bash
npm run build
npm install --global .
multiauth -d user@example.com
```

Matching trims whitespace and ignores email case. The email must match exactly one configured account. Restart OpenCode after changing the default. Selecting another account replaces the current default; there is no clear-default command.

### Automatic Rotation

When you hit a rate limit:
When an OpenAI request reaches a rate limit:

1. Plugin detects 429 (rate limited) response
2. Marks current account as limited for that model
3. Keeps the current session on the same account (no mid-turn hot-swap)
4. Keeps that session/account binding; start a new session to switch accounts
5. Shows toast notification for account usage and rate limit status
1. The plugin detects the `429` response.
2. It persists the account cooldown for the affected model.
3. It selects an eligible fallback account using the configured strategy.
4. It rebinds the current session before retrying with the fallback.
5. Later requests in the same plugin process and session continue using the fallback.

Session bindings are persisted locally so the same `prompt_cache_key` stays on the same account even after plugin process restarts.
If the default is cooling down, has failed repeatedly, or does not support the requested model, the normal selection strategy chooses an account. A configured default overrides a persisted binding once, at the first observable OpenAI request for that session in a plugin process. OpenCode exposes no `session.selected` hook, so this first request is the initialization boundary. Non-OpenAI providers are unaffected.

### Account Selection Strategies

Expand Down Expand Up @@ -126,6 +146,11 @@ All accounts are pooled - when one person's account is rate limited, the plugin

## Account Management

### Set the Default Account
```bash
multiauth -d user@example.com
```

### View Accounts
```bash
cat ~/.config/opencode/openai-accounts.json | jq '.accounts[] | {email, planType}'
Expand Down Expand Up @@ -185,6 +210,7 @@ npx -y opencode-openai-multi-auth@latest --uninstall
## Features

- **Multi-account rotation** - Add unlimited ChatGPT accounts, auto-rotate on rate limits
- **Manual default account** - Start OpenAI sessions with a selected account
- **Per-model rate tracking** - Each model's limits tracked separately per account
- **Toast notifications** - Visual feedback when accounts switch
- **OAuth authentication** - Same secure flow as official Codex CLI
Expand Down
30 changes: 27 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ CODEX_MODE=1 opencode run "task" # Temporarily enable

### Account Storage

Accounts are stored in `~/.config/opencode/openai-accounts.json`:
Accounts are stored in `~/.config/opencode/openai-accounts.json`. The optional `defaultAccountIndex` remains compatible with existing version-1 files:

```json
{
Expand All @@ -427,10 +427,13 @@ Accounts are stored in `~/.config/opencode/openai-accounts.json`:
"consecutiveFailures": 0
}
],
"activeAccountIndex": 0
"activeAccountIndex": 0,
"defaultAccountIndex": 0
}
```

The CLI resolves an email against the current account list and stores its numeric index. Removing the selected account clears the default; removing an earlier account decrements the index so it continues to identify the same account.

### Adding Multiple Accounts

```bash
Expand All @@ -443,10 +446,30 @@ opencode auth login
# Select "Add Another OpenAI Account"
```

### Selecting a Default Account

The package provides `multiauth` when installed globally or linked locally:

```bash
multiauth -d user@example.com
# Equivalent: multiauth --default user@example.com
```

Email matching trims whitespace, ignores case, and requires exactly one match. Unknown or ambiguous emails leave storage unchanged. Restart OpenCode after changing the default. Selecting another email replaces the default; there is no clear-default command.

### Runtime Selection Semantics

For each session, the first observable OpenAI request in a plugin process is the initialization boundary. The configured default is used when it is eligible for the requested model, overriding an existing persisted session binding once.

A default is ineligible when it is in global or model-specific cooldown, has at least three consecutive failures, or does not support the requested model. The configured account strategy selects a fallback in those cases. OpenCode exposes no `session.selected` hook, and non-OpenAI providers do not enter this path.

### Rate Limit Handling

- Per-model rate limits tracked separately
- Automatic rotation to next available account
- Cooldowns persisted before retry
- Automatic rotation to the next available account
- Session rebound to the fallback before retry
- Later requests in the same process and session stay on the fallback
- Toast notifications show rate limit status
- Accounts with 3+ consecutive failures are skipped

Expand All @@ -455,6 +478,7 @@ opencode auth login
- When OpenCode provides a `prompt_cache_key` (its session identifier), the plugin forwards it directly to Codex.
- The same value is sent via headers (`conversation_id`, `session_id`) and request body, reducing latency and token usage.
- The plugin does not synthesize a fallback key; hosts that omit `prompt_cache_key` will see uncached behaviour until they provide one.
- Requests without a key still prefer an eligible default but do not create a persisted session binding.
- No configuration needed—cache headers are injected during request transformation.

### Usage limit messaging
Expand Down
103 changes: 56 additions & 47 deletions docs/development/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,56 +60,59 @@ This document explains the technical design decisions, architecture, and impleme

## Multi-Account System

### AccountManager (`lib/accounts/manager.ts`)

The AccountManager handles multiple ChatGPT accounts with automatic rotation:
### Module Responsibilities

| Module | Responsibility |
|---|---|
| `index.ts` | Plugin entry point, session-aware selection, and retry handling |
| `lib/cli.ts` | `multiauth` command-line entry point |
| `lib/accounts/manager.ts` | Account storage, default resolution, strategies, cooldowns, and token refresh |
| `lib/accounts/types.ts` | Account and storage contracts |
| `lib/session-bindings.ts` | Persistent session-key to account-index bindings |
| `lib/secure-file.ts` | Secure JSON persistence |

```mermaid
flowchart TD
CLI[lib/cli.ts] --> Manager[lib/accounts/manager.ts]
Plugin[index.ts] --> AccountIndex[lib/accounts/index.ts]
AccountIndex --> Manager
Plugin --> Bindings[lib/session-bindings.ts]
Manager --> Auth[lib/auth/auth.ts]
Manager --> Types[lib/accounts/types.ts]
Manager --> Secure[lib/secure-file.ts]
Bindings --> Secure
Plugin --> Request[lib/request/fetch-helpers.ts]
```

```typescript
class AccountManager {
// Core state
private accounts: ManagedAccount[] = [];
private activeIndex = 0;
private config: MultiAccountConfig;

// Key methods
async loadFromDisk(): Promise<void> // Load accounts from JSON
async importFromOpenCodeAuth(): Promise<void> // Import from legacy auth
async addAccount(...): Promise<ManagedAccount> // Add new account
async getNextAvailableAccount(model?): Promise<ManagedAccount | null>
markRateLimited(account, retryAfterMs, model?)
async ensureValidToken(account): Promise<boolean>
}
### Default and Fallback Flow

```mermaid
flowchart TD
Request[OpenAI request] --> Key[Extract model and prompt_cache_key]
Key --> First{First use of this key in process?}
First -->|Yes| Default[Get eligible default]
Default -->|Found| BindDefault[Bind session to default]
Default -->|Unavailable| Strategy[Run new-session strategy]
Strategy --> BindSelected[Bind selected account]
First -->|No| Existing[Read existing binding]
Existing --> Execute[Execute request]
BindDefault --> Execute
BindSelected --> Execute
Execute --> Status{Response}
Status -->|Success| Return[Return response]
Status -->|429| Cooldown[Persist cooldown]
Cooldown --> Fallback[Select account excluding tried indexes]
Fallback --> Rebind[Rebind session before retry]
Rebind --> Execute
```

### Account Selection Flow
The first observable OpenAI request initializes a session because OpenCode exposes no `session.selected` hook. A process-local set ensures that a session rebound after a 429 does not immediately return to its default.

```
1. Request comes in with model name
├─▶ getNextAvailableAccount(model)
│ │
│ ├─▶ Check current account availability
│ │ ├─ consecutiveFailures < 3?
│ │ ├─ globalRateLimitReset expired?
│ │ └─ perModelRateLimit[model] expired?
│ │
│ ├─▶ If available: use current account
│ │
│ └─▶ If not: try next accounts in order
│ │
│ └─▶ If all rate limited: return least-limited
├─▶ ensureValidToken(account)
│ │
│ ├─▶ Check expiration (5 min proactive refresh)
│ └─▶ Refresh if needed
└─▶ executeRequest(account, input, init)
├─▶ On 429: markRateLimited() + try next account
├─▶ On 401: markRefreshFailed() + try next account
└─▶ On success: return response
```
### Default Account Contract

`AccountManager.setDefaultAccount(email)` trims and compares email addresses case-insensitively, requires exactly one match, and persists the matching numeric account index. Unknown or ambiguous input does not modify storage.

`AccountManager.getDefaultAccount(model)` returns `null` when no default is configured or when the configured account is cooling down, has failed at least three times, or does not support the requested model.

### Account Storage Format

Expand Down Expand Up @@ -137,10 +140,16 @@ class AccountManager {
"consecutiveFailures": 0
}
],
"activeAccountIndex": 0
"activeAccountIndex": 0,
"roundRobinCursor": 1,
"defaultAccountIndex": 0
}
```

`defaultAccountIndex` is optional, so existing version-1 files remain valid. Removing the selected account clears the default; removing an earlier account decrements the index.

On a `429`, `executeRequest()` records and persists the cooldown before selecting another account. It updates the session binding before recursively retrying, keeping later requests on that fallback for the current process/session.

### Environment Variables

| Variable | Description | Default |
Expand Down
Loading