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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 (
<div className="providerEndpointField">
{props.children(description)}
{endpointHelp && <p className="providerRequestUrlPreview">{endpointHelp}</p>}
{description && <p className="providerRequestUrlPreview" aria-hidden="true">{description}</p>}
</div>
);
}

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,10 @@ const zhCopy = {
},
shared: {
requestUrlLabel: '请求地址:',
azureFoundryEndpointHelp: '使用以 /openai/v1 结尾的已部署模型终结点。模型 ID 请填写部署名称;模型列表可能只包含基础模型。',
azureFoundryAnthropicEndpointHelp: 'Anthropic Messages 请使用 https://<resource-name>.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: '模型服务暂时不可用,请稍后重试。',
Expand Down Expand Up @@ -486,6 +490,10 @@ const zhTwCopy = {
},
shared: {
requestUrlLabel: '請求地址:',
azureFoundryEndpointHelp: '請使用以 /openai/v1 結尾的已部署模型端點。模型 ID 請填寫部署名稱;模型清單可能只包含基礎模型。',
azureFoundryAnthropicEndpointHelp: 'Anthropic Messages 請使用 https://<resource-name>.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: '模型服務暫時不可用,請稍後重試。',
Expand Down Expand Up @@ -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://<resource-name>.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.',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@
* under the License.
*/

export { providerRequestUrlPreview } from './provider-endpoint-field.js';
export { providerEndpointHelpKey, providerRequestUrlPreview } from './provider-endpoint-field.js';
6 changes: 4 additions & 2 deletions apps/desktop/src/renderer/settings/provider-add-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
MODEL_API_PROTOCOLS,
PROVIDER_REGISTRY,
deriveConnectionSlug,
providerSupportsApiProtocolSelection,
} from '@maka/core/llm-connections';
import {
providerAuthRequiresSecret,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -782,7 +784,7 @@ export function AddProviderForm(props: {
)}
</ProviderEndpointField>
)}
{isCustom && (
{canChooseApiProtocol && (
<Selector
label={copy.connectionApiProtocol}
description={copy.connectionApiProtocolHelp}
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/renderer/settings/provider-display-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,16 @@ export const PROVIDER_DISPLAY_COPY = {
'zh-TW': { name: 'Cloudflare Workers AI', description: 'Cloudflare 託管模型,使用帳號層級連線', badge: 'API' },
en: { name: 'Cloudflare Workers AI', description: 'Cloudflare-hosted models over the account-scoped API.', badge: 'API' },
},
'azure-foundry': {
'zh-CN': { name: 'Azure AI Foundry(API 密钥)', description: '仅支持使用 Foundry 终结点和 API 密钥连接模型。暂不支持 Entra ID;见 #6072。', badge: 'API' },
'zh-TW': { name: 'Azure AI Foundry(API 金鑰)', description: '僅支援使用 Foundry 端點與 API 金鑰連線模型。暫不支援 Entra ID;見 #6072。', badge: 'API' },
en: { name: 'Azure AI Foundry (API key)', description: 'Connect with a Foundry endpoint and API key. Entra ID is not supported yet; see #6072.', badge: 'API' },
},
'amazon-bedrock-api-key': {
'zh-CN': { name: 'Amazon Bedrock(API 密钥)', description: '仅支持 Bedrock API 密钥;可选择 Chat Completions、Responses 或 Anthropic Messages 协议。暂不支持 SigV4 或 IAM Identity Center;见 #6072。', badge: 'API' },
'zh-TW': { name: 'Amazon Bedrock(API 金鑰)', description: '僅支援 Bedrock API 金鑰;可選擇 Chat Completions、Responses 或 Anthropic Messages 協定。暫不支援 SigV4 或 IAM Identity Center;見 #6072。', badge: 'API' },
en: { name: 'Amazon Bedrock (API key)', description: 'API key only. Choose Chat Completions, Responses or Anthropic Messages; SigV4 and IAM Identity Center are not supported yet (#6072).', badge: 'API' },
},
huggingface: {
'zh-CN': { name: 'Hugging Face', description: 'Inference Providers 路由,聚合多家托管模型', badge: '路由' },
'zh-TW': { name: 'Hugging Face', description: 'Inference Providers 路由,聚合多家託管模型', badge: '路由' },
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/stories/settings/settings-pages.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2837,7 +2837,10 @@ export const UsageLongTail: Story = {
const requiredWidth = timeRange.getBoundingClientRect().width
+ Number.parseFloat(timeCellStyle.paddingLeft)
+ Number.parseFloat(timeCellStyle.paddingRight);
expect(requiredWidth).toBeLessThanOrEqual(timeCell!.clientWidth);
// Chromium measures text on fractional pixels while clientWidth is an
// integer. The one-pixel allowance preserves the no-clipping check
// without making the assertion depend on font rasterisation rounding.
expect(requiredWidth).toBeLessThanOrEqual(timeCell!.clientWidth + 1);

const longTarget = 'anthropic/claude-sonnet-4-5-20250929-preview-extended-thinking';
const targetCellText = within(table).getByText(longTarget);
Expand Down
19 changes: 19 additions & 0 deletions packages/cli/src/__tests__/runtime-host-onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1245,6 +1245,25 @@ describe('projectProviders', () => {
});
});

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' }]),
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/onboarding-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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]})`,
Expand Down
63 changes: 63 additions & 0 deletions packages/core/src/__tests__/provider-catalog-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
import {
CATALOG_PROVIDER_TYPES,
PROVIDER_REGISTRY,
providerSupportsApiProtocolSelection,
providerSupportsDefaultApiProtocol,
isRetiredProvider,
providerAcceptsOutputTokenLimit,
providerFallbackModelIds,
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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',
Expand Down
Loading