diff --git a/packages/runtime/src/__tests__/provider-contract-overrides.ts b/packages/runtime/src/__tests__/provider-contract-overrides.ts index ca7c049a2f..db3afa655d 100644 --- a/packages/runtime/src/__tests__/provider-contract-overrides.ts +++ b/packages/runtime/src/__tests__/provider-contract-overrides.ts @@ -351,10 +351,31 @@ async function runGitHubCopilotDiscovery(): Promise { copilotModel('gpt-5.4', ['/responses']), copilotModel('claude-sonnet-4.6', ['/v1/messages']), copilotModel('gemini-3.1-pro-preview', ['/chat/completions']), + { + id: 'policy-free', + name: 'policy-free display', + model_picker_enabled: true, + supported_endpoints: ['/chat/completions'], + capabilities: { + limits: { + max_prompt_tokens: 400_000, + max_output_tokens: 128_000, + }, + supports: { + tool_calls: true, + vision: true, + reasoning_effort: ['low', 'medium', 'high'], + }, + }, + }, { ...copilotModel('disabled-by-policy', ['/chat/completions']), policy: { state: 'disabled' }, }, + { + ...copilotModel('not-configured', ['/chat/completions']), + policy: { state: 'unconfigured' }, + }, { ...copilotModel('hidden-from-picker', ['/chat/completions']), model_picker_enabled: false, @@ -383,32 +404,40 @@ async function runGitHubCopilotDiscovery(): Promise { ); assert.deepEqual(models, [ - { - id: 'gpt-5.4', - displayName: 'gpt-5.4 display', - contextWindow: 400_000, - maxOutputTokens: 128_000, - apiProtocol: 'openai-responses', - capabilities: { vision: true, reasoning: true, functionCalling: true }, - }, - { - id: 'claude-sonnet-4.6', - displayName: 'claude-sonnet-4.6 display', - contextWindow: 400_000, - maxOutputTokens: 128_000, - apiProtocol: 'anthropic-messages', - capabilities: { vision: true, reasoning: true, functionCalling: true }, - }, - { - id: 'gemini-3.1-pro-preview', - displayName: 'gemini-3.1-pro-preview display', - contextWindow: 400_000, - maxOutputTokens: 128_000, - apiProtocol: 'openai-chat', - capabilities: { vision: true, reasoning: true, functionCalling: true }, - }, - ]); -} + { + id: 'gpt-5.4', + displayName: 'gpt-5.4 display', + contextWindow: 400_000, + maxOutputTokens: 128_000, + apiProtocol: 'openai-responses', + capabilities: { vision: true, reasoning: true, functionCalling: true }, + }, + { + id: 'claude-sonnet-4.6', + displayName: 'claude-sonnet-4.6 display', + contextWindow: 400_000, + maxOutputTokens: 128_000, + apiProtocol: 'anthropic-messages', + capabilities: { vision: true, reasoning: true, functionCalling: true }, + }, + { + id: 'gemini-3.1-pro-preview', + displayName: 'gemini-3.1-pro-preview display', + contextWindow: 400_000, + maxOutputTokens: 128_000, + apiProtocol: 'openai-chat', + capabilities: { vision: true, reasoning: true, functionCalling: true }, + }, + { + id: 'policy-free', + displayName: 'policy-free display', + contextWindow: 400_000, + maxOutputTokens: 128_000, + apiProtocol: 'openai-chat', + capabilities: { vision: true, reasoning: true, functionCalling: true }, + }, + ]); + } function copilotModel(id: string, supportedEndpoints: string[]): Record { return { diff --git a/packages/runtime/src/model-fetcher.ts b/packages/runtime/src/model-fetcher.ts index e886114c3e..6357929e11 100644 --- a/packages/runtime/src/model-fetcher.ts +++ b/packages/runtime/src/model-fetcher.ts @@ -519,11 +519,13 @@ function contextWindowOfOpenAiCodexModel(model: RawOpenAiCodexModel): number | u } function toGitHubCopilotModelInfo(model: RawGitHubCopilotModel): ModelInfo[] { + if (model.policy !== undefined) { + if (model.policy.state !== 'enabled') return []; + } if ( typeof model.id !== 'string' || !model.id || model.model_picker_enabled !== true || - model.policy?.state === 'disabled' || model.capabilities?.supports?.tool_calls !== true ) return []; diff --git a/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts b/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts index 5788a95ecd..4c53504cfb 100644 --- a/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts @@ -23,6 +23,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; import { RuntimePolicyCoordinator } from '../runtime-policy/coordinator.js'; +import { ConnectionCatalogDocumentOwner } from '../runtime-policy/connection-catalog-document.js'; test('runtime policy catalog overlays enabled custom model facts without changing the raw catalog', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-')); @@ -259,6 +260,85 @@ test('model fetch keeps enabled facts-backed models when provider inventory fill } }); +test('github copilot model fetch prunes fallback ids outside the live catalog', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-copilot-refresh-')); + try { + const catalog = new ConnectionCatalogDocumentOwner(); + const connectionId = '00000000-0000-4000-8000-00000000c0de'; + const current = { + schemaVersion: 1 as const, + revision: 1, + defaultTarget: { connectionId, modelId: 'copilot-fallback' }, + connections: [ + { + connectionId, + revision: 1, + slug: 'github-copilot', + name: 'GitHub Copilot', + providerType: 'github-copilot' as const, + enabled: true, + enabledModelIds: ['copilot-fallback'], + models: [{ id: 'copilot-fallback' }], + modelSource: 'fallback' as const, + }, + ], + }; + + const refreshed = await catalog.writeModelFetchResult( + root, + current, + { connectionId, revision: 1 }, + { models: [{ id: 'live-model' }], source: 'fetched', fetchedAt: 1 }, + ); + + const projected = refreshed.connections[0]; + assert.deepEqual(projected?.enabledModelIds, []); + assert.deepEqual(projected?.models.map((model) => model.id), ['live-model']); + assert.equal(refreshed.defaultTarget, null); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('github copilot model fetch clears a withdrawn default without picking a replacement', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-copilot-default-')); + try { + const catalog = new ConnectionCatalogDocumentOwner(); + const connectionId = '00000000-0000-4000-8000-00000000c0df'; + const current = { + schemaVersion: 1 as const, + revision: 1, + defaultTarget: { connectionId, modelId: 'copilot-fallback' }, + connections: [ + { + connectionId, + revision: 1, + slug: 'github-copilot', + name: 'GitHub Copilot', + providerType: 'github-copilot' as const, + enabled: true, + enabledModelIds: ['copilot-fallback', 'retained-live'], + models: [{ id: 'copilot-fallback' }, { id: 'retained-live' }], + modelSource: 'fallback' as const, + }, + ], + }; + + const refreshed = await catalog.writeModelFetchResult( + root, + current, + { connectionId, revision: 1 }, + { models: [{ id: 'retained-live' }], source: 'fetched', fetchedAt: 1 }, + ); + + const projected = refreshed.connections[0]; + assert.deepEqual(projected?.enabledModelIds, ['retained-live']); + assert.equal(refreshed.defaultTarget, null); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('protocol model facts edits clear verification, supersede tickets, and warn on malformed input', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-external-edit-')); const emitWarning = process.emitWarning; diff --git a/packages/storage/src/runtime-policy/connection-catalog-document.ts b/packages/storage/src/runtime-policy/connection-catalog-document.ts index 2ff3568290..df6b052cf0 100644 --- a/packages/storage/src/runtime-policy/connection-catalog-document.ts +++ b/packages/storage/src/runtime-policy/connection-catalog-document.ts @@ -471,13 +471,32 @@ export class ConnectionCatalogDocumentOwner { aliases: modelIdAliasesForProvider(previous.providerType), }, ); + // GitHub Copilot 的 `/models` 是账户当前可用目录,刷新时不能把旧 + // fallback 选择再次保留下来,否则选择器会回流不可用模型。 + const isGitHubCopilot = previous.providerType === 'github-copilot'; + const liveModelIds = isGitHubCopilot ? new Set(result.models.map(({ id }) => id)) : undefined; + const enabledModelIds = isGitHubCopilot + ? reconciled.enabledModelIds.filter((modelId) => liveModelIds?.has(modelId)) + : reconciled.enabledModelIds; + const defaultModel = + isGitHubCopilot && liveModelIds && !liveModelIds.has(reconciled.defaultModel) + ? '' + : reconciled.defaultModel; + // 目录里没有的 default 只应被清掉,不应顺手换成另一个 live 模型。 + // Copilot 的可用集是账户当前事实,不是可自动补位的偏好列表。 // Discovery MOVES a target: a provider's model rename carries the default // across by alias. A default outside the selection the reconciler just // decided is its own bug — fail closed where it is still attributable. const defaultTarget = currentDefaultTarget - ? { connectionId: previous.connectionId, modelId: reconciled.defaultModel } + ? defaultModel + ? { connectionId: previous.connectionId, modelId: defaultModel } + : null : current.defaultTarget; - if (currentDefaultTarget && !reconciled.enabledModelIds.includes(reconciled.defaultModel)) { + if ( + currentDefaultTarget && + !isGitHubCopilot && + !reconciled.enabledModelIds.includes(reconciled.defaultModel) + ) { throw codecError( 'invalid_document', 'Model discovery reconciled a default outside its own selection', @@ -489,14 +508,14 @@ export class ConnectionCatalogDocumentOwner { // next read. const relayModelProfiles = pruneRelayModelProfiles( previous.relayModelProfiles, - reconciled.enabledModelIds, + enabledModelIds, ); const { relayModelProfiles: _staleProfiles, ...previousWithoutProfiles } = previous; const discovered: ConnectionCatalogEntry = { ...previousWithoutProfiles, ...(relayModelProfiles ? { relayModelProfiles } : {}), revision: nextRevision(previous.revision), - enabledModelIds: reconciled.enabledModelIds, + enabledModelIds, models: result.models, modelSource: result.source, modelsFetchedAt: result.fetchedAt,