diff --git a/apps/desktop/src/main/__tests__/provider-endpoint-presentation.test.ts b/apps/desktop/src/main/__tests__/provider-endpoint-presentation.test.ts index 741c872a95a..2b6eb9f464f 100644 --- a/apps/desktop/src/main/__tests__/provider-endpoint-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/provider-endpoint-presentation.test.ts @@ -25,7 +25,10 @@ import { providerEndpointPresentation, } from '../../renderer/settings/provider-endpoint-presentation.js'; -import { providerRequestUrlPreview } from '../../renderer/features/connection-settings/testing.js'; +import { + providerEndpointHelpKey, + providerRequestUrlPreview, +} from '../../renderer/features/connection-settings/testing.js'; // A 40-char hex-shaped run, built rather than written: long enough to trip // the display redactor's long-opaque-token rule wherever it is left alone. @@ -215,6 +218,26 @@ test('draft request previews follow the relay protocol, custom prefixes and endp 'https://relay.example/proxy/responses'); assert.equal(providerRequestUrlPreview('custom', 'https://relay.example/', 'openai-responses'), 'https://relay.example/responses'); + assert.equal(providerRequestUrlPreview('azure-foundry', 'https://tenant.example/openai/v1'), + 'https://tenant.example/openai/v1/chat/completions'); + assert.equal(providerRequestUrlPreview('amazon-bedrock-api-key', 'https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1', 'openai-responses'), + 'https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1/responses'); +}); + +test('provider endpoint help follows the selected Azure and Bedrock API protocol', () => { + assert.equal(providerEndpointHelpKey('azure-foundry', 'openai-chat'), 'azureFoundryEndpointHelp'); + assert.equal( + providerEndpointHelpKey('azure-foundry', 'anthropic-messages'), + 'azureFoundryAnthropicEndpointHelp', + ); + assert.equal( + providerEndpointHelpKey('amazon-bedrock-api-key', 'anthropic-messages'), + 'bedrockAnthropicEndpointHelp', + ); + assert.equal( + providerEndpointHelpKey('amazon-bedrock-api-key', 'openai-responses'), + 'bedrockOpenAiEndpointHelp', + ); }); test('switching a custom connection protocol replaces the full OpenAI endpoint', () => { @@ -238,5 +261,6 @@ test('empty, incomplete, unsaveable and unsupported protocol drafts have no requ assert.equal(providerRequestUrlPreview('custom', draft), null, draft); } assert.equal(providerRequestUrlPreview('openai', 'https://relay.example/v1'), null); + assert.equal(providerRequestUrlPreview('amazon-bedrock-api-key', 'https://bedrock-runtime.us-east-1.amazonaws.com/anthropic', 'anthropic-messages'), null); assert.equal(providerRequestUrlPreview('custom', 'https://relay.example/v1', 'anthropic-messages'), null); }); diff --git a/apps/desktop/src/renderer/features/connection-settings/provider-endpoint-field.tsx b/apps/desktop/src/renderer/features/connection-settings/provider-endpoint-field.tsx index b470a62004d..15d0a90c3d7 100644 --- a/apps/desktop/src/renderer/features/connection-settings/provider-endpoint-field.tsx +++ b/apps/desktop/src/renderer/features/connection-settings/provider-endpoint-field.tsx @@ -24,6 +24,7 @@ import { getProviderSettingsCopy } from './settings-provider-copy.js'; import { openAiChatUrl, openResponsesUrl } from '@maka/core/openai-urls'; import { normalizeCatalogConnectionBaseUrl } from '@maka/core/runtime-policy'; import { redactSecrets } from '@maka/core/display-redaction'; +import { providerSupportsApiProtocolSelection } from '@maka/core/llm-connections'; export function ProviderEndpointField(props: { providerType: ProviderType; @@ -33,26 +34,55 @@ export function ProviderEndpointField(props: { }) { const copy = getProviderSettingsCopy(useUiLocale()).shared; const url = providerRequestUrlPreview(props.providerType, props.baseUrl, props.apiProtocol); - if (props.providerType !== 'custom') return props.children(undefined); + const supportsPreview = providerSupportsApiProtocolSelection(props.providerType); + if (!supportsPreview) return props.children(undefined); const description = url ? `${copy.requestUrlLabel} ${url}` : undefined; + const endpointHelpKey = providerEndpointHelpKey(props.providerType, props.apiProtocol); + const endpointHelp = endpointHelpKey ? copy[endpointHelpKey] : undefined; // Astryx's description is above the input (and hidden with its label). // This computed output belongs below it; pass it through aria-description // on the control as well, without duplicating the field's visible label. return (
{props.children(description)} + {endpointHelp &&

{endpointHelp}

} {description && }
); } +export function providerEndpointHelpKey( + providerType: ProviderType, + apiProtocol?: ModelApiProtocol, +): + | 'azureFoundryEndpointHelp' + | 'azureFoundryAnthropicEndpointHelp' + | 'bedrockOpenAiEndpointHelp' + | 'bedrockAnthropicEndpointHelp' + | undefined { + if (providerType === 'azure-foundry') { + return apiProtocol === 'anthropic-messages' + ? 'azureFoundryAnthropicEndpointHelp' + : 'azureFoundryEndpointHelp'; + } + if (providerType === 'amazon-bedrock-api-key') { + return apiProtocol === 'anthropic-messages' + ? 'bedrockAnthropicEndpointHelp' + : 'bedrockOpenAiEndpointHelp'; + } + return undefined; +} + /** Preview the selected protocol when adding, or the default model's protocol when editing. */ export function providerRequestUrlPreview( providerType: ProviderType, draftBaseUrl: string, apiProtocol: ModelApiProtocol = 'openai-chat', ): string | null { - if (providerType !== 'custom' || apiProtocol === 'anthropic-messages') { + if ( + !providerSupportsApiProtocolSelection(providerType) || + apiProtocol === 'anthropic-messages' + ) { return null; } // A draft must be a complete, saveable HTTP(S) address. Do not substitute diff --git a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts index 19df1d16644..d301244209d 100644 --- a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts @@ -273,6 +273,10 @@ const zhCopy = { }, shared: { requestUrlLabel: '请求地址:', + azureFoundryEndpointHelp: '使用以 /openai/v1 结尾的已部署模型终结点。模型 ID 请填写部署名称;模型列表可能只包含基础模型。', + azureFoundryAnthropicEndpointHelp: 'Anthropic Messages 请使用 https://.services.ai.azure.com/anthropic。模型 ID 请填写部署名称。', + bedrockOpenAiEndpointHelp: 'Chat Completions 或 Responses 请使用 https://bedrock-runtime.{region}.amazonaws.com/openai/v1。若未列出,请手动输入 Bedrock 模型 ID。', + bedrockAnthropicEndpointHelp: 'Anthropic Messages 请使用 https://bedrock-runtime.{region}.amazonaws.com/anthropic。若未列出,请手动输入 Bedrock 模型 ID。', connectionStale: '连接状态已更新,请刷新列表后再删除。', actionFallback: '模型连接服务暂时不可用,请稍后重试。', rateLimit: '当前账号或模型服务触发速率限制,请稍后重试。', timeout: '请求超时,请检查网络或代理后重试。', unavailable: '模型服务暂时不可用,请稍后重试。', @@ -486,6 +490,10 @@ const zhTwCopy = { }, shared: { requestUrlLabel: '請求地址:', + azureFoundryEndpointHelp: '請使用以 /openai/v1 結尾的已部署模型端點。模型 ID 請填寫部署名稱;模型清單可能只包含基礎模型。', + azureFoundryAnthropicEndpointHelp: 'Anthropic Messages 請使用 https://.services.ai.azure.com/anthropic。模型 ID 請填寫部署名稱。', + bedrockOpenAiEndpointHelp: 'Chat Completions 或 Responses 請使用 https://bedrock-runtime.{region}.amazonaws.com/openai/v1。若未列出,請手動輸入 Bedrock 模型 ID。', + bedrockAnthropicEndpointHelp: 'Anthropic Messages 請使用 https://bedrock-runtime.{region}.amazonaws.com/anthropic。若未列出,請手動輸入 Bedrock 模型 ID。', connectionStale: '連線狀態已更新,請重新整理清單後再刪除。', actionFallback: '模型連線服務暫時不可用,請稍後重試。', rateLimit: '目前帳號或模型服務觸發速率限制,請稍後重試。', timeout: '請求超時,請檢查網路或代理後重試。', unavailable: '模型服務暫時不可用,請稍後重試。', @@ -698,6 +706,10 @@ const enCopy: ProviderSettingsCopy = { }, shared: { requestUrlLabel: 'Request URL:', + azureFoundryEndpointHelp: 'Use the deployed model endpoint ending in /openai/v1. Enter the deployment name as the model ID; the models list may only contain base models.', + azureFoundryAnthropicEndpointHelp: 'For Anthropic Messages, use https://.services.ai.azure.com/anthropic. Enter the deployment name as the model ID.', + bedrockOpenAiEndpointHelp: 'For Chat Completions or Responses, use https://bedrock-runtime.{region}.amazonaws.com/openai/v1. Enter the Bedrock model ID manually if it is not listed.', + bedrockAnthropicEndpointHelp: 'For Anthropic Messages, use https://bedrock-runtime.{region}.amazonaws.com/anthropic. Enter the Bedrock model ID manually if it is not listed.', connectionStale: 'The connection changed while deleting. Refresh the list and try again.', actionFallback: 'The model connection service is temporarily unavailable. Try again later.', rateLimit: 'This account or model service is rate-limited. Try again later.', timeout: 'The request timed out. Check the network or proxy and try again.', unavailable: 'The model service is temporarily unavailable. Try again later.', diff --git a/apps/desktop/src/renderer/features/connection-settings/testing.ts b/apps/desktop/src/renderer/features/connection-settings/testing.ts index 444398e2da5..d77baf0f6b0 100644 --- a/apps/desktop/src/renderer/features/connection-settings/testing.ts +++ b/apps/desktop/src/renderer/features/connection-settings/testing.ts @@ -17,4 +17,4 @@ * under the License. */ -export { providerRequestUrlPreview } from './provider-endpoint-field.js'; +export { providerEndpointHelpKey, providerRequestUrlPreview } from './provider-endpoint-field.js'; diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index 80aafac7224..fe9cfb871ca 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -24,6 +24,7 @@ import { MODEL_API_PROTOCOLS, PROVIDER_REGISTRY, deriveConnectionSlug, + providerSupportsApiProtocolSelection, } from '@maka/core/llm-connections'; import { providerAuthRequiresSecret, @@ -132,6 +133,7 @@ export function AddProviderForm(props: { }>({ baseUrl: defaults.baseUrl, defaultApiProtocol: 'openai-chat' }); const { baseUrl, defaultApiProtocol } = endpoint; const isCustom = props.providerType === 'custom'; + const canChooseApiProtocol = providerSupportsApiProtocolSelection(props.providerType); const [cloudflareAccountId, setCloudflareAccountId] = useState(''); const [apiKey, setApiKey] = useState(''); const [defaultModel, setDefaultModel] = useState(recommendedDefaultModel); @@ -383,7 +385,7 @@ export function AddProviderForm(props: { name: name || display.name, providerType: props.providerType, baseUrl: resolvedBaseUrl, - ...(isCustom ? { defaultApiProtocol } : {}), + ...(canChooseApiProtocol ? { defaultApiProtocol } : {}), defaultModel: createdDefaultModel, ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), ...(Object.keys(normalizedRequestHeaders).length > 0 @@ -782,7 +784,7 @@ export function AddProviderForm(props: { )} )} - {isCustom && ( + {canChooseApiProtocol && ( { }); }); + test('CLI onboarding offers only supported protocols for cloud API-key entries', () => { + for (const providerType of ['azure-foundry', 'amazon-bedrock-api-key'] as const) { + const entries = listApiKeyOnboardableProviders().filter( + (provider) => provider.providerType === providerType, + ); + assert.deepEqual( + entries.map((entry) => entry.defaultApiProtocol), + ['openai-chat', 'openai-responses', 'anthropic-messages'], + ); + for (const entry of entries) { + assert.deepEqual(onboardingCreateTarget(entry), { + kind: 'create', + providerType, + defaultApiProtocol: entry.defaultApiProtocol, + }); + } + } + }); + test('several non-canonical connections remain independently editable', () => { const entries = projectProviders( catalog([relay, { ...relay, connectionId: 'relay-2-id', slug: 'my-relay-2' }]), diff --git a/packages/cli/src/onboarding-catalog.ts b/packages/cli/src/onboarding-catalog.ts index 11168c54b9b..cf6f6fd05a2 100644 --- a/packages/cli/src/onboarding-catalog.ts +++ b/packages/cli/src/onboarding-catalog.ts @@ -23,6 +23,8 @@ import { MODEL_API_PROTOCOLS, PROVIDER_REGISTRY, providerAuthSupportsApiKey, + providerSupportsApiProtocolSelection, + providerSupportsDefaultApiProtocol, } from '@maka/core/llm-connections'; import type { ConnectionOnboardingTarget } from '@maka/core/runtime-policy'; import type { OnboardableProvider } from './pi-tui-contracts.js'; @@ -47,8 +49,10 @@ export function listApiKeyOnboardableProviders(): OnboardableProvider[] { requiresBaseUrl: !definition.baseUrl, setupMethod: 'api_key' as const, }; - if (providerType !== 'custom') return [entry]; - return MODEL_API_PROTOCOLS.map((defaultApiProtocol) => ({ + if (!providerSupportsApiProtocolSelection(providerType)) return [entry]; + return MODEL_API_PROTOCOLS.filter((protocol) => + providerSupportsDefaultApiProtocol(providerType, protocol), + ).map((defaultApiProtocol) => ({ ...entry, defaultApiProtocol, label: `${definition.label} (${MODEL_API_PROTOCOL_LABELS[defaultApiProtocol]})`, diff --git a/packages/core/src/__tests__/provider-catalog-contract.test.ts b/packages/core/src/__tests__/provider-catalog-contract.test.ts index bce88beea88..640ba578912 100644 --- a/packages/core/src/__tests__/provider-catalog-contract.test.ts +++ b/packages/core/src/__tests__/provider-catalog-contract.test.ts @@ -31,6 +31,8 @@ import { import { CATALOG_PROVIDER_TYPES, PROVIDER_REGISTRY, + providerSupportsApiProtocolSelection, + providerSupportsDefaultApiProtocol, isRetiredProvider, providerAcceptsOutputTokenLimit, providerFallbackModelIds, @@ -66,6 +68,57 @@ describe('Moonshot provider regions', () => { }); describe('provider catalog contract — structural invariants over CATALOG_PROVIDER_TYPES', () => { + it('derives protocol picker availability from endpoint-owned registry entries', () => { + assert.equal(providerSupportsApiProtocolSelection('custom'), true); + assert.equal(providerSupportsApiProtocolSelection('azure-foundry'), true); + assert.equal(providerSupportsApiProtocolSelection('amazon-bedrock-api-key'), true); + assert.equal(providerSupportsApiProtocolSelection('openrouter'), false); + assert.equal(providerSupportsApiProtocolSelection('opencode'), false); + assert.equal(providerSupportsDefaultApiProtocol('azure-foundry', 'openai-chat'), true); + assert.equal(providerSupportsDefaultApiProtocol('azure-foundry', 'openai-responses'), true); + assert.equal( + providerSupportsDefaultApiProtocol('amazon-bedrock-api-key', 'anthropic-messages'), + true, + ); + assert.equal(providerSupportsDefaultApiProtocol('openrouter', 'openai-chat'), false); + }); + + it('marks tenant- or region-scoped API providers as user-endpoint connections', () => { + for (const type of ['azure-foundry', 'amazon-bedrock-api-key'] as const) { + const definition = PROVIDER_REGISTRY[type]; + assert.equal(definition.authKind, 'api_key'); + assert.equal(definition.baseUrl, ''); + assert.ok( + definition.runtimeAdapter.kind === 'openai-compatible' && + definition.runtimeAdapter.requireBaseUrl, + ); + assert.equal(definition.category, 'custom'); + assert.deepEqual(Object.keys(definition.protocolAdapters ?? {}).sort(), [ + 'anthropic-messages', + 'openai-responses', + ]); + } + }); + + it('declares a working runtime adapter for every selectable API protocol', () => { + for (const type of ['azure-foundry', 'amazon-bedrock-api-key'] as const) { + const definition = PROVIDER_REGISTRY[type]; + assert.equal(definition.runtimeAdapter.kind, 'openai-compatible'); + assert.deepEqual(definition.protocolAdapters?.['openai-responses'], { + kind: 'openai', + apiProtocol: 'openai-responses', + responses: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, + }); + assert.deepEqual(definition.protocolAdapters?.['anthropic-messages'], { + kind: 'anthropic', + // Bedrock's documented Anthropic endpoint accepts the API key in + // x-api-key, as does the Azure Foundry endpoint. + auth: 'api-key', + normalizeBaseUrl: true, + }); + } + }); + it('exposes an endpoint source that passes the production baseUrl gate', () => { for (const type of CATALOG_PROVIDER_TYPES) { const def = PROVIDER_REGISTRY[type]; @@ -174,6 +227,16 @@ describe('provider catalog contract — structural invariants over CATALOG_PROVI compatibility: 'alibaba-token-plan', }, }, + { + providerType: 'azure-foundry', + via: 'protocolAdapters.openai-responses', + contract: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, + }, + { + providerType: 'amazon-bedrock-api-key', + via: 'protocolAdapters.openai-responses', + contract: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, + }, { providerType: 'custom', via: 'protocolAdapters.openai-responses', diff --git a/packages/core/src/__tests__/runtime-policy-codec.test.ts b/packages/core/src/__tests__/runtime-policy-codec.test.ts index 521cae3f618..1b9d1b09e0a 100644 --- a/packages/core/src/__tests__/runtime-policy-codec.test.ts +++ b/packages/core/src/__tests__/runtime-policy-codec.test.ts @@ -439,7 +439,8 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', ( null, ); - // A custom connection needs a default wire; no other provider may carry one. + // Custom endpoints and the new protocol-flexible catalog entries can store + // a chosen default wire; other providers still cannot claim unsupported wires. const custom = { slug: 'relay', name: 'Relay', @@ -454,8 +455,8 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', ( [withoutProtocol, /default API protocol is invalid/], [{ ...custom, defaultApiProtocol: 'google-generate' }, /default API protocol is invalid/], [ - { ...custom, providerType: 'openai', baseUrl: undefined }, - /only a custom connection has a default API protocol/, + { ...custom, providerType: 'anthropic', baseUrl: undefined }, + /provider does not support this default API protocol/, ], ] as const) { assert.throws( @@ -463,6 +464,28 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', ( message, ); } + for (const providerType of ['azure-foundry', 'amazon-bedrock-api-key'] as const) { + for (const defaultApiProtocol of [ + 'openai-chat', + 'openai-responses', + 'anthropic-messages', + ] as const) { + assert.doesNotThrow(() => + normalizeCreateCatalogConnectionInput({ + expectedCatalogRevision: 0, + connection: { + slug: providerType, + name: providerType, + providerType, + baseUrl: 'https://provider.example/openai/v1', + defaultApiProtocol, + enabled: true, + enabledModelIds: [], + }, + }), + ); + } + } assert.deepEqual( normalizeConnectionCatalogEntryUpdate({ name: 'Relay', diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index 04833d5d656..6d7c6af1c75 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -45,6 +45,8 @@ import { providerDefaultsOf, providerFallbackModelIds, providerMenuLabel, + providerSupportsApiProtocolSelection, + providerSupportsDefaultApiProtocol, type ApplyPatchProtocol, type ModelApiProtocol, type OpenResponsesCompatibilityProfile, @@ -67,6 +69,8 @@ export { providerDefaultsOf, providerFallbackModelIds, providerMenuLabel, + providerSupportsApiProtocolSelection, + providerSupportsDefaultApiProtocol, }; export type { ApplyPatchProtocol, diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index 41441941ac4..fe21acbb910 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -1525,6 +1525,52 @@ const providerRegistry = { signupUrl: 'https://dash.cloudflare.com/profile/api-tokens', catalogOrder: 33, }, + 'azure-foundry': { + label: 'Azure AI Foundry', + baseUrl: '', + authKind: 'api_key', + fallbackModels: [], + status: 'ready', + runtimeAdapter: { kind: 'openai-compatible', requireBaseUrl: true }, + protocolAdapters: { + 'openai-responses': { + kind: 'openai', + apiProtocol: 'openai-responses', + responses: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, + }, + 'anthropic-messages': { kind: 'anthropic', auth: 'api-key', normalizeBaseUrl: true }, + }, + modelDiscovery: { kind: 'protocol' }, + // Foundry endpoints are resource-specific and must be supplied by the user. + category: 'custom', + catalogGroup: 'api', + signupUrl: 'https://ai.azure.com/', + catalogOrder: 42, + }, + 'amazon-bedrock-api-key': { + label: 'Amazon Bedrock', + baseUrl: '', + authKind: 'api_key', + fallbackModels: [], + status: 'ready', + runtimeAdapter: { kind: 'openai-compatible', requireBaseUrl: true }, + protocolAdapters: { + 'openai-responses': { + kind: 'openai', + apiProtocol: 'openai-responses', + responses: { adapter: 'openai', reasoningReplay: 'encrypted-content' }, + }, + 'anthropic-messages': { kind: 'anthropic', auth: 'api-key', normalizeBaseUrl: true }, + }, + // Bedrock's OpenAI-compatible endpoint and model-list availability vary + // by endpoint family and region. A custom endpoint remains user supplied. + modelDiscovery: { kind: 'protocol' }, + // Bedrock endpoint selection is region/account-specific and user supplied. + category: 'custom', + catalogGroup: 'api', + signupUrl: 'https://console.aws.amazon.com/bedrock/', + catalogOrder: 43, + }, 'ollama-cloud': { label: ollamaCloud.name, baseUrl: ollamaCloud.api, @@ -1686,6 +1732,31 @@ export function providerDefaultsOf(providerType: string): ProviderDefaults | und : undefined; } +/** Whether a provider's registry definition permits the user to choose a default API protocol. */ +export function providerSupportsApiProtocolSelection(providerType: ProviderType): boolean { + if (providerType === 'custom') return true; + const defaults = providerDefaultsOf(providerType); + return ( + defaults?.category === 'custom' && + defaults.runtimeAdapter.kind === 'openai-compatible' && + defaults.runtimeAdapter.requireBaseUrl === true && + Object.keys(defaults.protocolAdapters ?? {}).length > 0 + ); +} + +/** Whether the registry can execute a selected default API protocol for this provider. */ +export function providerSupportsDefaultApiProtocol( + providerType: ProviderType, + protocol: ModelApiProtocol, +): boolean { + const defaults = providerDefaultsOf(providerType); + if (!defaults || !providerSupportsApiProtocolSelection(providerType)) return false; + if (providerType === 'custom') return isModelApiProtocol(protocol); + return protocol === 'openai-chat' + ? defaults.runtimeAdapter.kind === 'openai-compatible' + : defaults.protocolAdapters?.[protocol] !== undefined; +} + /** * The models a provider offers with no live list to go on: the baseline it * ships. This is the only reader of diff --git a/packages/core/src/runtime-policy/connection-catalog-codec.ts b/packages/core/src/runtime-policy/connection-catalog-codec.ts index 2177e7f55ba..cdea654e3b9 100644 --- a/packages/core/src/runtime-policy/connection-catalog-codec.ts +++ b/packages/core/src/runtime-policy/connection-catalog-codec.ts @@ -19,6 +19,7 @@ import { isModelApiProtocol, + providerSupportsDefaultApiProtocol, isModelModality, effectiveBaseUrl, PROVIDER_REGISTRY, @@ -400,14 +401,23 @@ export function decodeDefaultApiProtocol( value: unknown, providerType: ProviderType, ): ModelApiProtocol | undefined { - if (providerType !== 'custom') { - if (value !== undefined) { - throw domainError('only a custom connection has a default API protocol'); + const supportsProtocol = (protocol: ModelApiProtocol): boolean => + providerSupportsDefaultApiProtocol(providerType, protocol); + if (value === undefined) { + if (providerType === 'custom') { + throw domainError('custom connection default API protocol is invalid'); } return undefined; } if (!isModelApiProtocol(value)) { - throw domainError('custom connection default API protocol is invalid'); + throw domainError('connection default API protocol is invalid'); + } + if (!supportsProtocol(value)) { + throw domainError( + providerType === 'custom' + ? 'custom connection default API protocol is invalid' + : 'provider does not support this default API protocol', + ); } return value; } diff --git a/packages/runtime-host/src/__tests__/host-kernel.test.ts b/packages/runtime-host/src/__tests__/host-kernel.test.ts index 1e6a9290c80..4e1b83f642a 100644 --- a/packages/runtime-host/src/__tests__/host-kernel.test.ts +++ b/packages/runtime-host/src/__tests__/host-kernel.test.ts @@ -3146,7 +3146,11 @@ describe('non-serving Runtime Host kernel', () => { }); }); - test('rejects a previous-epoch Client before admitting catalog commands', async () => { + test('rejects an epoch-217 Client before a catalog page can carry API-key provider types', async () => { + // Epoch-217 clients have a closed provider registry without azure-foundry + // and amazon-bedrock-api-key. They must never decode pages from this Host. + const preApiKeyProviderEpoch = 217; + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > preApiKeyProviderEpoch); await withHostPaths(async (paths) => { const candidate = await startTestRuntimeHostCandidate(paths, { rootPath: paths.root, @@ -3162,7 +3166,7 @@ describe('non-serving Runtime Host kernel', () => { clientInstanceId: 'previous-epoch-client', protocolMin: CURRENT_PROTOCOL.min, protocolMax: CURRENT_PROTOCOL.max, - compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1, + compatibilityEpoch: preApiKeyProviderEpoch, compositionId: 'maka.interactive', }); const response = decodeHostFrame(await transport.read(2_000)); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 3a4a71f0444..36b2ee7b8b7 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -104,7 +104,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 217 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 218 as const; +// 218: Connection catalog pages can include azure-foundry and +// amazon-bedrock-api-key. Epoch-217 clients reject these providerType values +// with their closed provider registry, so they must not share a Host. // 217: token_usage.lastRequestAnchor may carry completedAt, the provider // request settlement time; transcripts may carry context_compaction_applied // system notes; and session subscriptions forward the same boundary during