Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
8f7ec8f
feat(connections)!: unify custom connections with per-model protocols
Astro-Han Sep 24, 2026
68957e6
fix(runtime-host): bump the compatibility epoch for the custom connec…
Astro-Han Sep 24, 2026
179b31e
fix(ui): keep the custom connection protocol in the add form's endpoi…
Astro-Han Sep 24, 2026
f69a20d
fix(runtime-host): reject reusing a custom connection on another prot…
Astro-Han Sep 24, 2026
b983403
fix(storage): read migrated custom connections that have no base URL
Astro-Han Sep 24, 2026
47a7faa
fix(runtime): send minimal thinking as low effort on custom Messages
Astro-Han Sep 24, 2026
391243a
fix(storage): keep hosted web search on migrated anthropic-compatible…
Astro-Han Sep 24, 2026
cb4ebf2
refactor(config): skip legacy custom types on config import instead o…
Astro-Han Sep 24, 2026
e8fec7a
fix(core): put requireBaseUrl back on custom, not opencode
Astro-Han Sep 24, 2026
5c13db2
fix(desktop): overwrite a custom import in place when only its protoc…
Astro-Han Sep 24, 2026
29458e7
fix(runtime-host): compare the requested model's protocol when reusin…
Astro-Han Sep 24, 2026
b6c5cfe
fix(storage): keep hosted search for a listed but disabled deepseek-v…
Astro-Han Sep 24, 2026
d89a6dc
fix(runtime-host): match a hosted target on the connection default pr…
Astro-Han Sep 24, 2026
43d3e75
Merge remote-tracking branch 'origin/main' into feat/5673-unified-cus…
Astro-Han Sep 24, 2026
8685af2
test(runtime): expect the refreshed Vercel GPT-5.1 thinking efforts
Astro-Han Sep 24, 2026
006796c
fix(storage): skip an import overwrite that would change a custom pro…
Astro-Han Sep 24, 2026
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 @@ -236,7 +236,7 @@ for (const copy of localeCases) {
fetchModels: async () => { calls.push('fetchModels'); throw new Error('unexpected discovery'); },
} as unknown as ConnectionsBridge;
await harness.render(copy.locale, createElement(components.AddProviderForm, {
bridge, providerType: 'openai-compatible', existingSlugs: ['taken'],
bridge, providerType: 'custom', existingSlugs: ['taken'],
onCancel: unexpectedCall, onCreated: unexpectedCall,
}));
const input = harness.document.querySelector<HTMLInputElement>('input[placeholder="my-provider"]');
Expand Down Expand Up @@ -417,9 +417,10 @@ function relayConnection(): ProjectedLlmConnection {
const modelId = 'gpt-5.6-sol-joybuilder';
return {
connectionId: 'relay-connection',
slug: 'openai-responses-compatible-2',
slug: 'custom-2',
name: '自定义中转站(OpenAI Responses)',
providerType: 'openai-responses-compatible',
providerType: 'custom',
defaultApiProtocol: 'openai-responses',
baseUrl: 'https://relay.example/v1',
defaultModel: modelId,
enabledModelIds: [modelId],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ describe('model catalog picker helpers', () => {
connection({
slug: 'openrouter',
name: 'Openrouter',
providerType: 'openai-compatible',
providerType: 'custom',
defaultApiProtocol: 'openai-chat',
models: [{ id: 'anthropic/claude-sonnet-5' }],
modelSource: 'fetched',
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ function modelChoice(model: string, supportsVision: boolean): ChatModelChoice {
return {
connectionId: 'connection-test',
connectionSlug: 'test',
providerType: 'openai-compatible',
providerType: 'custom',
providerLabel: 'Test',
model,
label: model,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ import { createOnboardingService } from '../onboarding-service.js';

const storedConnection = {
connectionId: 'connection-1', slug: 'primary', name: 'Primary',
providerType: 'openai-compatible' as const, defaultModel: 'gpt-5',
providerType: 'custom' as const, defaultApiProtocol: 'openai-chat' as const,
baseUrl: 'https://relay.example/v1', defaultModel: 'gpt-5',
enabled: true, createdAt: 1, updatedAt: 1,
};
const connection: ProjectedLlmConnection = {
Expand Down
62 changes: 33 additions & 29 deletions apps/desktop/src/main/__tests__/provider-add-submission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,9 @@ const onboardingSaveInput: Parameters<ApiKeyOnboardingBridge['save']>[0] = {
enabledModelIds: ['gpt-5'],
};

const RELAY_TYPES: readonly ProviderType[] = ['openai-compatible', 'openai-responses-compatible'];

function draft(over: Partial<AddProviderDraft> = {}): AddProviderDraft {
return {
providerType: 'openai-compatible',
providerType: 'custom',
slug: 'house-relay',
existingSlugs: [],
apiKey: 'sk-test',
Expand All @@ -86,14 +84,24 @@ function connection(slug: string): IdentifiedLlmConnection {
connectionId: `connection-${slug}`,
slug,
name: slug,
providerType: 'openai-compatible',
providerType: 'custom',
defaultApiProtocol: 'openai-chat',
baseUrl: 'https://relay.example.com/v1',
defaultModel: '',
enabled: true,
createdAt: 0,
updatedAt: 0,
} as IdentifiedLlmConnection;
}

const CUSTOM_INPUT: CreateConnectionInput = {
slug: 'house-relay',
name: 'House',
providerType: 'custom',
defaultApiProtocol: 'openai-chat',
baseUrl: 'https://relay.example.com/v1',
};

function bridge(over: {
create?: (input: CreateConnectionInput) => Promise<IdentifiedLlmConnection>;
fetchModels?: (connection: { readonly connectionId: string; readonly slug: string }) => Promise<unknown>;
Expand All @@ -105,12 +113,10 @@ function bridge(over: {
}

// The first of the two behaviours this module exists to protect. A custom
// relay used to be the only provider class that refused to be created without
// a hand-typed model id — before the app had asked the relay what it serves.
test('a custom relay is created without a hand-typed model id', () => {
for (const providerType of RELAY_TYPES) {
assert.equal(validateAddProviderDraft(draft({ providerType })), null, providerType);
}
// connection used to be the only provider class that refused to be created
// without a hand-typed model id — before the app had asked it what it serves.
test('a custom connection is created without a hand-typed model id', () => {
assert.equal(validateAddProviderDraft(draft()), null);
});

test('no provider type demands a model id at creation', () => {
Expand All @@ -134,21 +140,19 @@ test('no provider type demands a model id at creation', () => {
});

// The second. Discovery failures were reported for every provider except the
// custom relays, which are the endpoints most likely to be misconfigured.
test('a discovery failure reaches the caller for a custom relay', async () => {
for (const providerType of RELAY_TYPES) {
const failure = new Error('relay refused /v1/models');
const created = await createProviderWithDiscovery(
bridge({
fetchModels: async () => {
throw failure;
},
}),
{ slug: 'house-relay', name: 'House', providerType } as CreateConnectionInput,
);
assert.equal(created.connection.slug, 'house-relay');
assert.equal(created.modelDiscoveryError, failure, providerType);
}
// custom connections, which are the endpoints most likely to be misconfigured.
test('a discovery failure reaches the caller for a custom connection', async () => {
const failure = new Error('relay refused /v1/models');
const created = await createProviderWithDiscovery(
bridge({
fetchModels: async () => {
throw failure;
},
}),
CUSTOM_INPUT,
);
assert.equal(created.connection.slug, 'house-relay');
assert.equal(created.modelDiscoveryError, failure);
});

test('a discovery failure reaches the caller for a built-in provider too', async () => {
Expand All @@ -174,15 +178,15 @@ test('a failed catalog fetch still yields the created connection', async () => {
throw new Error('ECONNREFUSED');
},
}),
{ slug: 'house-relay', name: 'House', providerType: 'openai-compatible' } as CreateConnectionInput,
CUSTOM_INPUT,
);
assert.equal(created.connection.slug, 'house-relay');
});

test('a successful catalog fetch reports no error', async () => {
const created = await createProviderWithDiscovery(
bridge({}),
{ slug: 'house-relay', name: 'House', providerType: 'openai-compatible' } as CreateConnectionInput,
CUSTOM_INPUT,
);
assert.equal(created.modelDiscoveryError, undefined);
});
Expand Down Expand Up @@ -215,7 +219,7 @@ test('a create failure propagates instead of being reported as a discovery probl
throw failure;
},
}),
{ slug: 'house-relay', name: 'House', providerType: 'openai-compatible' } as CreateConnectionInput,
CUSTOM_INPUT,
),
failure,
);
Expand Down Expand Up @@ -281,7 +285,7 @@ test('routes only fixed-endpoint API-key drafts without request customization to
hasRequestBodyOverlay: true,
}), { kind: 'legacy', reason: 'request_body' });
assert.deepEqual(apiKeyOnboardingRoute({
providerType: 'openai-compatible',
providerType: 'custom',
requestHeaderCount: 0,
hasRequestBodyOverlay: false,
}), { kind: 'legacy', reason: 'custom_endpoint' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ test('a persisted override is the displayed effective endpoint', () => {
test('displaying a custom endpoint masks userinfo and every query value without hiding its route', () => {
assert.deepEqual(
providerEndpointPresentation({
providerType: 'openai-compatible',
providerType: 'custom',
baseUrl:
`https://relay-user:relay-password@relay.example.com/v1?api-version=2026-08-01&api_key=${longOpaqueToken}`,
}),
Expand All @@ -81,7 +81,7 @@ test('displaying a custom endpoint masks userinfo and every query value without
test('query values are masked under arbitrary key names, not just known ones', () => {
assert.deepEqual(
providerEndpointPresentation({
providerType: 'openai-compatible',
providerType: 'custom',
baseUrl: `https://relay.example.com/v1?key=${longOpaqueToken}`,
}),
{
Expand All @@ -92,7 +92,7 @@ test('query values are masked under arbitrary key names, not just known ones', (
);
assert.deepEqual(
providerEndpointPresentation({
providerType: 'openai-compatible',
providerType: 'custom',
baseUrl: `https://relay.example.com/v1?client_secret=${longOpaqueToken}`,
}),
{
Expand All @@ -106,7 +106,7 @@ test('query values are masked under arbitrary key names, not just known ones', (
test('custom relays and local runtimes retain endpoint editing', () => {
assert.deepEqual(
providerEndpointPresentation({
providerType: 'openai-compatible',
providerType: 'custom',
baseUrl: 'https://relay.example.com/v1',
}),
{
Expand Down Expand Up @@ -149,7 +149,7 @@ test('derived and OAuth endpoints remain visible but read-only', () => {

test('an absent custom endpoint remains visible as a missing editable value', () => {
assert.deepEqual(
providerEndpointPresentation({ providerType: 'openai-compatible' }),
providerEndpointPresentation({ providerType: 'custom' }),
{ value: null, editable: true, emptyState: 'missing' },
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,8 @@ test('retries connection delete after a stale revision instead of failing perman
revision,
slug: 'openrouter',
name: 'OpenRouter',
providerType: 'openai-compatible',
providerType: 'custom',
defaultApiProtocol: 'openai-chat',
baseUrl: 'https://openrouter.ai/api/v1',
enabled: true,
catalogEntries: [],
Expand Down Expand Up @@ -538,7 +539,8 @@ test('projects the Host default target without inventing a second Connection aut
connectionId: 'connection-1',
slug: 'openrouter',
name: 'OpenRouter',
providerType: 'openai-compatible',
providerType: 'custom',
defaultApiProtocol: 'openai-chat',
baseUrl: 'https://openrouter.ai/api/v1',
enabled: true,
defaultModel: 'model-1',
Expand Down Expand Up @@ -609,7 +611,8 @@ function catalog(): ConnectionCatalogSnapshot {
revision: 4,
slug: 'openrouter',
name: 'OpenRouter',
providerType: 'openai-compatible',
providerType: 'custom',
defaultApiProtocol: 'openai-chat',
baseUrl: 'https://openrouter.ai/api/v1',
enabled: true,
enabledModelIds: ['model-1', 'model-2'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ function connection(): LlmConnection {
return {
slug: 'provider',
name: 'Provider',
providerType: 'openai-compatible',
providerType: 'custom',
defaultApiProtocol: 'openai-chat',
baseUrl: 'https://relay.example/v1',
enabled: true,
defaultModel: 'model-a',
enabledModelIds: ['model-a'],
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/main/e2e-fixture/scenarios-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,9 @@ export async function writeConnections(
const noModels: ConnectionCatalogEntryDraft = {
slug: 'no-models',
name: 'No Models Fixture',
providerType: 'openai-compatible',
providerType: 'custom',
baseUrl: 'https://empty.example.test/v1',
defaultApiProtocol: 'openai-chat',
enabled: true,
enabledModelIds: [],
};
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/main/runtime-host-config-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,9 @@ export async function saveConnection(
name: connection.name,
providerType: connection.providerType,
...(connection.baseUrl ? { baseUrl: connection.baseUrl } : {}),
...(connection.defaultApiProtocol === undefined

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Do not report a protocol-mismatched snapshot as overwritten while retaining the old protocol

Creation now restores defaultApiProtocol, but the overwrite branch above cannot apply it: that field is fixed at creation (packages/core/src/runtime-policy.ts:293-294), and catalog updates copy previous.defaultApiProtocol (packages/storage/src/runtime-policy/connection-catalog-document.ts:358-367). The importer nevertheless counts the item as overwritten and replaces its endpoint, selection, and override table. On this exact head, importing an openai-responses snapshot over an existing openai-chat connection changed the base URL to the source value but returned and stored openai-chat. The restored connection is therefore a hybrid that does not match the backup and can send unoverridden models on the wrong wire. Please reject/skip this conflict explicitly, or replace/migrate it atomically while preserving credentials.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 006796c: planConnectionMerge now skips an overwrite whose existing custom connection has a different defaultApiProtocol, so the import neither produces a hybrid nor writes the bundle's credential onto it. Replacing it would drop credentials a connection-only backup cannot restore; the user can remove the connection and import again to take the snapshot's protocol.

? {}
: { defaultApiProtocol: connection.defaultApiProtocol }),
enabled: connection.enabled,
enabledModelIds: [...(connection.enabledModelIds ?? [])],
...(importedProfiles === undefined ? {} : { modelOverrides: importedProfiles }),
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/main/runtime-host-connections-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ export function registerRuntimeHostConnectionsIpc(
name: input.name,
providerType: input.providerType,
...(input.baseUrl === undefined ? {} : { baseUrl: input.baseUrl }),
...(input.defaultApiProtocol === undefined
? {}
: { defaultApiProtocol: input.defaultApiProtocol }),
enabled: true,
enabledModelIds: connectionEnabledModelIds({
defaultModel: input.defaultModel,
Expand Down Expand Up @@ -405,6 +408,9 @@ export function projectHostConnections(
name: connection.name,
providerType: connection.providerType,
...(connection.baseUrl === undefined ? {} : { baseUrl: connection.baseUrl }),
...(connection.defaultApiProtocol === undefined
? {}
: { defaultApiProtocol: connection.defaultApiProtocol }),
enabled: connection.enabled,
defaultModel,
enabledModelIds: [...connection.enabledModelIds],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
*/

import { useId, useState, type ReactNode } from 'react';
import { isRelayProviderType, type ProviderType } from '@maka/core/llm-connections';
import { supportsRelayFastServiceTier, modelLimitsConflict, type ModelOverride } from '@maka/core/model-thinking';
import type { ModelApiProtocol, ProviderType } from '@maka/core/llm-connections';
import { supportsCustomFastServiceTier, modelLimitsConflict, type ModelOverride } from '@maka/core/model-thinking';
import { CapabilityEditor } from './provider-capability-editor.js';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
Expand All @@ -30,6 +30,7 @@ import { parseContextWindowInput } from './context-window-input.js';
export function AddModelDialog(props: {
isOpen: boolean;
providerType: ProviderType;
defaultApiProtocol?: ModelApiProtocol;
existingModelIds: readonly string[];
/** Another write is in flight; the store would drop this one on the floor. */
isSubmitDisabled?: boolean;
Expand All @@ -53,6 +54,14 @@ export function AddModelDialog(props: {
const [isSaving, setSaving] = useState(false);

const trimmedId = id.trim();
const showsFastMode = supportsCustomFastServiceTier(
{
providerType: props.providerType,
defaultApiProtocol: props.defaultApiProtocol,
modelOverrides: { [trimmedId]: profile },
},
trimmedId,
);
const idError = !trimmedId
? copy.addModelIdRequired
: props.existingModelIds.includes(trimmedId)
Expand Down Expand Up @@ -86,9 +95,7 @@ export function AddModelDialog(props: {
await props.onSubmit(trimmedId, {
...parameters,
...(contextWindow === null ? {} : { contextWindow }),
...(supportsRelayFastServiceTier(props.providerType, trimmedId) && serviceTier
? { serviceTier }
: {}),
...(showsFastMode && serviceTier ? { serviceTier } : {}),
})
)
close();
Expand All @@ -110,7 +117,7 @@ export function AddModelDialog(props: {
<CapabilityEditor
copy={copy}
modelId={trimmedId}
isRelay={isRelayProviderType(props.providerType)}
customDefaultApiProtocol={props.defaultApiProtocol}
declared={profile}
limitsConflict={limitsConflict}
onChange={(patch) => setProfile((current) => ({ ...current, ...patch }))}
Expand All @@ -125,7 +132,7 @@ export function AddModelDialog(props: {
setProfile((current) => ({ ...current, [field]: value ?? undefined }));
}}
disabled={isSaving}
showsFastMode={supportsRelayFastServiceTier(props.providerType, trimmedId)}
showsFastMode={showsFastMode}
defaultVision={undefined}
thinkingLevels={profile.thinkingLevels ?? []}
onContextWindowInput={setContextWindowInput}
Expand Down
Loading