Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Multi-account GitHub Copilot load balancing for OpenCode. Routes requests across
- Hybrid load-balancing with cooldowns and fallback
- Per-request account attribution (toast, log, header)
- Model availability cache with lazy detection
- Automatic same-request fallback when a model is only available on some accounts

## Install

Expand Down Expand Up @@ -55,6 +56,12 @@ Select **Manage Accounts** from the login menu to:

You can add multiple GitHub.com or Enterprise accounts by running `opencode auth login` and selecting the appropriate login method. The plugin will load-balance requests across all enabled accounts that support the requested model.

### Model-Aware Fallback

When a model is available on only some accounts, the plugin still tries the currently selected account first. If GitHub Copilot responds that the model is unavailable on that account, the plugin marks that account as unsupported for that model, retries the same request against another eligible account, and sticks to the working account for follow-up requests.

For agent requests, the toast/log message explains why the plugin stayed on the fallback account.

## Configuration

Create `~/.config/opencode/copilot-multi.json` or `.opencode/copilot-multi.json`:
Expand Down
5 changes: 3 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,21 @@ A custom `fetch` implementation injected into the OpenCode auth hook.
- **Interception**: It parses the request body to identify the model ID.
- **Routing**: It calls the Account Manager to select an account and updates the `Authorization` header with that account's access token.
- **Retries**: If a request fails with a rate limit, it automatically tries a different eligible account (if available).
- **Model Fallback**: If Copilot says a model is unavailable on the chosen account, it marks that account as unsupported for that model and retries the same request on another eligible account.
- **Token Refresh**: Automatically handles OAuth token refreshing before making requests.

### 3. Model Availability Cache (`src/models/availability.ts`)

Tracks which models are supported by which accounts.

- **Lazy Detection**: If an account returns a 404/400 indicating a model is not found, that model is marked as unsupported for that specific account.
- **Lazy Detection**: If an account returns a 404/400 indicating a model is not available there, that model is marked as unsupported for that specific account.
- **Filtering**: Future requests for that model will skip the unsupported account.

### 4. Observability (`src/observe/usage.ts`)

Provides feedback on account usage.

- **Toasts**: Shows a transient UI notification when an agent call is made, identifying the account label.
- **Toasts**: Shows a transient UI notification when an agent call is made, identifying the account label and fallback reason when the plugin has to stay on a different account.
- **Structured Logs**: Emits DEBUG level logs via the OpenCode TUI logging system, including the model ID and selection reason.
- **Headers**: Optionally attaches `x-opencode-copilot-account` to outgoing requests for external debugging.

Expand Down
39 changes: 28 additions & 11 deletions src/accounts/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,32 @@ export class CopilotAccountManager {
async markModelUnsupported(id: string, model: string) {
const account = this.accounts.find((item) => item.id === id);
if (!account) return;
account.models = Array.isArray(account.models)
? account.models.filter((item) => item !== model)
: [];
if (Array.isArray(account.models)) {
account.models = account.models.filter((item) => item !== model);
}
this.availability.markUnsupported(account, model);
await this.persist();
}

isAccountEligible(
account: CopilotAccount,
modelId: string,
host: string,
excludedAccountIds: Set<string> = new Set()
) {
Comment on lines +125 to +130
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add explicit return types to the new public methods.

isAccountEligible() and the widened selectAccount() signature are now part of the manager’s public surface, but both still rely on inference. Please annotate them explicitly (: boolean and : AccountSelection | null) so future edits cannot silently widen the API.

♻️ Proposed fix
   isAccountEligible(
     account: CopilotAccount,
     modelId: string,
     host: string,
     excludedAccountIds: Set<string> = new Set()
-  ) {
+  ): boolean {
   selectAccount(
     modelId: string,
     host: string,
     excludedAccountIds: Set<string> = new Set()
-  ): AccountSelection | null {
+  ): AccountSelection | null {

As per coding guidelines, "Prefer explicit type annotations over inference".

Also applies to: 187-191

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/accounts/manager.ts` around lines 125 - 130, The new public methods lack
explicit return types: add an explicit ": boolean" return type to
isAccountEligible(account: CopilotAccount, modelId: string, host: string,
excludedAccountIds: Set<string> = new Set()) and annotate selectAccount(...)
with ": AccountSelection | null" (the widened signature around selectAccount in
the block roughly corresponding to lines 187-191) so the manager's public API is
not inferred; update both function signatures accordingly and ensure any
helper/internal functions keep their existing types.

if (excludedAccountIds.has(account.id)) return false;
if (!account.enabled) return false;
if (account.host !== host) return false;
if (account.cooldownUntil && account.cooldownUntil > Date.now()) return false;
if (this.availability.isUnsupported(account, modelId)) return false;

const cachedModels = this.availability.get(account);
const models = cachedModels ?? account.models ?? null;
if (!models || models.length === 0) return true;

return models.includes(modelId);
}

async markFailure(id: string, cooldownMs: number) {
const account = this.accounts.find((item) => item.id === id);
if (!account) return;
Expand Down Expand Up @@ -167,15 +186,13 @@ export class CopilotAccountManager {
};
}

selectAccount(modelId: string, host: string): AccountSelection | null {
selectAccount(
modelId: string,
host: string,
excludedAccountIds: Set<string> = new Set()
): AccountSelection | null {
const eligible = this.accounts.filter((account) => {
if (!account.enabled) return false;
if (account.host !== host) return false;
if (account.cooldownUntil && account.cooldownUntil > Date.now()) return false;
const cached = this.availability.get(account);
const models = cached ?? account.models;
if (!models || models.length === 0) return true;
return models.includes(modelId);
return this.isAccountEligible(account, modelId, host, excludedAccountIds);
});

if (eligible.length === 0) return null;
Expand Down
248 changes: 154 additions & 94 deletions src/fetch/copilot-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ function sanitizeCopilotBody(body?: string): string | undefined {

function getHeaderValue(headers: HeadersInit | undefined, key: string): string | undefined {
if (!headers) return undefined;
if (headers instanceof Headers) return headers.get(key) ?? headers.get(key.toLowerCase()) ?? undefined;
if (headers instanceof Headers)
return headers.get(key) ?? headers.get(key.toLowerCase()) ?? undefined;
if (Array.isArray(headers)) {
const found = headers.find(([name]) => name.toLowerCase() === key.toLowerCase());
return found ? found[1] : undefined;
Expand All @@ -121,20 +122,6 @@ function getInitiator(headers: HeadersInit | undefined): Initiator {
return undefined;
}

function isAccountEligible(
account: { enabled: boolean; host: string; cooldownUntil?: number; models?: string[] },
modelId: string,
host: string,
) {
if (!account.enabled) return false;
if (account.host !== host) return false;
if (account.cooldownUntil && account.cooldownUntil > Date.now()) return false;
if (Array.isArray(account.models) && account.models.length > 0) {
return account.models.includes(modelId);
}
return true;
}

function buildHeaders(base: HeadersInit | undefined, auth: string, parsed: ParsedRequest) {
const headers = new Headers(base);
headers.set('authorization', `Bearer ${auth}`);
Expand All @@ -160,6 +147,34 @@ function getRetryAfter(response: Response, fallback: number) {
return fallback;
}

function isModelUnavailableBody(bodyText: string, modelId: string) {
const normalized = bodyText.toLowerCase();
const mentionsModel =
normalized.includes('model') ||
(modelId !== 'unknown' && normalized.includes(modelId.toLowerCase()));

if (!mentionsModel) return false;

return [
'not found',
'does not exist',
'not available',
'not supported',
'unsupported',
'no access to model',
'access to this model',
].some((phrase) => normalized.includes(phrase));
}

async function isModelUnavailableResponse(response: Response, modelId: string) {
if (response.status !== 400 && response.status !== 404) return false;
const bodyText = await response
.clone()
.text()
.catch(() => '');
return isModelUnavailableBody(bodyText, modelId);
}
Comment on lines +150 to +176
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Tighten the model-unavailable classifier before caching the miss.

Right now any 400/403/404 body that contains the word model and a generic phrase like not found or not available returns true. That will misclassify unrelated errors such as {"error":"not found","model":"gpt-5.4"} and then markModelUnsupported() will wrongly exclude that account for the TTL window. Please require either the requested model id or a stronger model-specific phrase before treating the response as a model miss.

💡 Narrow the heuristic
 function isModelUnavailableBody(bodyText: string, modelId: string): boolean {
   const normalized = bodyText.toLowerCase();
-  const mentionsModel =
-    normalized.includes('model') ||
-    (modelId !== 'unknown' && normalized.includes(modelId.toLowerCase()));
+  const mentionsRequestedModel =
+    modelId !== 'unknown' && normalized.includes(modelId.toLowerCase());
+  const mentionsModel = normalized.includes('model') || mentionsRequestedModel;
 
-  if (!mentionsModel) return false;
+  if (!mentionsModel) return false;
+
+  const strongModelSpecificMatch = [
+    'not supported',
+    'unsupported',
+    'no access to model',
+    'access to this model',
+  ].some((phrase) => normalized.includes(phrase));
+  if (strongModelSpecificMatch) return true;
 
   return [
     'not found',
     'does not exist',
     'not available',
-    'not supported',
-    'unsupported',
-    'no access to model',
-    'access to this model',
-  ].some((phrase) => normalized.includes(phrase));
+  ].some((phrase) => mentionsRequestedModel && normalized.includes(phrase));
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fetch/copilot-fetch.ts` around lines 150 - 176, The current heuristic in
isModelUnavailableBody is too permissive; change it so we only classify a miss
when the body either (A) contains the requested modelId (modelId !== 'unknown')
together with one of the error phrases, or (B) contains a stronger
model-specific phrase where "model" appears adjacent to the phrase (e.g. "model
not found", "model is not available", "model unsupported") — implement this by
replacing the simple includes checks with: require modelId presence plus phrase,
or test a regex that matches /model.{0,10}(not found|does not exist|not
available|unsupported|no access)/ (case-insensitive). Keep
isModelUnavailableResponse behavior but call the tightened
isModelUnavailableBody; this prevents markModelUnsupported() from caching
unrelated 400/403/404 errors that merely mention "model" or generic "not found".


async function refreshToken(host: string, refresh: string) {
const domain = host === 'github.com' ? 'github.com' : host;
const response = await fetch(`https://${domain}/login/oauth/access_token`, {
Expand Down Expand Up @@ -203,105 +218,150 @@ export function createCopilotFetch({ config, manager, notifier }: FetchDeps) {
const now = Date.now();
const lock = lockByHost.get(host);
const agentRecentlyActive = Boolean(
lock?.lastAgentAt && now - lock.lastAgentAt < AGENT_IDLE_TIMEOUT_MS,
lock?.lastAgentAt && now - lock.lastAgentAt < AGENT_IDLE_TIMEOUT_MS
);
const attemptedAccountIds = new Set<string>();
const resolvedParsed = { ...parsed, isAgent };

const updateHostLock = (accountId: string) => {
const previous = lockByHost.get(host);
lockByHost.set(host, {
accountId,
lastAgentAt: isAgent ? Date.now() : (previous?.lastAgentAt ?? 0),
});
};

const prepareSelection = async (
selection: NonNullable<ReturnType<typeof manager.selectAccount>>
) => {
updateHostLock(selection.account.id);

if (selection.account.expires > 0 && selection.account.expires < Date.now()) {
const refreshed = await refreshToken(host, selection.account.refresh);
if (refreshed) {
await manager.updateAccountTokens(
selection.account.id,
refreshed.access,
refreshed.refresh,
refreshed.expires
);
selection.account.access = refreshed.access;
selection.account.refresh = refreshed.refresh;
selection.account.expires = refreshed.expires;
}
}

return selection;
};

const buildFallbackMessage = (
nextAccountLabel: string,
previousAccountLabel: string,
message: string
) => {
return `Copilot: sticking to ${nextAccountLabel} for ${modelId}; ${previousAccountLabel} ${message}`;
};

const selectFallback = (
previousSelection: NonNullable<ReturnType<typeof manager.selectAccount>>,
message: string
) => {
const fallback = manager.selectAccount(modelId, host, attemptedAccountIds);
if (!fallback) return null;
return {
fallback,
message: buildFallbackMessage(
fallback.account.label,
previousSelection.account.label,
message
),
};
};

let selection = null;
if (lock && (isAgent || agentRecentlyActive)) {
const locked = manager
.listAccounts()
.find((account) => account.id === lock.accountId && isAccountEligible(account, modelId, host));
const locked = manager.listAccounts().find((account) => {
return account.id === lock.accountId && manager.isAccountEligible(account, modelId, host);
});
if (locked) {
selection = { account: locked, index: 0, reason: 'sticky' as const };
}
}

if (!selection) {
selection = manager.selectAccount(modelId, host);
selection = manager.selectAccount(modelId, host, attemptedAccountIds);
}
if (!selection) {
throw new Error(`No eligible Copilot accounts available for ${modelId}`);
}

lockByHost.set(host, {
accountId: selection.account.id,
lastAgentAt: isAgent ? now : lock?.lastAgentAt ?? 0,
});

if (selection.account.expires > 0 && selection.account.expires < Date.now()) {
const refreshed = await refreshToken(host, selection.account.refresh);
if (refreshed) {
await manager.updateAccountTokens(
selection.account.id,
refreshed.access,
refreshed.refresh,
refreshed.expires,
);
selection.account.access = refreshed.access;
selection.account.refresh = refreshed.refresh;
selection.account.expires = refreshed.expires;
let notificationMessage: string | undefined;

for (;;) {
attemptedAccountIds.add(selection.account.id);
const preparedSelection = await prepareSelection(selection);
const headers = buildHeaders(init?.headers, preparedSelection.account.access, resolvedParsed);
const sanitizedBody = sanitizeCopilotBody(init?.body);
const response = await fetch(request, {
...init,
body: sanitizedBody,
headers,
});

if (await isModelUnavailableResponse(response, modelId)) {
await manager.markModelUnsupported(preparedSelection.account.id, modelId);
log.warn('model unavailable on account', {
account: preparedSelection.account.label,
modelId,
});

const next = selectFallback(preparedSelection, 'does not support that model');
if (!next) return response;
selection = next.fallback;
notificationMessage = next.message;
continue;
}
}

if (isAgent) {
await manager.notifySelection(selection, modelId);
}
const resolvedParsed = { ...parsed, isAgent };
const headers = buildHeaders(init?.headers, selection.account.access, resolvedParsed);

const sanitizedBody = sanitizeCopilotBody(init?.body);
const response = await fetch(request, {
...init,
body: sanitizedBody,
headers,
});

if (response.status === 404 || response.status === 400) {
const bodyText = await response
.clone()
.text()
.catch(() => '');
if (
bodyText.toLowerCase().includes('model') &&
bodyText.toLowerCase().includes('not found')
) {
await manager.markModelUnsupported(selection.account.id, modelId);
if (response.status === 401 || response.status === 403) {
await manager.markFailure(preparedSelection.account.id, config.rateLimit.defaultBackoffMs);
log.warn('auth failure detected', { account: preparedSelection.account.label, modelId });

const next = selectFallback(preparedSelection, 'had an auth failure');
if (!next) return response;
selection = next.fallback;
notificationMessage = next.message;
continue;
}
}

if (response.status === 401 || response.status === 403) {
await manager.markFailure(selection.account.id, config.rateLimit.defaultBackoffMs);
log.warn('auth failure detected', { account: selection.account.label, modelId });
const fallback = manager.selectAccount(modelId, host);
if (!fallback) return response;
await notifier.accountSelected(fallback.account, modelId, 'fallback');
lockByHost.set(host, {
accountId: fallback.account.id,
lastAgentAt: isAgent ? Date.now() : lockByHost.get(host)?.lastAgentAt ?? 0,
});
const retryHeaders = buildHeaders(init?.headers, fallback.account.access, resolvedParsed);
return fetch(request, { ...init, headers: retryHeaders });
}
if (response.status === 429 || response.status === 503) {
const backoff = getRetryAfter(response, config.rateLimit.defaultBackoffMs);
await manager.markFailure(
preparedSelection.account.id,
Math.min(backoff, config.rateLimit.maxBackoffMs)
);
log.warn('rate limit detected', { account: preparedSelection.account.label, modelId });

if (response.status === 429 || response.status === 503) {
const backoff = getRetryAfter(response, config.rateLimit.defaultBackoffMs);
await manager.markFailure(
selection.account.id,
Math.min(backoff, config.rateLimit.maxBackoffMs),
);
log.warn('rate limit detected', { account: selection.account.label, modelId });
const fallback = manager.selectAccount(modelId, host);
if (!fallback) return response;
await notifier.accountSelected(fallback.account, modelId, 'fallback');
lockByHost.set(host, {
accountId: fallback.account.id,
lastAgentAt: isAgent ? Date.now() : lockByHost.get(host)?.lastAgentAt ?? 0,
});
const retryHeaders = buildHeaders(init?.headers, fallback.account.access, resolvedParsed);
return fetch(request, { ...init, headers: retryHeaders });
}
const next = selectFallback(preparedSelection, 'hit a cooldown-worthy rate limit');
if (!next) return response;
selection = next.fallback;
notificationMessage = next.message;
continue;
}

await manager.markSuccess(selection.account.id);
return response;
await manager.markSuccess(preparedSelection.account.id);
if (isAgent) {
if (notificationMessage) {
await notifier.accountSelected(
preparedSelection.account,
modelId,
'fallback',
notificationMessage
);
} else {
await manager.notifySelection(preparedSelection, modelId);
}
}
return response;
}
};
}
Loading
Loading