Skip to content
Merged
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 @@ -301,6 +301,117 @@ test('zh-TW: expanded Peer Mesh members render localized route states', async ()
}
});

test('custom connection creation updates and clears the request URL preview while typing', async () => {
const harness = installRenderer();
await harness.render('en', createElement(components.AddProviderForm, {
bridge: connectionDetailBridge({}),
providerType: 'custom', existingSlugs: [],
onCancel: unexpectedCall, onCreated: unexpectedCall,
}));
const input = harness.document.querySelector<HTMLInputElement>('.providerEndpointField input');
assert.ok(input, 'missing service URL input');
for (const [draft, expected] of [
['https://relay.example/proxy/chat/completions', 'https://relay.example/proxy/chat/completions'],
['https://relay.example/team', 'https://relay.example/team/chat/completions'],
['https://', null],
['', null],
] as const) {
await act(async () => {
input.value = draft;
const key = Object.keys(input).find((candidate) => candidate.startsWith('__reactProps$'));
assert.ok(key, 'missing React input props');
const props = (input as unknown as Record<string, unknown>)[key] as {
onChange(event: { target: HTMLInputElement; defaultPrevented: boolean }): void;
};
props.onChange({ target: input, defaultPrevented: false });
});
const preview = harness.document.querySelector('.providerRequestUrlPreview');
if (expected) {
assert.ok(preview);
assert.ok(preview.textContent.endsWith(expected));
assert.equal(input.getAttribute('aria-description'), preview.textContent);
} else {
assert.equal(preview, null);
assert.equal(input.getAttribute('aria-description'), null);
}
}
});

test('endpoint editing previews the default model protocol override', async () => {
const harness = installRenderer();
const base = relayConnection();
const connection: ProjectedLlmConnection = {
...base,
defaultApiProtocol: 'openai-chat',
modelOverrides: { [base.defaultModel]: { apiProtocol: 'openai-responses' } },
};
await harness.render('en', createElement(components.RuntimeHostSettingsTarget, {
host: { profileId: 'local', hostId: 'host-local' },
children: createElement(components.ConnectionDetail, {
bridge: connectionDetailBridge({ hasSecret: async () => true }),
connection,
isDefault: true,
onChanged: async () => {},
onDeleted: async () => {},
}),
}));
const edit = [...harness.document.querySelectorAll<HTMLButtonElement>('button')].find(
(button) => button.getAttribute('aria-label') === 'Edit: Service URL',
);
assert.ok(edit, 'missing service URL edit action');
await act(async () => edit.click());
const preview = harness.document.querySelector('.providerRequestUrlPreview');
assert.ok(preview);
assert.ok(preview.textContent.endsWith('https://relay.example/v1/responses'));
assert.equal(
harness.document.querySelector('.providerEndpointField input')?.getAttribute('aria-description'),
preview.textContent,
);
});

test('legacy credential endpoint editing shows one preview and retains its accessible description', async () => {
const harness = installRenderer();
const connection: ProjectedLlmConnection = {
...relayConnection(),
baseUrl: 'https://relay.example/v1?token=legacy-secret',
};
await harness.render('en', createElement(components.RuntimeHostSettingsTarget, {
host: { profileId: 'local', hostId: 'host-local' },
children: createElement(components.ConnectionDetail, {
bridge: connectionDetailBridge({ hasSecret: async () => true }),
connection,
isDefault: true,
onChanged: async () => {},
onDeleted: async () => {},
}),
}));
const edit = harness.document.querySelector<HTMLButtonElement>('button[aria-label="Edit: Service URL"]');
assert.ok(edit);
await act(async () => edit.click());
const input = harness.document.querySelector<HTMLInputElement>('.providerEndpointField input');
assert.ok(input);
assert.equal(input.type, 'password');
assert.equal(harness.document.querySelector('.providerRequestUrlPreview'), null);
await act(async () => {
input.value = 'https://relay.example/v1';
const key = Object.keys(input).find((candidate) => candidate.startsWith('__reactProps$'));
assert.ok(key);
const props = (input as unknown as Record<string, unknown>)[key] as {
onChange(event: { target: HTMLInputElement; defaultPrevented: boolean }): void;
};
props.onChange({ target: input, defaultPrevented: false });
});
const previews = harness.document.querySelectorAll('.providerRequestUrlPreview');
assert.equal(previews.length, 1);
const preview = previews[0]!;
assert.ok(preview.textContent.endsWith('https://relay.example/v1/responses'));
const descriptions = describedElements(input);
assert.ok(descriptions.some((element) => element.textContent.includes(preview.textContent)));
assert.ok(descriptions.some((element) =>
element.querySelector('.maka-visually-hidden')?.textContent.trim() === preview.textContent,
), 'the accessible copy of the URL must not render a second visible preview');
});

test('credential probing does not flash a page-level loading warning', async () => {
const harness = installRenderer();
const credential = deferred<boolean>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
providerEndpointPresentation,
} from '../../renderer/settings/provider-endpoint-presentation.js';

import { providerRequestUrlPreview } from '../../renderer/features/connection-settings/index.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.
const longOpaqueToken = 'ab01'.repeat(10);
Expand Down Expand Up @@ -202,3 +204,39 @@ test('endpointCarriesCredentials gates userinfo and query-bearing endpoints', ()
assert.equal(endpointCarriesCredentials(undefined), false);
assert.equal(endpointCarriesCredentials('not a url'), false);
});


test('draft request previews follow the relay protocol, custom prefixes and endpoint forms', () => {
assert.equal(providerRequestUrlPreview('custom', 'http://localhost:8080/v1'),
'http://localhost:8080/v1/chat/completions');
assert.equal(providerRequestUrlPreview('custom', 'https://relay.example/proxy/chat/completions/'),
'https://relay.example/proxy/chat/completions');
assert.equal(providerRequestUrlPreview('custom', 'https://relay.example/proxy/responses', 'openai-responses'),
'https://relay.example/proxy/responses');
assert.equal(providerRequestUrlPreview('custom', 'https://relay.example/', 'openai-responses'),
'https://relay.example/responses');
});

test('switching a custom connection protocol replaces the full OpenAI endpoint', () => {
assert.equal(providerRequestUrlPreview('custom', 'https://relay.example/proxy/chat/completions', 'openai-responses'),
'https://relay.example/proxy/responses');
assert.equal(providerRequestUrlPreview('custom', 'https://relay.example/proxy/responses', 'openai-chat'),
'https://relay.example/proxy/chat/completions');
});

test('draft request previews redact token-shaped path segments without changing URL normalization', () => {
assert.equal(
providerRequestUrlPreview('custom', `https://relay.example/${longOpaqueToken}/v1`),
'https://relay.example/<redacted>/v1/chat/completions',
);
});

test('empty, incomplete, unsaveable and unsupported protocol drafts have no request preview', () => {
for (const draft of ['', ' ', 'http', 'https://', 'https:relay.example', 'relay.example/v1',
'file:///v1', 'https://relay.example:abc/v1', 'https://user:secret@relay.example/v1',
'https://relay.example/v1?token=secret', 'https://relay.example/v1#fragment']) {
assert.equal(providerRequestUrlPreview('custom', draft), null, draft);
}
assert.equal(providerRequestUrlPreview('openai', 'https://relay.example/v1'), null);
assert.equal(providerRequestUrlPreview('custom', 'https://relay.example/v1', 'anthropic-messages'), null);
});
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,5 @@ export { GenericProviderMark } from './generic-provider-mark.js';
export { parseContextWindowInput } from './context-window-input.js';
export { CapabilityEditor } from './provider-capability-editor.js';
export { AddModelDialog, ModelParametersDialog } from './provider-add-model-dialog.js';

export { ProviderEndpointField, providerRequestUrlPreview } from './provider-endpoint-field.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import type { ReactNode } from 'react';
import type { ModelApiProtocol, ProviderType } from '@maka/core/llm-connections';
import { useUiLocale } from '@maka/ui';
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';

export function ProviderEndpointField(props: {
providerType: ProviderType;
baseUrl: string;
apiProtocol?: ModelApiProtocol;
children(description: string | undefined): ReactNode;
}) {
const copy = getProviderSettingsCopy(useUiLocale()).shared;
const url = providerRequestUrlPreview(props.providerType, props.baseUrl, props.apiProtocol);
if (props.providerType !== 'custom') return props.children(undefined);
const description = url ? `${copy.requestUrlLabel} ${url}` : 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)}
{description && <p className="providerRequestUrlPreview" aria-hidden="true">{description}</p>}
</div>
);
}

/** 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') {
return null;
}
// A draft must be a complete, saveable HTTP(S) address. Do not substitute
// defaults while it is empty, or expose embedded credentials in a preview.
if (!/^https?:\/\//i.test(draftBaseUrl.trim())) return null;
try {
const baseUrl = normalizeCatalogConnectionBaseUrl(draftBaseUrl);
if (!baseUrl) return null;
// Token-shaped path segments are masked only for display. In that case
// the preview intentionally differs from the actual request URL.
return redactSecrets(apiProtocol === 'openai-chat'
? openAiChatUrl(baseUrl)
: openResponsesUrl(baseUrl));
} catch {
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ const zhCopy = {
},
},
shared: {
requestUrlLabel: '请求地址:',
connectionStale: '连接状态已更新,请刷新列表后再删除。',
actionFallback: '模型连接服务暂时不可用,请稍后重试。', rateLimit: '当前账号或模型服务触发速率限制,请稍后重试。',
timeout: '请求超时,请检查网络或代理后重试。', unavailable: '模型服务暂时不可用,请稍后重试。',
Expand Down Expand Up @@ -471,6 +472,7 @@ const zhTwCopy = {
},
},
shared: {
requestUrlLabel: '請求地址:',
connectionStale: '連線狀態已更新,請重新整理清單後再刪除。',
actionFallback: '模型連線服務暫時不可用,請稍後重試。', rateLimit: '目前帳號或模型服務觸發速率限制,請稍後重試。',
timeout: '請求超時,請檢查網路或代理後重試。', unavailable: '模型服務暫時不可用,請稍後重試。',
Expand Down Expand Up @@ -672,6 +674,7 @@ const enCopy: ProviderSettingsCopy = {
},
},
shared: {
requestUrlLabel: 'Request URL:',
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
40 changes: 23 additions & 17 deletions apps/desktop/src/renderer/settings/provider-add-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import { providerDisplay } from './provider-display';
import { useActionGuard } from './use-action-guard';
import {
OnboardingStepForm,
ProviderEndpointField,
getProviderSettingsCopy,
providerPanelActionErrorMessage,
type ApiKeyOnboardingBridge,
Expand Down Expand Up @@ -758,23 +759,28 @@ export function AddProviderForm(props: {
}
/>
) : (
<TextInput
value={baseUrl}
onChange={(value) => {
setEndpoint((current) => ({ ...current, baseUrl: value }));
resetManagedVerification();
clearFieldError('baseUrl');
}}
placeholder={defaults.baseUrl || 'https://…'}
isDisabled={isExperimental || busy}
label={copy.endpointLabel}
isRequired={requiresBaseUrl}
status={
error?.field === 'baseUrl'
? { type: 'error', message: error.message }
: undefined
}
/>
<ProviderEndpointField providerType={props.providerType} baseUrl={baseUrl} apiProtocol={defaultApiProtocol}>
{(requestDescription) => (
<TextInput
aria-description={requestDescription}
value={baseUrl}
onChange={(value) => {
setEndpoint((current) => ({ ...current, baseUrl: value }));
resetManagedVerification();
clearFieldError('baseUrl');
}}
placeholder={defaults.baseUrl || 'https://…'}
isDisabled={isExperimental || busy}
label={copy.endpointLabel}
isRequired={requiresBaseUrl}
status={
error?.field === 'baseUrl'
? { type: 'error', message: error.message }
: undefined
}
/>
)}
</ProviderEndpointField>
)}
{isCustom && (
<Selector
Expand Down
54 changes: 34 additions & 20 deletions apps/desktop/src/renderer/settings/provider-connection-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
} from '@astryxdesign/core';
import { PROVIDER_REGISTRY } from '@maka/core/llm-connections';
import {
declaredModelApiProtocol,
supportsCustomFastServiceTier,
modelLimitsConflict,
type ModelOverride,
Expand All @@ -55,6 +56,7 @@ import {
} from './runtime-host-settings-target.js';
import { useOAuthLoginFlow } from './use-oauth-login-flow';
import {
ProviderEndpointField,
getProviderSettingsCopy,
parseContextWindowInput,
providerPanelActionErrorMessage,
Expand Down Expand Up @@ -560,26 +562,38 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
onCancel={() => { setBaseUrl(savedBaseUrl); setEditingRow(null); }}
onSave={async () => { if (await save('endpoint')) setEditingRow(null); }}
>
{endpointHasCredentials ? (
<PasswordInput
value={baseUrl}
onChange={setBaseUrl}
placeholder={defaults.baseUrl}
label={copy.endpoint}
isLabelHidden
description={copy.endpointCredentialsMasked}
isDisabled={allActionsBusy}
/>
) : (
<TextInput
label={copy.endpoint}
isLabelHidden
value={baseUrl}
onChange={setBaseUrl}
placeholder={defaults.baseUrl}
isDisabled={allActionsBusy}
/>
)}
<ProviderEndpointField
providerType={connection.providerType}
baseUrl={baseUrl}
apiProtocol={declaredModelApiProtocol(connection, connection.defaultModel)}
>
{(requestDescription) => (
endpointHasCredentials ? (
<PasswordInput
value={baseUrl}
onChange={setBaseUrl}
placeholder={defaults.baseUrl}
label={copy.endpoint}
isLabelHidden
description={<>
{copy.endpointCredentialsMasked}
{requestDescription && <span className="maka-visually-hidden"> {requestDescription}</span>}
</>}
isDisabled={allActionsBusy}
/>
) : (
<TextInput
aria-description={requestDescription}
label={copy.endpoint}
isLabelHidden
value={baseUrl}
onChange={setBaseUrl}
placeholder={defaults.baseUrl}
isDisabled={allActionsBusy}
/>
)
)}
</ProviderEndpointField>
</SettingsExpandableRow>
) : (
<SettingsRow label={copy.endpoint} description={endpointDisplay} align="start" />
Expand Down
Loading