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
32 changes: 32 additions & 0 deletions packages/core/src/__tests__/llm-connections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/llm-connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,11 @@ export function reconcileConnectionAfterModelFetch(
* caller that knows the provider's naming supplies the table.
*/
readonly aliases?: Readonly<Record<string, string>>;
/**
* 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;
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion packages/runtime/src/__tests__/provider-contract-overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,13 +348,21 @@ async function runGitHubCopilotDiscovery(): Promise<void> {
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,
Expand Down
15 changes: 13 additions & 2 deletions packages/runtime/src/model-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 [];
Expand Down Expand Up @@ -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<string, unknown>).state === 'enabled';
}

async function fetchCohereModels(
baseUrl: string,
apiKey: string,
Expand Down
36 changes: 36 additions & 0 deletions packages/storage/src/__tests__/runtime-policy-stores.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 24 additions & 14 deletions scripts/release-cli-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`,
);
}
}
Expand Down
Loading