Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/desktop/src/renderer/locales/settings-provider-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ const zhCapabilitiesCopy = {
contextWindow: '上下文窗口(tokens)',
contextWindowHelp: '声明后压缩与预算按此值计算;留空跟随内置元数据。',
saveCapabilities: '保存能力声明',
fastMode: 'Fast 模式',
fastModeHelp: '使用 OpenAI 的 fast service tier;留空跟随服务商默认值。',
fastAuto: '自动',
fastEnabled: 'Fast',
};
const enCapabilitiesCopy = {
capabilities: 'Capabilities',
Expand All @@ -38,6 +42,10 @@ const enCapabilitiesCopy = {
contextWindow: 'Context window (tokens)',
contextWindowHelp: 'When set, compaction and budgets use this value; when empty, built-in metadata decides.',
saveCapabilities: 'Save capability declarations',
fastMode: 'Fast mode',
fastModeHelp: "Use OpenAI's fast service tier; empty follows the provider default.",
fastAuto: 'Auto',
fastEnabled: 'Fast',
};

const zhCopy = {
Expand Down
24 changes: 22 additions & 2 deletions apps/desktop/src/renderer/settings/provider-connection-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections';
import {
DECLARABLE_RELAY_THINKING_LEVELS,
THINKING_LEVELS,
isRelayProviderType,
type RelayModelProfile,
type ThinkingLevel,
} from '@maka/core/model-thinking';
Expand Down Expand Up @@ -152,18 +153,19 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
setDraftThinkingLevels,
setDraftVision,
setDraftContextWindow,
setDraftServiceTier,
saveRelayProfiles,
runTest,
refreshModels,
remove,
refreshAfterRelogin,
} = useConnectionDetail(props);
// Capability switches only exist for openai-compatible relays: built-in
// Capability switches only exist for custom OpenAI relays: built-in
// providers declare their thinking support in model metadata, a custom
// relay's backing model is unknown until the user says what it can do. The
// declaration is per model — a relay can front both a reasoner and a plain
// instruct model.
const showsCapabilities = connection.providerType === 'openai-compatible';
const showsCapabilities = isRelayProviderType(connection.providerType);
// Rows are the enabled models, exactly — the store prunes a model's profile
// the moment it is disabled, so no declaration can ever belong to a row
// this list does not show. The editor edits the per-model draft; 保存
Expand Down Expand Up @@ -526,6 +528,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
? 'disabled'
: 'auto';
const draftLevels = declared?.thinkingLevels ?? [];
const serviceTierValue = declared?.serviceTier ?? 'auto';
// The menu offers the five declarable levels PLUS anything
// the stored table already claims — a level saved while it
// was still declarable (or hand-written into the document)
Expand Down Expand Up @@ -617,6 +620,23 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
}
/>
</CapabilityRow>
<CapabilityRow label={copy.fastMode} description={copy.fastModeHelp}>
<Selector
label={`${copy.fastMode} — ${modelId}`}
isLabelHidden
size="sm"
width={132}
options={[
{ value: 'auto', label: copy.fastAuto },
{ value: 'fast', label: copy.fastEnabled },
]}
value={serviceTierValue}
onChange={(value) =>
setDraftServiceTier(modelId, value === 'fast' ? 'fast' : undefined)
}
isDisabled={allActionsBusy}
/>
</CapabilityRow>
</VStack>
);
})}
Expand Down
14 changes: 13 additions & 1 deletion apps/desktop/src/renderer/settings/use-connection-detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
}
}

// Per-model profile declarations for openai-compatible relays, edited as a
// Per-model profile declarations for custom OpenAI relays, edited as a
// LOCAL DRAFT and committed by an explicit 保存 button — never keystroke by
// keystroke. A draft is `Record<modelId, RelayModelProfile>` seeded from the
// saved table; entries a user empties fully drop out of the map, and the
Expand Down Expand Up @@ -407,6 +407,17 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
});
}

function setDraftServiceTier(modelId: string, serviceTier: 'fast' | undefined): void {
updateRelayProfileDraft(modelId, (current) => {
if (serviceTier === undefined) {
if (!current) return current;
const { serviceTier: _dropped, ...rest } = current;
return Object.keys(rest).length > 0 ? rest : undefined;
}
return { ...(current ?? {}), serviceTier };
});
}

// Compare against what persistence would store: drafts pruned to the
// current selection and order-normalized by the same sanitizer the write
// path applies, so a reordered-but-equal draft doesn't keep 保存 lit.
Expand Down Expand Up @@ -641,6 +652,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
setDraftThinkingLevels,
setDraftVision,
setDraftContextWindow,
setDraftServiceTier,
saveRelayProfiles,
runTest,
refreshModels,
Expand Down
24 changes: 23 additions & 1 deletion packages/core/src/__tests__/model-thinking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
thinkingVariantsForConnection,
thinkingVariantsForModel,
} from '../model-thinking.js';
import { isRelayProviderType } from '../llm-connections.js';

test('declarable relay levels are every intensity tier but off', () => {
// `off` is a disable-wire encoding (reasoning_effort 'none'), not an
Expand All @@ -25,6 +26,13 @@ test('declarable relay levels are every intensity tier but off', () => {
assert.deepEqual([...thinkingVariantsForConnection(declaredOff, 'm')], ['low']);
});

test('relay profiles preserve the fast service tier declaration', () => {
assert.deepEqual(normalizeRelayModelProfiles({ m: { serviceTier: 'fast' } }), {
m: { serviceTier: 'fast' },
});
assert.deepEqual(normalizeRelayModelProfiles({ m: { serviceTier: 'unknown' } }), undefined);
});

test('relayModelProfile returns undefined without a usable declaration', () => {
const connection = {
providerType: 'openai-compatible',
Expand Down Expand Up @@ -78,18 +86,32 @@ test('relayModelProfile normalizes order, keeps explicit vision:false, and bound
}
});

test('relayModelProfile gates declarations to openai-compatible relays', () => {
test('relayModelProfile gates declarations to custom OpenAI relays', () => {
const profiles = { m: { vision: true, contextWindow: 64_000 } };
assert.deepEqual(
relayModelProfile({ providerType: 'openai-compatible', relayModelProfiles: profiles }, 'm'),
{ vision: true, contextWindow: 64_000 },
);
assert.deepEqual(
relayModelProfile(
{ providerType: 'openai-responses-compatible', relayModelProfiles: profiles },
'm',
),
{ vision: true, contextWindow: 64_000 },
);
// The same table on a non-relay connection is inert: metadata rules.
for (const providerType of ['anthropic', 'openai'] as const) {
assert.equal(relayModelProfile({ providerType, relayModelProfiles: profiles }, 'm'), undefined);
}
});

test('isRelayProviderType only accepts the two custom OpenAI relay providers', () => {
assert.equal(isRelayProviderType('openai-compatible'), true);
assert.equal(isRelayProviderType('openai-responses-compatible'), true);
assert.equal(isRelayProviderType('openai'), false);
assert.equal(isRelayProviderType('anthropic'), false);
});

test('normalizeRelayModelProfiles sanitizes write-side tables', () => {
const sanitized = normalizeRelayModelProfiles({
reasoner: { thinkingLevels: ['high', 'low', 'turbo'], vision: true, contextWindow: 200_000 },
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/__tests__/runtime-policy-codec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', (
thinkingLevels: ['minimal', 'low'],
vision: true,
contextWindow: 128_000,
serviceTier: 'fast',
},
};
const draft = normalizeCreateCatalogConnectionInput({
Expand All @@ -237,6 +238,19 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', (
},
});
assert.deepEqual(draft.connection.relayModelProfiles, table);
const responsesDraft = normalizeCreateCatalogConnectionInput({
expectedCatalogRevision: 0,
connection: {
slug: 'responses-relay',
name: 'Responses Relay',
providerType: 'openai-responses-compatible',
baseUrl: 'https://responses.example/v1',
enabled: true,
enabledModelIds: ['relay-reasoner'],
relayModelProfiles: table,
},
});
assert.deepEqual(responsesDraft.connection.relayModelProfiles, table);
// The canonical path re-decodes the same table (entry = draft + identity).
const entry = decodeCanonicalConnectionCatalogEntry({
...draft.connection,
Expand Down
12 changes: 9 additions & 3 deletions packages/core/src/llm-connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ export type {
ProviderType,
};

export function isRelayProviderType(
providerType: ProviderType,
): providerType is 'openai-compatible' | 'openai-responses-compatible' {
return PROVIDER_REGISTRY[providerType].relayModelProfiles === true;
}

export type ConnectionAuth =
| { kind: 'api_key'; apiKey: string }
| { kind: 'optional_api_key'; apiKey?: string }
Expand Down Expand Up @@ -107,13 +113,13 @@ export interface RuntimeExecutionConnection {
defaultModel: string;
models?: ModelInfo[];
/**
* Per-model user declarations for an `openai-compatible` relay: the facts
* Per-model user declarations for a custom OpenAI relay: the facts
* (offered thinking levels, vision enable/disable, context window) that
* neither the relay's /models report nor built-in metadata can decide
* (see `RelayModelProfile` in `model-thinking.ts`). First-class and typed —
* relay models are unknown to metadata and a catalog refresh rewrites
* `models[]` rows, so declarations live next to the user-edited fields.
* Invariants enforced at store boundaries: only `openai-compatible`
* Invariants enforced at store boundaries: only custom OpenAI relay
* connections carry profiles, and only for ids in `enabledModelIds`
* (disabling a model deletes its profile).
*/
Expand Down Expand Up @@ -598,7 +604,7 @@ export interface UpdateConnectionInput {
/**
* Replace the whole relay profiles table: absent leaves it untouched,
* `null` clears it outright, a table replaces it (with the usual rules —
* only `openai-compatible`, only for `enabledModelIds`).
* only custom OpenAI relays, only for `enabledModelIds`).
*/
relayModelProfiles?: RelayModelProfiles | null;
requestBodyOverlay?: JsonObject | null;
Expand Down
16 changes: 11 additions & 5 deletions packages/core/src/model-thinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* per-model supported set, so the UI and runtime share one source of truth.
*/

import { isRelayProviderType } from './llm-connections.js';
import type { ProviderType } from './llm-connections.js';
import { lookupModelMetadata } from './model-metadata.js';

Expand Down Expand Up @@ -101,7 +102,8 @@ export function deriveThinkingChoices(
}

/**
* One model behind an `openai-compatible` relay, as declared by the user:
* One model behind an OpenAI-compatible relay (Chat Completions or Responses),
* as declared by the user:
* the facts neither the relay's /models report nor built-in metadata can be
* trusted to know. Every field is independent, and every ABSENT field means
* "Auto" — the /models report and the metadata chain decide. The single
Expand All @@ -113,14 +115,16 @@ export function deriveThinkingChoices(
* (`relayModelProfiles`, keyed by model id): relay models are unknown to
* `model-metadata.ts` and a catalog refresh rewrites `models[]` rows, so
* declarations sit next to the user-edited connection fields. Two invariants
* are enforced at the store boundaries — profiles exist only for
* `openai-compatible` connections, and only for models in `enabledModelIds`
* are enforced at the store boundaries — profiles exist only for custom OpenAI
* relay connections, and only for models in `enabledModelIds`
* (disabling a model deletes its profile).
*/
export interface RelayModelProfile {
readonly thinkingLevels?: readonly ThinkingLevel[];
readonly vision?: boolean;
readonly contextWindow?: number;
/** Use OpenAI's low-latency service tier for this relay model. */
readonly serviceTier?: 'fast';
}

export type RelayModelProfiles = Readonly<Record<string, RelayModelProfile>>;
Expand All @@ -135,6 +139,7 @@ function normalizeRelayModelProfile(entry: unknown): RelayModelProfile | undefin
thinkingLevels?: readonly ThinkingLevel[];
vision?: boolean;
contextWindow?: number;
serviceTier?: 'fast';
} = {};
if (Array.isArray(entry.thinkingLevels)) {
// Declared levels are filtered to the declarable vocabulary, not merely
Expand Down Expand Up @@ -166,6 +171,7 @@ function normalizeRelayModelProfile(entry: unknown): RelayModelProfile | undefin
) {
declared.contextWindow = entry.contextWindow;
}
if (entry.serviceTier === 'fast') declared.serviceTier = 'fast';
return Object.keys(declared).length > 0 ? (declared as RelayModelProfile) : undefined;
}

Expand Down Expand Up @@ -231,12 +237,12 @@ export function relayModelProfile(
connection: ConnectionThinkingContext,
modelId: string,
): RelayModelProfile | undefined {
if (connection.providerType !== 'openai-compatible') return undefined;
if (!isRelayProviderType(connection.providerType)) return undefined;
return normalizeRelayModelProfile(connection.relayModelProfiles?.[modelId]);
}

/**
* `openai-compatible` connections declare thinking support **per model** via
* OpenAI-compatible relay connections declare thinking support **per model** via
* `relayModelProfiles[modelId].thinkingLevels` — a relay may front a
* DeepSeek-family reasoner and a plain instruct model side by side, so the
* declaration granularity is the model, not the connection. Without a usable
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/provider-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export interface ProviderDefaults {
status: 'ready' | 'phase3-experimental';
protocol: 'anthropic' | 'openai' | 'google' | 'cohere';
runtimeAdapter: ProviderRuntimeAdapter;
/** User-declared per-model capabilities are authoritative for this provider. */
relayModelProfiles?: boolean;
modelDiscovery: ProviderModelDiscovery;
category: ProviderCategory;
catalogGroup?: ProviderCatalogGroup;
Expand Down Expand Up @@ -1727,6 +1729,7 @@ const providerRegistry = {
status: 'ready',
protocol: 'openai',
runtimeAdapter: { kind: 'openai-compatible', name: 'connection', requireBaseUrl: true },
relayModelProfiles: true,
modelDiscovery: { kind: 'protocol' },
category: 'custom',
catalogGroup: 'aggregators',
Expand All @@ -1745,6 +1748,7 @@ const providerRegistry = {
status: 'ready',
protocol: 'openai',
runtimeAdapter: { kind: 'openai', apiProtocol: 'openai-responses' },
relayModelProfiles: true,
modelDiscovery: { kind: 'protocol' },
category: 'custom',
catalogGroup: 'aggregators',
Expand Down
22 changes: 17 additions & 5 deletions packages/core/src/runtime-policy/connection-catalog-codec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { PROVIDER_DEFAULTS, validateSlug, type ProviderType } from '../llm-connections.js';
import {
isRelayProviderType,
PROVIDER_DEFAULTS,
validateSlug,
type ProviderType,
} from '../llm-connections.js';
import {
DECLARABLE_RELAY_THINKING_LEVELS,
isThinkingLevel,
Expand Down Expand Up @@ -205,12 +210,12 @@ export function normalizeConnectionCatalogEntryUpdateForProvider(
};
}

// Relay profiles are an openai-compatible feature: on any other provider the
// Relay profiles are a custom OpenAI relay feature: on any other provider the
// metadata chain is the truth, and a table here would either sit as dead
// state or silently shadow metadata for the ungated read seams.
function rejectForeignProfiles(providerType: ProviderType): void {
if (providerType !== 'openai-compatible') {
throw domainError('relay model profiles are only supported for openai-compatible connections');
if (!isRelayProviderType(providerType)) {
throw domainError('relay model profiles are only supported for OpenAI-compatible connections');
}
}

Expand Down Expand Up @@ -244,13 +249,14 @@ export function decodeRelayModelProfilesTable(
const entry = exactRecord(
rawEntry,
`relay model profile for ${modelId}`,
['thinkingLevels', 'vision', 'contextWindow'],
['thinkingLevels', 'vision', 'contextWindow', 'serviceTier'],
[],
);
const declared: {
thinkingLevels?: readonly ThinkingLevel[];
vision?: boolean;
contextWindow?: number;
serviceTier?: 'fast';
} = {};
if (entry.thinkingLevels !== undefined) {
if (!Array.isArray(entry.thinkingLevels) || entry.thinkingLevels.length === 0) {
Expand Down Expand Up @@ -283,6 +289,12 @@ export function decodeRelayModelProfilesTable(
Number.MAX_SAFE_INTEGER,
);
}
if (entry.serviceTier !== undefined) {
if (entry.serviceTier !== 'fast') {
throw domainError(`declared service tier for ${modelId} must be fast`);
}
declared.serviceTier = 'fast';
}
if (Object.keys(declared).length === 0) {
throw domainError(`relay model profile for ${modelId} declares nothing`);
}
Expand Down
Loading
Loading