From 78d0a7b12797550088ee08a0c658736b08b7f284 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Thu, 24 Sep 2026 17:13:46 +0800 Subject: [PATCH 1/4] feat(desktop): preview model request URLs while editing Show the final Chat or Responses request URL as custom relay addresses are edited in both connection forms. Hide incomplete drafts and preserve custom prefixes, with wrapping and accessible descriptions for the live preview. Share endpoint normalization between the preview, connection probes, SDK requests and model discovery. Full endpoint inputs previously duplicated /chat/completions or incorrectly appended /models beneath the endpoint. Generated-by: Codex --- .../provider-endpoint-presentation.test.ts | 23 +++++ .../features/connection-settings/index.ts | 2 + .../provider-endpoint-field.tsx | 68 +++++++++++++ .../settings-provider-copy.ts | 3 + .../renderer/settings/provider-add-form.tsx | 40 ++++---- .../settings/provider-connection-detail.tsx | 48 ++++++---- .../styles/settings/provider-editor.css | 14 +++ packages/core/package.json | 1 + packages/core/src/openai-urls.ts | 53 +++++++++++ .../__tests__/openai-relay-endpoints.test.ts | 95 +++++++++++++++++++ packages/runtime/src/model-fetcher.ts | 9 +- packages/runtime/src/model-runtime.ts | 5 +- packages/runtime/src/provider-urls.ts | 25 ++--- packages/runtime/src/test-connection.ts | 4 +- 14 files changed, 330 insertions(+), 60 deletions(-) create mode 100644 apps/desktop/src/renderer/features/connection-settings/provider-endpoint-field.tsx create mode 100644 packages/core/src/openai-urls.ts create mode 100644 packages/runtime/src/__tests__/openai-relay-endpoints.test.ts 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 906c9bb23f..9f72b08015 100644 --- a/apps/desktop/src/main/__tests__/provider-endpoint-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/provider-endpoint-presentation.test.ts @@ -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); @@ -202,3 +204,24 @@ 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('openai-compatible', 'http://localhost:8080/v1'), + 'http://localhost:8080/v1/chat/completions'); + assert.equal(providerRequestUrlPreview('openai-compatible', 'https://relay.example/proxy/chat/completions/'), + 'https://relay.example/proxy/chat/completions'); + assert.equal(providerRequestUrlPreview('openai-responses-compatible', 'https://relay.example/proxy/responses'), + 'https://relay.example/proxy/responses'); + assert.equal(providerRequestUrlPreview('openai-responses-compatible', 'https://relay.example/'), + 'https://relay.example/responses'); +}); + +test('empty, incomplete, unsaveable and model-dependent 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('openai-compatible', draft), null, draft); + } + assert.equal(providerRequestUrlPreview('openai', 'https://relay.example/v1'), null); +}); diff --git a/apps/desktop/src/renderer/features/connection-settings/index.ts b/apps/desktop/src/renderer/features/connection-settings/index.ts index 2a886a4654..f9c6be3657 100644 --- a/apps/desktop/src/renderer/features/connection-settings/index.ts +++ b/apps/desktop/src/renderer/features/connection-settings/index.ts @@ -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'; 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 new file mode 100644 index 0000000000..9970607ea3 --- /dev/null +++ b/apps/desktop/src/renderer/features/connection-settings/provider-endpoint-field.tsx @@ -0,0 +1,68 @@ +/* + * 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 { isRelayProviderType, type LlmConnection, type 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; + children(description: string | undefined): ReactNode; +}) { + const copy = getProviderSettingsCopy(useUiLocale()).shared; + const url = providerRequestUrlPreview(props.providerType, props.baseUrl); + if (!isRelayProviderType(props.providerType)) 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 ( +
+ {props.children(description)} + {description && } +
+ ); +} + +/** Only fixed-protocol relays have one request URL independent of the model. */ +export function providerRequestUrlPreview( + providerType: LlmConnection['providerType'], + draftBaseUrl: string, +): string | null { + if (providerType !== 'openai-compatible' && providerType !== 'openai-responses-compatible') { + 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; + return redactSecrets(providerType === 'openai-compatible' + ? openAiChatUrl(baseUrl) + : openResponsesUrl(baseUrl)); + } catch { + return null; + } +} 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 c013ef52e9..05eda5195a 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 @@ -253,6 +253,7 @@ const zhCopy = { }, }, shared: { + requestUrlLabel: '请求地址:', connectionStale: '连接状态已更新,请刷新列表后再删除。', actionFallback: '模型连接服务暂时不可用,请稍后重试。', rateLimit: '当前账号或模型服务触发速率限制,请稍后重试。', timeout: '请求超时,请检查网络或代理后重试。', unavailable: '模型服务暂时不可用,请稍后重试。', @@ -455,6 +456,7 @@ const zhTwCopy = { }, }, shared: { + requestUrlLabel: '請求地址:', connectionStale: '連線狀態已更新,請重新整理清單後再刪除。', actionFallback: '模型連線服務暫時不可用,請稍後重試。', rateLimit: '目前帳號或模型服務觸發速率限制,請稍後重試。', timeout: '請求超時,請檢查網路或代理後重試。', unavailable: '模型服務暫時不可用,請稍後重試。', @@ -656,6 +658,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.', diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index 90ff855c7f..26788dabc1 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -51,6 +51,7 @@ import { providerDisplay } from './provider-display'; import { useActionGuard } from './use-action-guard'; import { OnboardingStepForm, + ProviderEndpointField, getProviderSettingsCopy, providerPanelActionErrorMessage, type ApiKeyOnboardingBridge, @@ -747,23 +748,28 @@ export function AddProviderForm(props: { } /> ) : ( - { - setBaseUrl(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 - } - /> + + {(requestDescription) => ( + { + setBaseUrl(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 + } + /> + )} + )} {showsDefaultModel && ( { setBaseUrl(savedBaseUrl); setEditingRow(null); }} onSave={async () => { if (await save('endpoint')) setEditingRow(null); }} > - {endpointHasCredentials ? ( - - ) : ( - - )} + + {(requestDescription) => ( + endpointHasCredentials ? ( + + ) : ( + + ) + )} + ) : ( diff --git a/apps/desktop/src/renderer/styles/settings/provider-editor.css b/apps/desktop/src/renderer/styles/settings/provider-editor.css index 8c090fbaff..d64e3538c4 100644 --- a/apps/desktop/src/renderer/styles/settings/provider-editor.css +++ b/apps/desktop/src/renderer/styles/settings/provider-editor.css @@ -44,6 +44,20 @@ color: var(--muted-foreground); } +.providerEndpointField { + display: grid; + gap: 6px; + min-width: 0; + width: 100%; +} + +.providerRequestUrlPreview { + margin: 0; + color: var(--muted-foreground); + font: var(--maka-text-supporting); + overflow-wrap: anywhere; +} + @media (max-width: 980px) { .app { grid-template-columns: 1fr; diff --git a/packages/core/package.json b/packages/core/package.json index fbd5077ea2..0254051d74 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -95,6 +95,7 @@ "./incognito": "./dist/incognito.js", "./backend-types": "./dist/backend-types.js", "./codex-model-compatibility": "./dist/codex-model-compatibility.js", + "./openai-urls": "./dist/openai-urls.js", "./llm-connections": "./dist/llm-connections.js", "./provider-registry": "./dist/provider-registry.js", "./model-catalog": "./dist/model-catalog.js", diff --git a/packages/core/src/openai-urls.ts b/packages/core/src/openai-urls.ts new file mode 100644 index 0000000000..737f7dfa3b --- /dev/null +++ b/packages/core/src/openai-urls.ts @@ -0,0 +1,53 @@ +/* + * 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. + */ + +/** Shared by request previews, connection probes and SDK base URL resolution. */ +export function openAiChatBaseUrl(baseUrl: string): string { + return openAiBaseUrl(baseUrl, '/chat/completions'); +} + +export function openAiChatUrl(baseUrl: string): string { + return openAiRequestUrl(baseUrl, '/chat/completions'); +} + +export function openAiResponsesBaseUrl(baseUrl: string): string { + return openAiBaseUrl(baseUrl, '/responses'); +} + +export function openResponsesUrl(baseUrl: string): string { + return openAiRequestUrl(baseUrl, '/responses'); +} + +function openAiBaseUrl(baseUrl: string, endpoint: string): string { + const url = new URL(baseUrl); + let path = url.pathname.replace(/\/+$/, ''); + // Accept both a base and a full endpoint, including previously duplicated + // suffixes. Preserve the gateway's prefix; never assume or insert /v1. + while (path.toLowerCase().endsWith(endpoint)) { + path = path.slice(0, -endpoint.length).replace(/\/+$/, ''); + } + url.pathname = path; + return url.toString(); +} + +function openAiRequestUrl(baseUrl: string, endpoint: string): string { + const url = new URL(openAiBaseUrl(baseUrl, endpoint)); + url.pathname = `${url.pathname.replace(/\/+$/, '')}${endpoint}`; + return url.toString(); +} diff --git a/packages/runtime/src/__tests__/openai-relay-endpoints.test.ts b/packages/runtime/src/__tests__/openai-relay-endpoints.test.ts new file mode 100644 index 0000000000..ebc8ceae77 --- /dev/null +++ b/packages/runtime/src/__tests__/openai-relay-endpoints.test.ts @@ -0,0 +1,95 @@ +/* + * 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 assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { LlmConnection } from '@maka/core/llm-connections'; +import { getAIModel } from '../model-factory.js'; +import { runConnectionModelDiscoveryEffect } from '../model-fetcher.js'; +import { runConnectionTestEffect } from '../test-connection.js'; + +for (const [providerType, path] of [ + ['openai-compatible', '/chat/completions'], + ['openai-responses-compatible', '/responses'], +] as const) { + test(`${providerType}: full endpoint works for testing, generation and discovery`, async () => { + for (const prefix of ['', '/v1', '/gateway/team/api']) { + for (const suffix of ['', path, `${path}/`, `${path}${path}`]) { + const base = `https://relay.example${prefix}`; + const connection: LlmConnection = { + slug: 'relay', + name: 'Relay', + providerType, + enabled: true, + baseUrl: `${base}${suffix}`, + defaultModel: 'relay-model', + enabledModelIds: ['relay-model'], + createdAt: 0, + updatedAt: 0, + }; + const urls: string[] = []; + const fetch: typeof globalThis.fetch = async (input) => { + const url = input instanceof Request ? input.url : String(input); + urls.push(url); + return Response.json( + url.endsWith('/models') + ? { data: [{ id: 'relay-model' }] } + : providerType === 'openai-compatible' + ? { + id: 'chat', + object: 'chat.completion', + created: 0, + model: 'relay-model', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hi' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + } + : { + id: 'response', + object: 'response', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + ); + }; + const tested = await runConnectionTestEffect(connection, 'test-key', { fetch }); + assert.equal(tested.ok, true); + const model = getAIModel({ connection, apiKey: 'test-key', modelId: 'relay-model', fetch }); + await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'Hi' }] }], + }); + const discovered = await runConnectionModelDiscoveryEffect(connection, 'test-key', { + fetch, + }); + assert.equal(discovered.ok, true); + assert.deepEqual( + urls, + [`${base}${path}`, `${base}${path}`, `${base}/models`], + `${base}${suffix}`, + ); + } + } + }); +} diff --git a/packages/runtime/src/model-fetcher.ts b/packages/runtime/src/model-fetcher.ts index eafe33e807..1ad0a08037 100644 --- a/packages/runtime/src/model-fetcher.ts +++ b/packages/runtime/src/model-fetcher.ts @@ -17,6 +17,7 @@ * under the License. */ +import { openAiChatBaseUrl, openAiResponsesBaseUrl } from '@maka/core/openai-urls'; import { PROVIDER_REGISTRY, providerFallbackModelIds, @@ -157,7 +158,13 @@ async function fetchProviderModelsStrict( apiKey: string, fetchFn: ConnectionEffectFetch | undefined, ): Promise { - const baseUrl = effectiveBaseUrl(connection); + const configuredBaseUrl = effectiveBaseUrl(connection); + const baseUrl = + configuredBaseUrl && connection.providerType === 'openai-compatible' + ? openAiChatBaseUrl(configuredBaseUrl) + : configuredBaseUrl && connection.providerType === 'openai-responses-compatible' + ? openAiResponsesBaseUrl(configuredBaseUrl) + : configuredBaseUrl; const definition = PROVIDER_REGISTRY[connection.providerType]; // Unknown providerType → no discovery path. Throw a clear error (caught and // generalized by the caller) rather than crashing on `.modelDiscovery`. diff --git a/packages/runtime/src/model-runtime.ts b/packages/runtime/src/model-runtime.ts index fdea6eb37c..bd5e4c43f0 100644 --- a/packages/runtime/src/model-runtime.ts +++ b/packages/runtime/src/model-runtime.ts @@ -36,6 +36,7 @@ import { anthropicV1BaseUrl, googleV1BetaBaseUrl, openAiResponsesBaseUrl, + openAiChatBaseUrl, } from './provider-urls.js'; import { resolveApplyPatchProfile, type ApplyPatchProfile } from './apply-patch-profile.js'; @@ -149,7 +150,9 @@ export function resolveModelRuntime( ? anthropicV1BaseUrl(resolvedBaseUrl) : wire === 'openai-responses' && resolvedBaseUrl ? openAiResponsesBaseUrl(resolvedBaseUrl) - : resolvedBaseUrl; + : wire === 'openai-chat' && resolvedBaseUrl + ? openAiChatBaseUrl(resolvedBaseUrl) + : resolvedBaseUrl; const parallelToolCalls = resolveParallelToolCalls(connection, modelId, baseAdapter); return { ...call, diff --git a/packages/runtime/src/provider-urls.ts b/packages/runtime/src/provider-urls.ts index f7ddbffc6a..1089a302a7 100644 --- a/packages/runtime/src/provider-urls.ts +++ b/packages/runtime/src/provider-urls.ts @@ -55,25 +55,12 @@ export function googleApiUrl(baseUrl: string, path: string, apiKey: string): str return `${googleV1BetaBaseUrl(baseUrl)}${cleanPath}?key=${encodeURIComponent(apiKey)}`; } -/** Normalize an Open Responses endpoint without assuming a `/v1` prefix. */ -export function openResponsesUrl(baseUrl: string): string { - const url = new URL(baseUrl); - const basePath = url.pathname.replace(/\/+$/, '').replace(/\/responses$/i, ''); - url.pathname = `${basePath}/responses`; - return url.toString(); -} - -/** - * Inverse of {@link openResponsesUrl} for the native OpenAI adapter: it - * appends `/responses` internally, so an endpoint-form override - * (`…/v1/responses`, which the probe accepts) must be reduced back to its - * base or the model request lands on `/responses/responses` (#2972). - */ -export function openAiResponsesBaseUrl(baseUrl: string): string { - const url = new URL(baseUrl); - url.pathname = url.pathname.replace(/\/+$/, '').replace(/\/responses$/i, ''); - return url.toString(); -} +export { + openAiChatBaseUrl, + openAiChatUrl, + openAiResponsesBaseUrl, + openResponsesUrl, +} from '@maka/core/openai-urls'; function stripTrailing(u: string): string { return u.replace(/\/+$/, ''); diff --git a/packages/runtime/src/test-connection.ts b/packages/runtime/src/test-connection.ts index 67e9bf890c..71a6d83e35 100644 --- a/packages/runtime/src/test-connection.ts +++ b/packages/runtime/src/test-connection.ts @@ -29,7 +29,7 @@ import { type ConnectionTestResult, type LlmConnection, } from '@maka/core/llm-connections'; -import { openResponsesUrl } from './provider-urls.js'; +import { openAiChatUrl, openResponsesUrl } from './provider-urls.js'; import { resolveModelRuntime } from './model-runtime.js'; import { fetchGitHubCopilotModels } from './model-fetcher.js'; import { @@ -384,7 +384,7 @@ async function probeOpenAI( // chat with the provider error class. return { ok: true, latencyMs: Date.now() - t0, modelTested: model }; } - const r = await fetchForConnectionEffect(fetchFn, `${stripTrailing(baseUrl)}/chat/completions`, { + const r = await fetchForConnectionEffect(fetchFn, openAiChatUrl(baseUrl), { method: 'POST', headers: { ...requestHeaders, From 0c42c1e281545b574375f1926664feae1a324a19 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Thu, 24 Sep 2026 17:45:50 +0800 Subject: [PATCH 2/4] chore(desktop): refresh Astryx surface inventory Register the new provider endpoint field in both generated inventories so the CI coverage gate matches the renderer tree. Generated-by: Codex --- docs/astryx-surface-file-inventory.md | 3 ++- docs/astryx-surface-file-inventory.paths | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 31a0964226..a832682325 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.2` (195 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 295 files — blocker 0, reimplementation 0, polish 4, aligned 291. +**Totals:** 296 files — blocker 0, reimplementation 0, polish 4, aligned 292. ## Exclusions (explicit) @@ -60,6 +60,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/connection-settings/onboarding-step-form.tsx` | dialog-overlay | VStack | aligned — uses Astryx (VStack) | aligned | | `apps/desktop/src/renderer/features/connection-settings/provider-add-model-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, TextInput | aligned — uses Astryx (Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, TextInput) | aligned | | `apps/desktop/src/renderer/features/connection-settings/provider-capability-editor.tsx` | other | DropdownMenu, DropdownMenuCheckboxItem, Field, FormLayout, Selector, TextInput | aligned — uses Astryx (DropdownMenu, DropdownMenuCheckboxItem, Field, FormLayout, Selector, TextInput) | aligned | +| `apps/desktop/src/renderer/features/connection-settings/provider-endpoint-field.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/connection-settings/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/conversation/controller/live-turn-reconciler.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/conversation/controller/session-local-messages.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index 155185e32a..0f96f6bd0a 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -30,6 +30,7 @@ apps/desktop/src/renderer/features/connection-settings/generic-provider-mark.tsx apps/desktop/src/renderer/features/connection-settings/onboarding-step-form.tsx apps/desktop/src/renderer/features/connection-settings/provider-add-model-dialog.tsx apps/desktop/src/renderer/features/connection-settings/provider-capability-editor.tsx +apps/desktop/src/renderer/features/connection-settings/provider-endpoint-field.tsx apps/desktop/src/renderer/features/connection-settings/services-context.tsx apps/desktop/src/renderer/features/conversation/controller/live-turn-reconciler.tsx apps/desktop/src/renderer/features/conversation/controller/session-local-messages.tsx From 8d6572195bbff5dee26a0186aa1a19e894f52bb9 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Thu, 24 Sep 2026 17:58:50 +0800 Subject: [PATCH 3/4] test(runtime): tolerate added Vercel reasoning levels The refreshed models.dev snapshot adds minimal and xhigh for the exact Vercel model. Verify the namespace and wire value of every advertised effort instead of freezing its full catalog list, retaining explicit high, off and exact-model-ID assertions. Generated-by: Codex --- .../src/__tests__/model-factory-thinking.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index 4ce90a8994..5ab101b720 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -717,10 +717,13 @@ describe('buildProviderOptions: thinking level', () => { }); test('Vercel Gateway sends reasoning effort under its stable namespace and exact model id', () => { - assert.deepEqual( - [...thinkingVariantsForModel('vercel', 'openai/gpt-5.1-thinking')], - ['off', 'low', 'medium', 'high'], - ); + // models.dev may add effort levels; every advertised level must keep the + // same namespace and wire mapping without pinning the entire catalog set. + for (const level of thinkingVariantsForModel('vercel', 'openai/gpt-5.1-thinking')) { + assert.deepEqual(buildProviderOptions(conn('vercel'), 'openai/gpt-5.1-thinking', level), { + vercel: { reasoningEffort: level === 'off' ? 'none' : level }, + }); + } assert.deepEqual(buildProviderOptions(conn('vercel'), 'openai/gpt-5.1-thinking', 'high'), { vercel: { reasoningEffort: 'high' }, }); From 4ddb2b1507399b53dbd1338be8a1e789f9e64708 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Fri, 25 Sep 2026 18:25:28 +0800 Subject: [PATCH 4/4] fix(runtime): align OpenAI endpoint normalization across providers Normalize model discovery in the resolved OpenAI adapter branch and compose Kimi Chat endpoint stripping with version-prefix normalization. Preserve one visible endpoint preview for legacy credential edits and document display redaction and endpoint-path ambiguity. Cover non-custom providers, Kimi probe and SDK URLs, and accessible legacy endpoint previews with regressions that fail before the fix. Generated-by: Codex --- .../connection-settings-locale-render.test.ts | 43 ++++++ .../provider-endpoint-presentation.test.ts | 7 + .../provider-endpoint-field.tsx | 2 + .../settings/provider-connection-detail.tsx | 7 +- packages/core/src/openai-urls.ts | 4 +- .../__tests__/openai-relay-endpoints.test.ts | 130 ++++++++++++------ packages/runtime/src/model-fetcher.ts | 14 +- packages/runtime/src/model-runtime.ts | 2 +- 8 files changed, 149 insertions(+), 60 deletions(-) diff --git a/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts b/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts index f5ae3f34e0..aa541c5b40 100644 --- a/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts +++ b/apps/desktop/src/main/__tests__/connection-settings-locale-render.test.ts @@ -369,6 +369,49 @@ test('endpoint editing previews the default model protocol override', async () = ); }); +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('button[aria-label="Edit: Service URL"]'); + assert.ok(edit); + await act(async () => edit.click()); + const input = harness.document.querySelector('.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)[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(); 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 bc427c4fa5..2d6ddaeeb2 100644 --- a/apps/desktop/src/main/__tests__/provider-endpoint-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/provider-endpoint-presentation.test.ts @@ -224,6 +224,13 @@ test('switching a custom connection protocol replaces the full OpenAI endpoint', '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//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', 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 b18e0c0923..b470a62004 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 @@ -61,6 +61,8 @@ export function providerRequestUrlPreview( 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)); diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index 830ce7364c..14dadd5f5d 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -575,9 +575,10 @@ function ConnectionDetailInner(props: ConnectionDetailProps) { placeholder={defaults.baseUrl} label={copy.endpoint} isLabelHidden - description={requestDescription - ? `${copy.endpointCredentialsMasked} ${requestDescription}` - : copy.endpointCredentialsMasked} + description={<> + {copy.endpointCredentialsMasked} + {requestDescription && {requestDescription}} + } isDisabled={allActionsBusy} /> ) : ( diff --git a/packages/core/src/openai-urls.ts b/packages/core/src/openai-urls.ts index 35d273add1..c17febe982 100644 --- a/packages/core/src/openai-urls.ts +++ b/packages/core/src/openai-urls.ts @@ -34,12 +34,14 @@ export function openResponsesUrl(baseUrl: string): string { return openAiRequestUrl(baseUrl, '/responses'); } -function openAiBaseUrl(baseUrl: string): string { +export function openAiBaseUrl(baseUrl: string): string { const url = new URL(baseUrl); let path = url.pathname.replace(/\/+$/, ''); // Accept both a base and a full endpoint, including previously duplicated // suffixes. Models on one connection may use either OpenAI protocol. // Preserve the gateway's prefix; never assume or insert /v1. + // A terminal OpenAI endpoint is always treated as an endpoint: a gateway + // mounted at that exact path cannot be distinguished from a full request URL. const endpoint = /\/(?:chat\/completions|responses)$/i; while (endpoint.test(path)) { path = path.replace(endpoint, '').replace(/\/+$/, ''); diff --git a/packages/runtime/src/__tests__/openai-relay-endpoints.test.ts b/packages/runtime/src/__tests__/openai-relay-endpoints.test.ts index 05cf3769e3..c9ebe63c02 100644 --- a/packages/runtime/src/__tests__/openai-relay-endpoints.test.ts +++ b/packages/runtime/src/__tests__/openai-relay-endpoints.test.ts @@ -24,57 +24,29 @@ import { getAIModel } from '../model-factory.js'; import { runConnectionModelDiscoveryEffect } from '../model-fetcher.js'; import { runConnectionTestEffect } from '../test-connection.js'; -for (const [apiProtocol, path] of [ - ['openai-chat', '/chat/completions'], - ['openai-responses', '/responses'], +for (const [providerType, apiProtocol] of [ + ['custom', 'openai-chat'], + ['custom', 'openai-responses'], + ['openai', 'openai-chat'], + ['openai', 'openai-responses'], + ['openrouter', 'openai-chat'], + ['deepseek', 'openai-chat'], + ['deepseek', 'openai-responses'], ] as const) { - test(`${apiProtocol}: full endpoint works for testing, generation and discovery`, async () => { + const path = apiProtocol === 'openai-chat' ? '/chat/completions' : '/responses'; + test(`${providerType} ${apiProtocol}: full endpoint works for testing, generation and discovery`, async () => { for (const prefix of ['', '/v1', '/gateway/team/api']) { for (const suffix of ['', '/chat/completions', '/responses', `${path}/`, `${path}${path}`]) { const base = `https://relay.example${prefix}`; const connection: LlmConnection = { - slug: 'relay', - name: 'Relay', - providerType: 'custom', - defaultApiProtocol: apiProtocol, - enabled: true, - baseUrl: `${base}${suffix}`, - defaultModel: 'relay-model', - enabledModelIds: ['relay-model'], - createdAt: 0, - updatedAt: 0, + ...relayConnection(`${base}${suffix}`), + providerType, + ...(providerType === 'custom' + ? { defaultApiProtocol: apiProtocol } + : { modelOverrides: { 'relay-model': { apiProtocol } } }), }; const urls: string[] = []; - const fetch: typeof globalThis.fetch = async (input) => { - const url = input instanceof Request ? input.url : String(input); - urls.push(url); - return Response.json( - url.endsWith('/models') - ? { data: [{ id: 'relay-model' }] } - : apiProtocol === 'openai-chat' - ? { - id: 'chat', - object: 'chat.completion', - created: 0, - model: 'relay-model', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Hi' }, - finish_reason: 'stop', - }, - ], - usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - } - : { - id: 'response', - object: 'response', - status: 'completed', - output: [], - usage: { input_tokens: 1, output_tokens: 1 }, - }, - ); - }; + const fetch = recordingFetch(urls); const tested = await runConnectionTestEffect(connection, 'test-key', { fetch }); assert.equal(tested.ok, true); const model = getAIModel({ connection, apiKey: 'test-key', modelId: 'relay-model', fetch }); @@ -94,3 +66,73 @@ for (const [apiProtocol, path] of [ } }); } + +test('Kimi Chat probes and SDK requests strip endpoints before adding the version prefix', async () => { + for (const prefix of ['', '/v1', '/gateway/team/api', '/gateway/team/api/v1']) { + for (const suffix of [ + '', + '/chat/completions', + '/responses/', + '/chat/completions/chat/completions', + ]) { + const base = `https://relay.example${prefix}`; + const connection: LlmConnection = { + ...relayConnection(`${base}${suffix}`), + providerType: 'kimi-coding-plan', + modelOverrides: { 'relay-model': { apiProtocol: 'openai-chat' } }, + }; + const urls: string[] = []; + const fetch = recordingFetch(urls); + assert.equal((await runConnectionTestEffect(connection, 'test-key', { fetch })).ok, true); + await getAIModel({ + connection, + apiKey: 'test-key', + modelId: 'relay-model', + fetch, + }).doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'Hi' }] }], + }); + const expected = `${base.replace(/\/v1$/, '')}/v1/chat/completions`; + assert.deepEqual(urls, [expected, expected], `${base}${suffix}`); + } + } +}); + +function relayConnection(baseUrl: string): LlmConnection { + return { + slug: 'relay', + name: 'Relay', + providerType: 'custom', + enabled: true, + baseUrl, + defaultModel: 'relay-model', + enabledModelIds: ['relay-model'], + createdAt: 0, + updatedAt: 0, + }; +} + +function recordingFetch(urls: string[]): typeof globalThis.fetch { + return async (input) => { + const url = input instanceof Request ? input.url : String(input); + urls.push(url); + if (url.endsWith('/models')) return Response.json({ data: [{ id: 'relay-model' }] }); + if (url.endsWith('/responses')) { + return Response.json({ + id: 'response', + object: 'response', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1 }, + }); + } + return Response.json({ + id: 'chat', + object: 'chat.completion', + created: 0, + model: 'relay-model', + choices: [{ index: 0, message: { role: 'assistant', content: 'Hi' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }); + }; +} diff --git a/packages/runtime/src/model-fetcher.ts b/packages/runtime/src/model-fetcher.ts index c489fc6f98..81c7a214f4 100644 --- a/packages/runtime/src/model-fetcher.ts +++ b/packages/runtime/src/model-fetcher.ts @@ -17,7 +17,7 @@ * under the License. */ -import { openAiChatBaseUrl, openAiResponsesBaseUrl } from '@maka/core/openai-urls'; +import { openAiBaseUrl } from '@maka/core/openai-urls'; import { PROVIDER_REGISTRY, providerFallbackModelIds, @@ -158,15 +158,7 @@ async function fetchProviderModelsStrict( apiKey: string, fetchFn: ConnectionEffectFetch | undefined, ): Promise { - const configuredBaseUrl = effectiveBaseUrl(connection); - const baseUrl = - configuredBaseUrl && connection.providerType === 'custom' - ? connection.defaultApiProtocol === 'openai-responses' - ? openAiResponsesBaseUrl(configuredBaseUrl) - : connection.defaultApiProtocol !== 'anthropic-messages' - ? openAiChatBaseUrl(configuredBaseUrl) - : configuredBaseUrl - : configuredBaseUrl; + const baseUrl = effectiveBaseUrl(connection); const definition = PROVIDER_REGISTRY[connection.providerType]; // Unknown providerType → no discovery path. Throw a clear error (caught and // generalized by the caller) rather than crashing on `.modelDiscovery`. @@ -235,7 +227,7 @@ async function fetchProviderModelsStrict( case 'openai-compatible': { const r = await fetchForConnectionEffect( fetchFn, - modelListUrl(baseUrl, discovery.path, discovery.query), + modelListUrl(openAiBaseUrl(baseUrl), discovery.path, discovery.query), { headers: { 'content-type': 'application/json', diff --git a/packages/runtime/src/model-runtime.ts b/packages/runtime/src/model-runtime.ts index 735b24eda7..bdc4948e16 100644 --- a/packages/runtime/src/model-runtime.ts +++ b/packages/runtime/src/model-runtime.ts @@ -153,7 +153,7 @@ export function resolveModelRuntime( : adapter.kind === 'google' && adapter.normalizeBaseUrl !== false ? googleV1BetaBaseUrl(resolvedBaseUrl) : adapter.kind === 'openai-compatible' && adapter.normalizeBaseUrl - ? anthropicV1BaseUrl(resolvedBaseUrl) + ? anthropicV1BaseUrl(openAiChatBaseUrl(resolvedBaseUrl)) : wire === 'openai-responses' && resolvedBaseUrl ? openAiResponsesBaseUrl(resolvedBaseUrl) : wire === 'openai-chat' && resolvedBaseUrl