From 717e36a8fabe7698612d06f80190a176d7825cfc Mon Sep 17 00:00:00 2001 From: "FarisZR (agent)" <35614734+FarisZR@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:22:53 +0100 Subject: [PATCH 1/3] fix: retry model-unavailable requests on supported accounts --- src/accounts/manager.ts | 39 ++++-- src/fetch/copilot-fetch.ts | 248 +++++++++++++++++++++++-------------- src/models/availability.ts | 31 ++++- src/observe/usage.ts | 8 +- test/accounts.test.ts | 28 +++++ test/availability.test.ts | 12 ++ test/fetch.test.ts | 125 ++++++++++++++++++- 7 files changed, 374 insertions(+), 117 deletions(-) diff --git a/src/accounts/manager.ts b/src/accounts/manager.ts index e2bf7fa..8eb6520 100644 --- a/src/accounts/manager.ts +++ b/src/accounts/manager.ts @@ -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 = new Set() + ) { + 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; @@ -167,15 +186,13 @@ export class CopilotAccountManager { }; } - selectAccount(modelId: string, host: string): AccountSelection | null { + selectAccount( + modelId: string, + host: string, + excludedAccountIds: Set = 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; diff --git a/src/fetch/copilot-fetch.ts b/src/fetch/copilot-fetch.ts index d0b8495..da22202 100644 --- a/src/fetch/copilot-fetch.ts +++ b/src/fetch/copilot-fetch.ts @@ -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; @@ -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}`); @@ -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); +} + 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`, { @@ -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(); + 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> + ) => { + 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>, + 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; + } }; } diff --git a/src/models/availability.ts b/src/models/availability.ts index c336e7f..8bb3242 100644 --- a/src/models/availability.ts +++ b/src/models/availability.ts @@ -1,7 +1,8 @@ import type { CopilotAccount } from '../accounts/types.ts'; type CacheEntry = { - models: string[]; + models: string[] | null; + unsupportedModels: string[]; expiresAt: number; }; @@ -20,16 +21,40 @@ export class ModelAvailabilityCache { return entry.models; } + isUnsupported(account: CopilotAccount, modelId: string): boolean { + const entry = this.cache.get(account.id); + if (!entry) return false; + if (entry.expiresAt < Date.now()) { + this.cache.delete(account.id); + return false; + } + return entry.unsupportedModels.includes(modelId); + } + set(account: CopilotAccount, models: string[]) { this.cache.set(account.id, { models, + unsupportedModels: [], expiresAt: Date.now() + this.config.modelCacheTtlMs, }); } markUnsupported(account: CopilotAccount, modelId: string) { const entry = this.cache.get(account.id); - if (!entry) return; - entry.models = entry.models.filter((model) => model !== modelId); + if (!entry || entry.expiresAt < Date.now()) { + this.cache.set(account.id, { + models: null, + unsupportedModels: [modelId], + expiresAt: Date.now() + this.config.modelCacheTtlMs, + }); + return; + } + + if (entry.models) { + entry.models = entry.models.filter((model) => model !== modelId); + } + if (!entry.unsupportedModels.includes(modelId)) { + entry.unsupportedModels.push(modelId); + } } } diff --git a/src/observe/usage.ts b/src/observe/usage.ts index a81d6f4..8b1698f 100644 --- a/src/observe/usage.ts +++ b/src/observe/usage.ts @@ -8,12 +8,13 @@ export type UsageNotifier = { account: CopilotAccount, modelId: string, reason: string, + message?: string ) => Promise; }; export function createUsageNotifier( client: OpencodeClient, - config: CopilotMultiConfig, + config: CopilotMultiConfig ): UsageNotifier { const log = createLogger('usage'); let lastToastAt = 0; @@ -38,15 +39,16 @@ export function createUsageNotifier( }; return { - async accountSelected(account, modelId, reason) { + async accountSelected(account, modelId, reason, message) { if (config.visibility.log) { log.debug('account selected', { label: account.label, modelId, reason, + message, }); } - await maybeToast(`Copilot: ${account.label}`); + await maybeToast(message ?? `Copilot: ${account.label}`); }, }; } diff --git a/test/accounts.test.ts b/test/accounts.test.ts index 14dedd9..91dcf45 100644 --- a/test/accounts.test.ts +++ b/test/accounts.test.ts @@ -43,4 +43,32 @@ describe('CopilotAccountManager', () => { const selection = manager.selectAccount('claude-3', 'github.com'); expect(selection).toBeNull(); }); + + it('remembers unsupported models for unknown accounts without blocking other models', async () => { + const manager = await CopilotAccountManager.load(config, notifier); + await manager.addAccount({ + label: 'work', + host: 'github.com', + refresh: 'work-refresh', + access: 'work-access', + expires: 0, + }); + await manager.addAccount({ + label: 'personal', + host: 'github.com', + refresh: 'personal-refresh', + access: 'personal-access', + expires: 0, + models: ['gpt-5.4'], + }); + + const work = manager.listAccounts().find((account) => account.label === 'work'); + expect(work).toBeDefined(); + + await manager.markModelUnsupported(work!.id, 'gpt-5.4'); + + expect(manager.isAccountEligible(work!, 'gpt-5.4', 'github.com')).toBe(false); + expect(manager.isAccountEligible(work!, 'gpt-4.1', 'github.com')).toBe(true); + expect(manager.selectAccount('gpt-5.4', 'github.com')?.account.label).toBe('personal'); + }); }); diff --git a/test/availability.test.ts b/test/availability.test.ts index 6b9867f..c3a5f28 100644 --- a/test/availability.test.ts +++ b/test/availability.test.ts @@ -29,4 +29,16 @@ describe('ModelAvailabilityCache', () => { await new Promise((resolve) => setTimeout(resolve, 80)); expect(cache.get(account)).toBeNull(); }); + + it('expires unsupported model markers for unknown accounts', async () => { + const cache = new ModelAvailabilityCache(config); + cache.markUnsupported(account, 'gpt-5.4'); + + expect(cache.get(account)).toBeNull(); + expect(cache.isUnsupported(account, 'gpt-5.4')).toBe(true); + + await new Promise((resolve) => setTimeout(resolve, 80)); + + expect(cache.isUnsupported(account, 'gpt-5.4')).toBe(false); + }); }); diff --git a/test/fetch.test.ts b/test/fetch.test.ts index 4ce398f..9f2a67b 100644 --- a/test/fetch.test.ts +++ b/test/fetch.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createCopilotFetch } from '../src/fetch/copilot-fetch.ts'; import type { CopilotMultiConfig } from '../src/config/load.ts'; import { CopilotAccountManager } from '../src/accounts/manager.ts'; @@ -12,16 +12,129 @@ const config: CopilotMultiConfig = { }; const notifier = { - accountSelected: async () => undefined, + accountSelected: vi.fn(async () => undefined), }; +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.clearAllMocks(); +}); + +function createRequest(modelId: string) { + return { + method: 'POST', + headers: { + 'x-initiator': 'agent', + }, + body: JSON.stringify({ + model: modelId, + messages: [{ role: 'user', content: 'hi' }], + }), + }; +} + +function getAuthorizationHeader(init: unknown) { + const headers = (init as RequestInit | undefined)?.headers; + return new Headers(headers).get('authorization'); +} + describe('createCopilotFetch', () => { it('throws when no eligible account', async () => { const manager = await CopilotAccountManager.load(config, notifier); const fetcher = createCopilotFetch({ config, manager, notifier }); - await expect(fetcher('https://copilot-api.github.com/v1/chat/completions', { - method: 'POST', - body: JSON.stringify({ model: 'gpt-5-mini', messages: [{ role: 'user', content: 'hi' }] }), - })).rejects.toThrow('No eligible Copilot accounts'); + await expect( + fetcher('https://copilot-api.github.com/v1/chat/completions', createRequest('gpt-5-mini')) + ).rejects.toThrow('No eligible Copilot accounts'); + }); + + it('retries once on model-unavailable responses and sticks to the supporting account', async () => { + const manager = await CopilotAccountManager.load(config, notifier); + await manager.addAccount({ + label: 'work', + host: 'github.com', + refresh: 'work-refresh', + access: 'work-access', + expires: 0, + }); + await manager.addAccount({ + label: 'personal', + host: 'github.com', + refresh: 'personal-refresh', + access: 'personal-access', + expires: 0, + models: ['gpt-5.4'], + }); + + const fetchSpy = vi + .fn() + .mockResolvedValueOnce( + new Response('Model gpt-5.4 is not supported on this account', { status: 404 }) + ) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const fetcher = createCopilotFetch({ config, manager, notifier }); + const request = createRequest('gpt-5.4'); + + const firstResponse = await fetcher( + 'https://copilot-api.github.com/v1/chat/completions', + request + ); + expect(firstResponse.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(2); + + expect(getAuthorizationHeader(fetchSpy.mock.calls[0]?.[1])).toBe('Bearer work-access'); + expect(getAuthorizationHeader(fetchSpy.mock.calls[1]?.[1])).toBe('Bearer personal-access'); + + expect(notifier.accountSelected).toHaveBeenCalledWith( + expect.objectContaining({ label: 'personal' }), + 'gpt-5.4', + 'fallback', + 'Copilot: sticking to personal for gpt-5.4; work does not support that model' + ); + + const secondResponse = await fetcher( + 'https://copilot-api.github.com/v1/chat/completions', + request + ); + expect(secondResponse.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(3); + + expect(getAuthorizationHeader(fetchSpy.mock.calls[2]?.[1])).toBe('Bearer personal-access'); + }); + + it('does not retry on unrelated 404 responses', async () => { + const manager = await CopilotAccountManager.load(config, notifier); + await manager.addAccount({ + label: 'work', + host: 'github.com', + refresh: 'work-refresh', + access: 'work-access', + expires: 0, + }); + await manager.addAccount({ + label: 'personal', + host: 'github.com', + refresh: 'personal-refresh', + access: 'personal-access', + expires: 0, + }); + + const fetchSpy = vi + .fn() + .mockResolvedValueOnce(new Response('Route not found', { status: 404 })); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const fetcher = createCopilotFetch({ config, manager, notifier }); + const response = await fetcher( + 'https://copilot-api.github.com/v1/chat/completions', + createRequest('gpt-5.4') + ); + + expect(response.status).toBe(404); + expect(fetchSpy).toHaveBeenCalledTimes(1); }); }); From 6b82a25ae7f23d4d848a98e925460d04faad16ef Mon Sep 17 00:00:00 2001 From: "FarisZR (agent)" <35614734+FarisZR@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:24:58 +0100 Subject: [PATCH 2/3] docs: describe model-aware fallback behavior --- README.md | 7 +++++++ docs/ARCHITECTURE.md | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dbd6d92..c789be8 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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`: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e25354d..97a1fff 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. From e72eada7f90572e532c6150c81b56debaea83179 Mon Sep 17 00:00:00 2001 From: "FarisZR (agent)" <35614734+FarisZR@users.noreply.github.com> Date: Fri, 6 Mar 2026 21:11:04 +0100 Subject: [PATCH 3/3] fix: tighten model availability handling --- src/accounts/manager.ts | 6 +-- src/fetch/copilot-fetch.ts | 21 +++++--- test/accounts.test.ts | 36 ++++++++++++++ test/fetch.test.ts | 99 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 12 deletions(-) diff --git a/src/accounts/manager.ts b/src/accounts/manager.ts index 8eb6520..908cdfe 100644 --- a/src/accounts/manager.ts +++ b/src/accounts/manager.ts @@ -118,9 +118,6 @@ export class CopilotAccountManager { async markModelUnsupported(id: string, model: string) { const account = this.accounts.find((item) => item.id === id); if (!account) return; - if (Array.isArray(account.models)) { - account.models = account.models.filter((item) => item !== model); - } this.availability.markUnsupported(account, model); await this.persist(); } @@ -139,7 +136,8 @@ export class CopilotAccountManager { const cachedModels = this.availability.get(account); const models = cachedModels ?? account.models ?? null; - if (!models || models.length === 0) return true; + if (!models) return true; + if (models.length === 0) return false; return models.includes(modelId); } diff --git a/src/fetch/copilot-fetch.ts b/src/fetch/copilot-fetch.ts index da22202..8e211a4 100644 --- a/src/fetch/copilot-fetch.ts +++ b/src/fetch/copilot-fetch.ts @@ -147,7 +147,7 @@ function getRetryAfter(response: Response, fallback: number) { return fallback; } -function isModelUnavailableBody(bodyText: string, modelId: string) { +function isModelUnavailableBody(bodyText: string, modelId: string): boolean { const normalized = bodyText.toLowerCase(); const mentionsModel = normalized.includes('model') || @@ -166,8 +166,8 @@ function isModelUnavailableBody(bodyText: string, modelId: string) { ].some((phrase) => normalized.includes(phrase)); } -async function isModelUnavailableResponse(response: Response, modelId: string) { - if (response.status !== 400 && response.status !== 404) return false; +async function isModelUnavailableResponse(response: Response, modelId: string): Promise { + if (response.status !== 400 && response.status !== 403 && response.status !== 404) return false; const bodyText = await response .clone() .text() @@ -223,17 +223,19 @@ export function createCopilotFetch({ config, manager, notifier }: FetchDeps) { const attemptedAccountIds = new Set(); const resolvedParsed = { ...parsed, isAgent }; - const updateHostLock = (accountId: string) => { + const updateHostLock = (accountId: string): void => { const previous = lockByHost.get(host); + const nextAccountId = + !isAgent && agentRecentlyActive && previous ? previous.accountId : accountId; lockByHost.set(host, { - accountId, + accountId: nextAccountId, lastAgentAt: isAgent ? Date.now() : (previous?.lastAgentAt ?? 0), }); }; const prepareSelection = async ( selection: NonNullable> - ) => { + ): Promise>> => { updateHostLock(selection.account.id); if (selection.account.expires > 0 && selection.account.expires < Date.now()) { @@ -258,14 +260,17 @@ export function createCopilotFetch({ config, manager, notifier }: FetchDeps) { nextAccountLabel: string, previousAccountLabel: string, message: string - ) => { + ): string => { return `Copilot: sticking to ${nextAccountLabel} for ${modelId}; ${previousAccountLabel} ${message}`; }; const selectFallback = ( previousSelection: NonNullable>, message: string - ) => { + ): { + fallback: NonNullable>; + message: string; + } | null => { const fallback = manager.selectAccount(modelId, host, attemptedAccountIds); if (!fallback) return null; return { diff --git a/test/accounts.test.ts b/test/accounts.test.ts index 91dcf45..a66e29b 100644 --- a/test/accounts.test.ts +++ b/test/accounts.test.ts @@ -71,4 +71,40 @@ describe('CopilotAccountManager', () => { expect(manager.isAccountEligible(work!, 'gpt-4.1', 'github.com')).toBe(true); expect(manager.selectAccount('gpt-5.4', 'github.com')?.account.label).toBe('personal'); }); + + it('keeps persisted model lists unchanged when marking a model unsupported', async () => { + const manager = await CopilotAccountManager.load(config, notifier); + await manager.addAccount({ + label: 'personal', + host: 'github.com', + refresh: 'personal-refresh', + access: 'personal-access', + expires: 0, + models: ['gpt-5.4'], + }); + + const personal = manager.listAccounts()[0]; + expect(personal?.models).toEqual(['gpt-5.4']); + + await manager.markModelUnsupported(personal!.id, 'gpt-5.4'); + + expect(manager.listAccounts()[0]?.models).toEqual(['gpt-5.4']); + expect(manager.isAccountEligible(personal!, 'gpt-5.4', 'github.com')).toBe(false); + }); + + it('treats an explicit empty model list as supports nothing', async () => { + const manager = await CopilotAccountManager.load(config, notifier); + await manager.addAccount({ + label: 'empty', + host: 'github.com', + refresh: 'empty-refresh', + access: 'empty-access', + expires: 0, + models: [], + }); + + const empty = manager.listAccounts()[0]; + expect(manager.isAccountEligible(empty!, 'gpt-5.4', 'github.com')).toBe(false); + expect(manager.selectAccount('gpt-5.4', 'github.com')).toBeNull(); + }); }); diff --git a/test/fetch.test.ts b/test/fetch.test.ts index 9f2a67b..72d9e31 100644 --- a/test/fetch.test.ts +++ b/test/fetch.test.ts @@ -35,6 +35,19 @@ function createRequest(modelId: string) { }; } +function createUserRequest(modelId: string) { + return { + method: 'POST', + headers: { + 'x-initiator': 'user', + }, + body: JSON.stringify({ + model: modelId, + messages: [{ role: 'user', content: 'hi' }], + }), + }; +} + function getAuthorizationHeader(init: unknown) { const headers = (init as RequestInit | undefined)?.headers; return new Headers(headers).get('authorization'); @@ -137,4 +150,90 @@ describe('createCopilotFetch', () => { expect(response.status).toBe(404); expect(fetchSpy).toHaveBeenCalledTimes(1); }); + + it('treats 403 model-unavailable responses as model fallback, not auth failure', async () => { + const manager = await CopilotAccountManager.load(config, notifier); + await manager.addAccount({ + label: 'work', + host: 'github.com', + refresh: 'work-refresh', + access: 'work-access', + expires: 0, + }); + await manager.addAccount({ + label: 'personal', + host: 'github.com', + refresh: 'personal-refresh', + access: 'personal-access', + expires: 0, + models: ['gpt-5.4'], + }); + + const fetchSpy = vi + .fn() + .mockResolvedValueOnce( + new Response('No access to model gpt-5.4 on this account', { status: 403 }) + ) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const fetcher = createCopilotFetch({ config, manager, notifier }); + const response = await fetcher( + 'https://copilot-api.github.com/v1/chat/completions', + createRequest('gpt-5.4') + ); + + expect(response.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(getAuthorizationHeader(fetchSpy.mock.calls[1]?.[1])).toBe('Bearer personal-access'); + expect(notifier.accountSelected).toHaveBeenCalledWith( + expect.objectContaining({ label: 'personal' }), + 'gpt-5.4', + 'fallback', + 'Copilot: sticking to personal for gpt-5.4; work does not support that model' + ); + }); + + it('keeps the recent agent lock when a user request hits another account', async () => { + const manager = await CopilotAccountManager.load(config, notifier); + await manager.addAccount({ + label: 'work', + host: 'github.com', + refresh: 'work-refresh', + access: 'work-access', + expires: 0, + }); + await manager.addAccount({ + label: 'personal', + host: 'github.com', + refresh: 'personal-refresh', + access: 'personal-access', + expires: 0, + models: ['gpt-5.4'], + }); + + const fetchSpy = vi + .fn() + .mockResolvedValueOnce( + new Response('Model gpt-5.4 is not supported on this account', { status: 404 }) + ) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 })); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + + const fetcher = createCopilotFetch({ config, manager, notifier }); + + await fetcher('https://copilot-api.github.com/v1/chat/completions', createRequest('gpt-5.4')); + await fetcher( + 'https://copilot-api.github.com/v1/chat/completions', + createUserRequest('gpt-4.1') + ); + await fetcher('https://copilot-api.github.com/v1/chat/completions', createRequest('gpt-5.4')); + + expect(getAuthorizationHeader(fetchSpy.mock.calls[0]?.[1])).toBe('Bearer work-access'); + expect(getAuthorizationHeader(fetchSpy.mock.calls[1]?.[1])).toBe('Bearer personal-access'); + expect(getAuthorizationHeader(fetchSpy.mock.calls[2]?.[1])).toBe('Bearer work-access'); + expect(getAuthorizationHeader(fetchSpy.mock.calls[3]?.[1])).toBe('Bearer personal-access'); + }); });