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 d2f4be77f0..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 @@ -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('.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)[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('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('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 625cdd1920..2d6ddaeeb2 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,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//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); +}); 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..b470a62004 --- /dev/null +++ b/apps/desktop/src/renderer/features/connection-settings/provider-endpoint-field.tsx @@ -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 ( +
+ {props.children(description)} + {description && } +
+ ); +} + +/** 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; + } +} 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 816d07a8c4..21e05b797c 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 @@ -269,6 +269,7 @@ const zhCopy = { }, }, shared: { + requestUrlLabel: '请求地址:', connectionStale: '连接状态已更新,请刷新列表后再删除。', actionFallback: '模型连接服务暂时不可用,请稍后重试。', rateLimit: '当前账号或模型服务触发速率限制,请稍后重试。', timeout: '请求超时,请检查网络或代理后重试。', unavailable: '模型服务暂时不可用,请稍后重试。', @@ -471,6 +472,7 @@ const zhTwCopy = { }, }, shared: { + requestUrlLabel: '請求地址:', connectionStale: '連線狀態已更新,請重新整理清單後再刪除。', actionFallback: '模型連線服務暫時不可用,請稍後重試。', rateLimit: '目前帳號或模型服務觸發速率限制,請稍後重試。', timeout: '請求超時,請檢查網路或代理後重試。', unavailable: '模型服務暫時不可用,請稍後重試。', @@ -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.', diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index fdeafe07cc..80aafac722 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -56,6 +56,7 @@ import { providerDisplay } from './provider-display'; import { useActionGuard } from './use-action-guard'; import { OnboardingStepForm, + ProviderEndpointField, getProviderSettingsCopy, providerPanelActionErrorMessage, type ApiKeyOnboardingBridge, @@ -758,23 +759,28 @@ export function AddProviderForm(props: { } /> ) : ( - { - 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 - } - /> + + {(requestDescription) => ( + { + 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 + } + /> + )} + )} {isCustom && ( { setBaseUrl(savedBaseUrl); setEditingRow(null); }} onSave={async () => { if (await save('endpoint')) setEditingRow(null); }} > - {endpointHasCredentials ? ( - - ) : ( - - )} + + {(requestDescription) => ( + endpointHasCredentials ? ( + + {copy.endpointCredentialsMasked} + {requestDescription && {requestDescription}} + } + isDisabled={allActionsBusy} + /> + ) : ( + + ) + )} + ) : ( 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/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 455397fd2a..d2d4981260 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:** 298 files — blocker 0, reimplementation 0, polish 4, aligned 294. +**Totals:** 299 files — blocker 0, reimplementation 0, polish 4, aligned 295. ## 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 03477d1e05..a6c4762bb1 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 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..c17febe982 --- /dev/null +++ b/packages/core/src/openai-urls.ts @@ -0,0 +1,57 @@ +/* + * 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); +} + +export function openAiChatUrl(baseUrl: string): string { + return openAiRequestUrl(baseUrl, '/chat/completions'); +} + +export function openAiResponsesBaseUrl(baseUrl: string): string { + return openAiBaseUrl(baseUrl); +} + +export function openResponsesUrl(baseUrl: string): string { + return openAiRequestUrl(baseUrl, '/responses'); +} + +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(/\/+$/, ''); + } + url.pathname = path; + return url.toString(); +} + +function openAiRequestUrl(baseUrl: string, endpoint: string): string { + const url = new URL(openAiBaseUrl(baseUrl)); + url.pathname = `${url.pathname.replace(/\/+$/, '')}${endpoint}`; + return url.toString(); +} diff --git a/packages/runtime-host/protocol-compatible-changes/executor-catalog-browser-safe.json b/packages/runtime-host/protocol-compatible-changes/executor-catalog-browser-safe.json index fd46b90c84..47bc0dc100 100644 --- a/packages/runtime-host/protocol-compatible-changes/executor-catalog-browser-safe.json +++ b/packages/runtime-host/protocol-compatible-changes/executor-catalog-browser-safe.json @@ -1,5 +1,5 @@ { - "epoch": 188, + "epoch": 189, "files": ["packages/runtime-host/src/protocol/plugin-platform.ts"], - "reason": "The browser-safe refactor moves the unchanged executor catalog normalizer from the Node-only runtime service into core and updates its import. Against the cumulative protocol through epoch 188, this relocation does not change catalog validation, accepted values, output fields, errors, or wire encoding; the renderer no longer evaluates the service's Node crypto dependency." + "reason": "The browser-safe refactor moves the unchanged executor catalog normalizer from the Node-only runtime service into core and updates its import. Against the cumulative protocol through epoch 189, this relocation does not change catalog validation, accepted values, output fields, errors, or wire encoding; the renderer no longer evaluates the service's Node crypto dependency." } diff --git a/packages/runtime-host/protocol-compatible-changes/issue-4032-composition-identity.json b/packages/runtime-host/protocol-compatible-changes/issue-4032-composition-identity.json index ebfdc31285..7843c9d4b0 100644 --- a/packages/runtime-host/protocol-compatible-changes/issue-4032-composition-identity.json +++ b/packages/runtime-host/protocol-compatible-changes/issue-4032-composition-identity.json @@ -1,5 +1,5 @@ { - "epoch": 188, + "epoch": 189, "files": ["packages/runtime-host/src/protocol/index.ts"], - "reason": "Against the cumulative protocol through epoch 188, this moves the existing interactive composition ID constant into a lightweight module while preserving the exact protocol export and encoded value" + "reason": "Against the cumulative protocol through epoch 189, this moves the existing interactive composition ID constant into a lightweight module while preserving the exact protocol export and encoded value" } diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index 554681436f..b153a07319 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -731,13 +731,15 @@ 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', 'minimal', 'low', 'medium', 'high', 'xhigh'], - ); - for (const level of ['minimal', 'low', 'medium', 'high', 'xhigh'] as const) { + // Keep the known effort levels covered while allowing models.dev to add + // levels; every advertised level must retain its namespace and wire mapping. + const levels = thinkingVariantsForModel('vercel', 'openai/gpt-5.1-thinking'); + for (const level of ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const) { + assert.ok(levels.includes(level), `Vercel must advertise ${level}`); + } + for (const level of levels) { assert.deepEqual(buildProviderOptions(conn('vercel'), 'openai/gpt-5.1-thinking', level), { - vercel: { reasoningEffort: level }, + vercel: { reasoningEffort: level === 'off' ? 'none' : level }, }); } assert.deepEqual(buildProviderOptions(conn('vercel'), 'openai/gpt-5.1-thinking', 'off'), { 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..c9ebe63c02 --- /dev/null +++ b/packages/runtime/src/__tests__/openai-relay-endpoints.test.ts @@ -0,0 +1,138 @@ +/* + * 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, 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) { + 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 = { + ...relayConnection(`${base}${suffix}`), + providerType, + ...(providerType === 'custom' + ? { defaultApiProtocol: apiProtocol } + : { modelOverrides: { 'relay-model': { apiProtocol } } }), + }; + const urls: string[] = []; + 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 }); + 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}`, + ); + } + } + }); +} + +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 c0098a1761..81c7a214f4 100644 --- a/packages/runtime/src/model-fetcher.ts +++ b/packages/runtime/src/model-fetcher.ts @@ -17,6 +17,7 @@ * under the License. */ +import { openAiBaseUrl } from '@maka/core/openai-urls'; import { PROVIDER_REGISTRY, providerFallbackModelIds, @@ -226,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 e8aa46e5d5..bdc4948e16 100644 --- a/packages/runtime/src/model-runtime.ts +++ b/packages/runtime/src/model-runtime.ts @@ -41,6 +41,7 @@ import { anthropicV1BaseUrl, googleV1BetaBaseUrl, openAiResponsesBaseUrl, + openAiChatBaseUrl, } from './provider-urls.js'; import { resolveApplyPatchProfile, type ApplyPatchProfile } from './apply-patch-profile.js'; @@ -152,10 +153,12 @@ 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) - : 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,