diff --git a/packages/core/src/__tests__/llm-connections.test.ts b/packages/core/src/__tests__/llm-connections.test.ts index 97cce94cb9..d18dc3cb0d 100644 --- a/packages/core/src/__tests__/llm-connections.test.ts +++ b/packages/core/src/__tests__/llm-connections.test.ts @@ -126,6 +126,38 @@ test('a fetch never deletes a choice the user made', () => { ); }); +test('an authoritative account catalog removes unavailable bootstrap and stale models', () => { + assert.deepEqual( + reconcileConnectionAfterModelFetch( + { + defaultModel: 'fallback-unavailable', + enabledModelIds: ['fallback-unavailable', 'account-available'], + hasModelInventory: false, + }, + [{ id: 'account-available' }, { id: 'newly-available' }], + { authoritative: true }, + ), + { + defaultModel: 'account-available', + enabledModelIds: ['account-available', 'newly-available'], + }, + ); + // Once an account inventory exists, a refresh removes withdrawn selections + // without automatically opting the user into newly introduced models. + assert.deepEqual( + reconcileConnectionAfterModelFetch( + { + defaultModel: 'account-available', + enabledModelIds: ['account-available', 'withdrawn'], + hasModelInventory: true, + }, + [{ id: 'account-available' }, { id: 'newly-available' }], + { authoritative: true }, + ), + { defaultModel: 'account-available', enabledModelIds: ['account-available'] }, + ); +}); + test('model reconciliation never invents a default the user cleared', () => { // Unchecking the default leaves a legitimate {no default, some enabled} // state. Repair had nothing to repair here, so it reached for "the first diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index b17ffff8ed..33fc7896d4 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -458,6 +458,11 @@ export function reconcileConnectionAfterModelFetch( * caller that knows the provider's naming supplies the table. */ readonly aliases?: Readonly>; + /** + * The provider guarantees this is the account's complete usable catalog. + * Missing ids are therefore unavailable, unlike ordinary partial snapshots. + */ + readonly authoritative?: boolean; }, ): { defaultModel: string; @@ -490,6 +495,22 @@ export function reconcileConnectionAfterModelFetch( ), ), ]; + if (options?.authoritative) { + // The first account-scoped fetch replaces the provider fallback guess: no + // user chose those bootstrap ids, and every usable model should be offered. + // Later refreshes preserve explicit user choices only while they remain in + // the account catalog; newly introduced models stay opt-in. + const enabledModelIds = connection.hasModelInventory + ? previousEnabled.filter((id) => live.has(id)) + : liveIds; + if (enabledModelIds.length === 0 && previousEnabled.length > 0 && liveIds.length > 0) { + enabledModelIds.push(liveIds[0]!); + } + const defaultModel = enabledModelIds.includes(previousDefault) + ? previousDefault + : (enabledModelIds[0] ?? ''); + return { defaultModel, enabledModelIds }; + } // Seed a first choice only for a connection that has never had a list to // pick from: four providers ship no `fallbackModels`, so for them discovery // is the only place a first default can come from. diff --git a/packages/runtime/src/__tests__/provider-contract-overrides.ts b/packages/runtime/src/__tests__/provider-contract-overrides.ts index ca7c049a2f..a3b1af6310 100644 --- a/packages/runtime/src/__tests__/provider-contract-overrides.ts +++ b/packages/runtime/src/__tests__/provider-contract-overrides.ts @@ -348,13 +348,21 @@ async function runGitHubCopilotDiscovery(): Promise { assert.equal(request.headers['x-github-api-version'], '2026-06-01'); respondJson(response, 200, { data: [ - copilotModel('gpt-5.4', ['/responses']), + { + ...copilotModel('gpt-5.4', ['/responses']), + // Current GitHub clients also accept models with no policy gate. + policy: undefined, + }, copilotModel('claude-sonnet-4.6', ['/v1/messages']), copilotModel('gemini-3.1-pro-preview', ['/chat/completions']), { ...copilotModel('disabled-by-policy', ['/chat/completions']), policy: { state: 'disabled' }, }, + { + ...copilotModel('policy-not-accepted', ['/chat/completions']), + policy: { state: 'unconfigured' }, + }, { ...copilotModel('hidden-from-picker', ['/chat/completions']), model_picker_enabled: false, diff --git a/packages/runtime/src/model-fetcher.ts b/packages/runtime/src/model-fetcher.ts index e886114c3e..f6db59b150 100644 --- a/packages/runtime/src/model-fetcher.ts +++ b/packages/runtime/src/model-fetcher.ts @@ -114,7 +114,7 @@ type RawGitHubCopilotModel = { name?: string; model_picker_enabled?: boolean; supported_endpoints?: string[]; - policy?: { state?: string }; + policy?: unknown; capabilities?: { limits?: { max_context_window_tokens?: number; @@ -523,7 +523,12 @@ function toGitHubCopilotModelInfo(model: RawGitHubCopilotModel): ModelInfo[] { typeof model.id !== 'string' || !model.id || model.model_picker_enabled !== true || - model.policy?.state === 'disabled' || + // GitHub historically returned enabled/disabled/unconfigured policy gates. + // A policy-free model needs no acknowledgement; when the gate is present, + // Maka can use the model only after another client has enabled it. Maka has + // no policy-acceptance flow, so fail closed over unconfigured and unknown + // states instead of advertising a model that inference will reject. + !isGitHubCopilotModelPolicyEnabled(model.policy) || model.capabilities?.supports?.tool_calls !== true ) return []; @@ -572,6 +577,12 @@ function toGitHubCopilotModelInfo(model: RawGitHubCopilotModel): ModelInfo[] { ]; } +function isGitHubCopilotModelPolicyEnabled(policy: unknown): boolean { + if (policy === undefined) return true; + if (!policy || typeof policy !== 'object' || Array.isArray(policy)) return false; + return (policy as Record).state === 'enabled'; +} + async function fetchCohereModels( baseUrl: string, apiKey: string, diff --git a/packages/storage/src/__tests__/runtime-policy-stores.test.ts b/packages/storage/src/__tests__/runtime-policy-stores.test.ts index dbc20f4ac0..2f5ef7450b 100644 --- a/packages/storage/src/__tests__/runtime-policy-stores.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-stores.test.ts @@ -1664,6 +1664,42 @@ describe('runtime policy stores', () => { }); }); + test('replaces Copilot bootstrap ids with the account-authorized model catalog', async () => { + await withInteractiveOwner(async ({ stores }) => { + const connection = await createConnection( + stores, + 0, + connectionDraft('copilot-models', 'github-copilot', 'Copilot models'), + ); + const configured = await stores.credentialVault.set({ + locator: connectionCredential(connection, 'oauth_token'), + expected: null, + secret: JSON.stringify({ + access_token: 'github-access', + refresh_token: 'github-refresh', + expires_at: Number.MAX_SAFE_INTEGER, + }), + }); + assert.equal(configured.kind, 'committed'); + + const prepared = await stores.operations.beginModelFetch(connection.connectionId); + assert.equal(prepared.kind, 'ready'); + if (prepared.kind !== 'ready') return; + const completed = await stores.operations.completeModelFetch(prepared.ticket, { + models: [{ id: 'account-available' }, { id: 'account-preview' }], + source: 'fetched', + fetchedAt: 43, + }); + assert.equal(completed.kind, 'committed'); + if (completed.kind !== 'committed') return; + + const updated = completed.snapshot.connections[0]; + assert.deepEqual(updated?.models, [{ id: 'account-available' }, { id: 'account-preview' }]); + assert.deepEqual(updated?.enabledModelIds, ['account-available', 'account-preview']); + assert.equal(updated?.enabledModelIds.includes('gpt-5'), false); + }); + }); + test('keeps the canonical default target when discovery stops listing its model', async () => { await withInteractiveOwner(async ({ stores }) => { const connection = await createConnection( diff --git a/packages/storage/src/runtime-policy/connection-catalog-document.ts b/packages/storage/src/runtime-policy/connection-catalog-document.ts index 2ff3568290..7377c3b285 100644 --- a/packages/storage/src/runtime-policy/connection-catalog-document.ts +++ b/packages/storage/src/runtime-policy/connection-catalog-document.ts @@ -469,6 +469,10 @@ export class ConnectionCatalogDocumentOwner { result.models, { aliases: modelIdAliasesForProvider(previous.providerType), + // GitHub Copilot's filtered /models response is the account's complete + // usable catalog. Unlike generic provider snapshots, omission here is + // an entitlement answer and must remove bootstrap/stale ids. + authoritative: previous.providerType === 'github-copilot', }, ); // Discovery MOVES a target: a provider's model rename carries the default diff --git a/scripts/release-cli-package.mjs b/scripts/release-cli-package.mjs index c3976e694c..3b302eaec2 100644 --- a/scripts/release-cli-package.mjs +++ b/scripts/release-cli-package.mjs @@ -296,21 +296,31 @@ function buildRuntimeWorkspaces(options) { } function checkProductionAudit() { - const audit = spawnSync( - 'npm', - ['audit', '--omit=dev', '--workspace', 'maka-agent', '--json'], - npmSpawnOptions({ - cwd: repoRoot, - encoding: 'utf8', - env: releaseNpmEnvironment(process.env, join(repoRoot, '.npmrc')), - maxBuffer: 64 * 1024 * 1024, - }), - ); - const report = JSON.parse(audit.stdout || '{}'); - const vulnerabilities = report.metadata?.vulnerabilities; - if (audit.error || audit.status !== 0 || vulnerabilities?.total !== 0) { + for (let attempt = 1; attempt <= 2; attempt += 1) { + const audit = spawnSync( + 'npm', + ['audit', '--omit=dev', '--workspace', 'maka-agent', '--json'], + npmSpawnOptions({ + cwd: repoRoot, + encoding: 'utf8', + env: releaseNpmEnvironment(process.env, join(repoRoot, '.npmrc')), + maxBuffer: 64 * 1024 * 1024, + }), + ); + const report = JSON.parse(audit.stdout || '{}'); + const vulnerabilities = report.metadata?.vulnerabilities; + if (!audit.error && audit.status === 0 && vulnerabilities?.total === 0) return; + const transient = + (typeof report.statusCode === 'number' && report.statusCode >= 500) || + ['EAI_AGAIN', 'ECONNRESET', 'ETIMEDOUT'].includes(audit.error?.code); + if (attempt === 1 && transient && !vulnerabilities?.total) { + console.warn( + `[release-cli] npm audit unavailable: ${report.message ?? audit.error}; retrying`, + ); + continue; + } throw new Error( - `CLI production dependency audit failed: ${JSON.stringify(vulnerabilities ?? report.error ?? audit.error)}`, + `CLI production dependency audit failed: ${JSON.stringify(vulnerabilities ?? report.message ?? report.error ?? audit.error)}`, ); } }