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 &&
{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