You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/08/21 05:35:24 UTC
(maka) 02/02: feat(core,runtime,desktop): let a user state the model facts Maka cannot learn
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch fix/1584-model-selection-authority
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 9d634444aa734fe47f55a404c204eeadd7b35f7d
Author: AstroHan <le...@outlook.com>
AuthorDate: Fri Aug 21 13:29:42 2026 +0800
feat(core,runtime,desktop): let a user state the model facts Maka cannot learn
Once a model no catalog lists is selectable, the user needs somewhere to
put what they know about it, and a way to add it in the first place.
Model declarations (`relayModelProfiles`) already existed but were
refused on any non-relay connection. That gate split the wrong way. It
turns on the FIELD, not the provider:
- `contextWindow` and `vision` state facts about a model. A user has them
whenever Maka does not — a model newer than the bundled snapshot, or
any model on a provider with no model-list endpoint — so they are legal
on every provider.
- `thinkingLevels` and `serviceTier` name a wire feature instead
(`reasoning_effort` tiers, priority processing) that only the
OpenAI-compatible relays accept, and stay relay-only. Declaring them
elsewhere would describe a request Maka never sends, or draw a 400 the
user cannot explain.
`assertProfileFieldsFitProvider` in the catalog codec is the single write
seam for that rule, so the read paths do not re-derive it.
On the settings side, a connection detail page now offers "Add model"
alongside "Update models" rather than instead of it: the two answer
different questions, and a provider that supports discovery can still
serve a model its discovery did not report. The dialog takes an id plus
the optional declarations above.
The field name stays `relayModelProfiles` — renaming a persisted field is
a migration this change does not need — and its doc comment now explains
that the name is historical.
Generated-by: Claude Code
---
.../src/renderer/locales/settings-provider-copy.ts | 21 ++++
.../settings/provider-add-model-dialog.tsx | 139 +++++++++++++++++++++
.../settings/provider-connection-detail.tsx | 57 +++++++--
.../src/renderer/settings/use-connection-detail.ts | 62 +++++++++
.../stories/settings/provider-settings.stories.tsx | 39 +++++-
docs/astryx-surface-file-inventory.md | 3 +-
docs/astryx-surface-file-inventory.paths | 1 +
packages/core/src/__tests__/model-thinking.test.ts | 35 +++---
.../src/__tests__/runtime-policy-codec.test.ts | 98 ++++++++++-----
packages/core/src/model-thinking.ts | 52 ++++----
.../src/runtime-policy/connection-catalog-codec.ts | 54 +++++---
.../context-budget-mid-turn-policy.test.ts | 20 ++-
packages/runtime/src/context-budget-policy.ts | 7 +-
13 files changed, 488 insertions(+), 100 deletions(-)
diff --git a/apps/desktop/src/renderer/locales/settings-provider-copy.ts b/apps/desktop/src/renderer/locales/settings-provider-copy.ts
index 7a55ead23..997139426 100644
--- a/apps/desktop/src/renderer/locales/settings-provider-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-provider-copy.ts
@@ -66,6 +66,16 @@ const zhCopy = {
credentialUnknownDetail: '模型凭据状态暂时没刷新成功,已避免把未知状态显示成未登录或未配置。',
testConnection: '测试连接',
updateModels: '更新模型目录', endpoint: '服务地址',
+ addModel: '添加模型',
+ addModelConfirm: '添加',
+ addModelIdField: '模型 ID',
+ addModelIdFieldHelp: '需与服务商完全一致,区分大小写。',
+ addModelIdPlaceholder: 'deepseek-v4-pro-beta',
+ addModelIdRequired: '请填写模型 ID。',
+ addModelIdDuplicate: '该模型已在列表中。',
+ addModelContextWindow: '上下文窗口',
+ addModelContextWindowHelp: '服务商模型页给出的最大 token 数。缺少它 Maka 只能按 32k 处理,长对话会被提前截断。',
+ addModelContextWindowRequired: '请填写上下文窗口。',
credentials: '连接', dangerZone: '删除连接', deleteRowHelp: '此操作不可撤销。',
credentialsHelp: '密钥只保存在本机。',
credentialsHelpAccount: '登录令牌只保存在本机。',
@@ -216,6 +226,17 @@ const enCopy: ProviderSettingsCopy = {
credentialUnknownDetail: 'Model credential status could not be refreshed, so the connection is not being mislabeled as signed out or unconfigured.',
testConnection: 'Test connection',
updateModels: 'Update model catalog', endpoint: 'Service URL',
+ addModel: 'Add model',
+ addModelConfirm: 'Add',
+ addModelIdField: 'Model ID',
+ addModelIdFieldHelp: 'Must match the provider exactly, including case.',
+ addModelIdPlaceholder: 'deepseek-v4-pro-beta',
+ addModelIdRequired: 'Enter a model ID.',
+ addModelIdDuplicate: 'This model is already in the list.',
+ addModelContextWindow: 'Context window',
+ addModelContextWindowHelp:
+ "The maximum token count from the provider's model page. Without it Maka can only assume 32k, and long conversations get truncated early.",
+ addModelContextWindowRequired: 'Enter a context window.',
credentials: 'Connection', dangerZone: 'Delete connection', deleteRowHelp: 'This cannot be undone.',
credentialsHelp: 'The key stays on this machine.',
credentialsHelpAccount: 'The sign-in token stays on this machine.',
diff --git a/apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx b/apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx
new file mode 100644
index 000000000..6623134fc
--- /dev/null
+++ b/apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx
@@ -0,0 +1,139 @@
+import { useState, type FormEvent } from 'react';
+import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
+import { FormLayout } from '@astryxdesign/core/FormLayout';
+import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
+import { Button, HStack, NumberInput, TextInput, useUiLocale } from '@maka/ui';
+import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
+
+/**
+ * Introduce a model by exact id, for a provider whose catalog cannot grow on
+ * its own: without a model-list endpoint, refresh replays the array this build
+ * shipped, so a model the user's plan already serves has no other way in
+ * (#1584).
+ *
+ * Two fields. The id enters `enabledModelIds`, which is the authorization. The
+ * context window is the one fact nothing else can supply: an id Maka has never
+ * seen resolves no window, and the history budget falls back to a flat 32k
+ * (context-budget-policy.ts) — three percent of a 1M-token window.
+ *
+ * Everything else a user can declare is edited in the capability section below
+ * the model list, which shows a row for exactly the models Maka cannot
+ * describe — every model added here, the moment it is added.
+ */
+export function AddModelDialog(props: {
+ isOpen: boolean;
+ existingModelIds: readonly string[];
+ /** Another write is in flight; the store would drop this one on the floor. */
+ isSubmitDisabled?: boolean;
+ onOpenChange(open: boolean): void;
+ onSubmit(id: string, contextWindow: number): void;
+}) {
+ const copy = getProviderSettingsCopy(useUiLocale()).detail;
+ const [id, setId] = useState('');
+ const [contextWindow, setContextWindow] = useState<number | null>(null);
+ const [submitAttempted, setSubmitAttempted] = useState(false);
+
+ const trimmedId = id.trim();
+ const idError = !trimmedId
+ ? copy.addModelIdRequired
+ : props.existingModelIds.includes(trimmedId)
+ ? copy.addModelIdDuplicate
+ : null;
+ // Required, not defaulted: an unknown window falls back to a flat 32k history
+ // budget, and guessing higher on the user's behalf would trade a wasted
+ // window for requests the provider rejects outright. Whoever types an exact
+ // model id is reading the provider's own model page, where this is stated.
+ const contextWindowError = contextWindow ? null : copy.addModelContextWindowRequired;
+
+ function close() {
+ setId('');
+ setContextWindow(null);
+ setSubmitAttempted(false);
+ props.onOpenChange(false);
+ }
+
+ function submit(event: FormEvent) {
+ event.preventDefault();
+ setSubmitAttempted(true);
+ if (idError || !contextWindow) return;
+ props.onSubmit(trimmedId, contextWindow);
+ close();
+ }
+
+ return (
+ <Dialog
+ isOpen={props.isOpen}
+ onOpenChange={(open) => {
+ if (!open) close();
+ }}
+ purpose="form"
+ width={480}
+ >
+ <Layout
+ header={
+ <DialogHeader
+ title={copy.addModel}
+ onOpenChange={(open) => {
+ if (!open) close();
+ }}
+ />
+ }
+ content={
+ <LayoutContent>
+ <form id="maka-add-model-form" onSubmit={submit}>
+ <FormLayout>
+ {/* The exact id, kept verbatim through selection and inference
+ — `deepseek-v4-pro-beta` is a different model from
+ `deepseek-v4-pro`, and only the user knows which one their
+ plan actually serves. */}
+ <TextInput
+ label={copy.addModelIdField}
+ description={copy.addModelIdFieldHelp}
+ isRequired
+ hasAutoFocus
+ value={id}
+ placeholder={copy.addModelIdPlaceholder}
+ onChange={setId}
+ status={
+ submitAttempted && idError ? { type: 'error', message: idError } : undefined
+ }
+ />
+ <NumberInput
+ label={copy.addModelContextWindow}
+ description={copy.addModelContextWindowHelp}
+ isRequired
+ value={contextWindow}
+ hasClear
+ isIntegerOnly
+ min={1}
+ onChange={setContextWindow}
+ status={
+ submitAttempted && contextWindowError
+ ? { type: 'error', message: contextWindowError }
+ : undefined
+ }
+ />
+ </FormLayout>
+ </form>
+ </LayoutContent>
+ }
+ footer={
+ <LayoutFooter>
+ {/* One button, as in scheduled-task-form-dialog: the header's close
+ control and Escape are already two ways out, so a footer cancel
+ would be a third route to the same place. */}
+ <HStack gap={2} hAlign="end">
+ <Button
+ variant="primary"
+ type="submit"
+ form="maka-add-model-form"
+ isDisabled={props.isSubmitDisabled}
+ label={copy.addModelConfirm}
+ />
+ </HStack>
+ </LayoutFooter>
+ }
+ />
+ </Dialog>
+ );
+}
diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx
index 9dc89d7bd..1ae4ecead 100644
--- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx
+++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx
@@ -12,6 +12,7 @@ import {
VStack,
} from '@astryxdesign/core';
import { isRelayProviderType, PROVIDER_DEFAULTS } from '@maka/core/llm-connections';
+import { hasModelMetadata } from '@maka/core/model-metadata';
import {
DECLARABLE_RELAY_THINKING_LEVELS,
THINKING_LEVELS,
@@ -33,6 +34,7 @@ import { PasswordInput } from './password-input';
import { SettingsExpandableRow } from './settings-expandable-row';
import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
import { providerDisplay } from './provider-display';
+import { AddModelDialog } from './provider-add-model-dialog';
import { EnabledModelManager } from './provider-enabled-model-manager';
import { useActionGuard } from './use-action-guard';
import { useRuntimeHostSettingsTarget } from './runtime-host-settings-target.js';
@@ -148,6 +150,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
savedBaseUrl,
save,
updateEnabledModels,
+ addDeclaredModel,
relayProfileDraft,
hasRelayProfileChanges,
setDraftThinkingLevels,
@@ -160,22 +163,34 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
remove,
refreshAfterRelogin,
} = useConnectionDetail(props);
- // 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 = isRelayProviderType(connection.providerType);
+ // A model gets capability switches when Maka cannot describe it otherwise.
+ // On a custom OpenAI relay that is every model: the id is whatever the
+ // operator chose, so even one that collides with a known name may front
+ // something else entirely. Elsewhere it is the models the bundled metadata
+ // has never heard of — a model newer than this build, or one the user typed
+ // in on a provider whose key cannot call a model-list endpoint, which no
+ // refresh will ever describe (#1584).
+ //
+ // A model that already carries a declaration always keeps its row, or a
+ // stale declaration would be uneditable and unclearable.
+ const isRelay = 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; 保存
// commits the whole table in one write.
- const capabilityModelIds = enabledModelIds;
+ const capabilityModelIds = enabledModelIds.filter(
+ (modelId) =>
+ isRelay ||
+ relayProfileDraft[modelId] !== undefined ||
+ !hasModelMetadata(connection.providerType, modelId),
+ );
+ const showsCapabilities = capabilityModelIds.length > 0;
// One row is a form at a time, the way the settings-sidebar template does it.
// Opening a row discards the other's draft: leaving an abandoned draft in
// state meant it reappeared when the user came back to that row, and — until
// `save` became per-field — rode along with the next save.
const [editingRow, setEditingRow] = useState<'key' | 'endpoint' | 'headers' | 'body' | null>(null);
+ const [addModelOpen, setAddModelOpen] = useState(false);
const [savedHeaderNames, setSavedHeaderNames] = useState<readonly string[]>([]);
const [headerDrafts, setHeaderDrafts] = useState<RequestHeaderDraft[]>([]);
const savedBodyText = formatRequestBodyOverlay(connection.requestBodyOverlay);
@@ -506,10 +521,31 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
would pass a MouseEvent as `opts`. */}
<HStack gap={2} vAlign="center" wrap="wrap">
<Button variant="secondary" isDisabled={allActionsBusy || !hasUsableCredential} clickAction={() => runTest()} label={copy.testConnection} />
+ {/* Both, wherever refresh exists. Refresh is the fast path and stays
+ first, but having a model-list endpoint does not mean the endpoint
+ answers for this account: a self-hosted gateway on
+ `openai-compatible` may not serve /models at all, and a provider's
+ list can lag a model the account already has. Making the two
+ alternatives left those users with no way in (#1584). */}
{supportsRemoteDiscovery && (
<Button variant="ghost" isDisabled={allActionsBusy || !hasUsableCredential} clickAction={() => refreshModels()} label={copy.updateModels} />
)}
+ <Button variant="ghost" isDisabled={allActionsBusy} clickAction={() => setAddModelOpen(true)} label={copy.addModel} />
</HStack>
+ <AddModelDialog
+ isOpen={addModelOpen}
+ /* The catalog, not just the selection: `models` is usually a proper
+ superset of what the user enabled. Checking only the selection lets
+ a listed-but-unchecked id through, and the dialog then requires a
+ hand-typed context window that overrides the one Maka already
+ knows. */
+ existingModelIds={[...enabledModelIds, ...(connection.models ?? []).map(({ id }) => id)]}
+ /* A write started after the dialog opened would make the store drop
+ this submission silently, taking the typed id with it. */
+ isSubmitDisabled={allActionsBusy}
+ onOpenChange={setAddModelOpen}
+ onSubmit={(id, contextWindow) => void addDeclaredModel(id, contextWindow)}
+ />
</DetailSection>
{showsCapabilities && (
<>
@@ -552,6 +588,12 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
left, one compact control on the right (the 模型功能
row language). A CheckboxList wall was the reason this
section looked like a form from a different app. */}
+ {/* Relay-only, like 快速模式 below: a declared level encodes
+ into `reasoning_effort`, a wire field only the
+ OpenAI-compatible relays accept. The catalog codec
+ refuses to persist one elsewhere, so offering the
+ control would promise an edit that cannot be saved. */}
+ {isRelay && (
<CapabilityRow label={copy.thinkingEffort} description={copy.thinkingEffortHelp}>
{/* DropdownMenu, not MultiSelector: levels have a
canonical order (low → max) that must not shuffle —
@@ -593,6 +635,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
))}
</DropdownMenu>
</CapabilityRow>
+ )}
<CapabilityRow label={copy.visionInput} description={copy.visionInputHelp}>
<Selector
label={`${copy.visionInput} — ${modelId}`}
diff --git a/apps/desktop/src/renderer/settings/use-connection-detail.ts b/apps/desktop/src/renderer/settings/use-connection-detail.ts
index a1fa59166..a55b7a901 100644
--- a/apps/desktop/src/renderer/settings/use-connection-detail.ts
+++ b/apps/desktop/src/renderer/settings/use-connection-detail.ts
@@ -238,6 +238,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
slug: connection.slug,
providerType: connection.providerType,
defaultModel: connection.defaultModel,
+ enabledModelIds,
models: modelSource === 'fetched' || models.length > 0 ? models : undefined,
modelSource,
modelsFetchedAt: connection.modelsFetchedAt,
@@ -478,6 +479,66 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
}
}
+ /**
+ * Introduce a model the provider's catalog does not list.
+ *
+ * Only offered where refresh cannot help: a provider with no model-list
+ * endpoint replays the array this build shipped, so a model the user's plan
+ * serves but Maka has never heard of has no other way in (#1584).
+ *
+ * The id enters `enabledModelIds` — the same user-selection authority a
+ * catalogued model uses, so nothing here pretends the provider advertised
+ * it — and the context window enters `relayModelProfiles`, which is where a
+ * user states a fact no other source knows. Both go in ONE write: the store
+ * requires every declaration to key an enabled model, so a table written
+ * ahead of its id would be rejected.
+ *
+ * The saved table is the base, not the unsaved draft: adding a model must
+ * not silently commit edits the user has open in the capability section.
+ * The draft is then caught up by hand, because a dirty draft deliberately
+ * does not reseed from props — see `relayProfileDraftReseedPlan`.
+ */
+ async function addDeclaredModel(id: string, contextWindow: number) {
+ const modelId = id.trim();
+ if (!modelId || enabledModelIds.includes(modelId)) return;
+ if (connectionDetailActionGuard.has('save-enabled-models') || detailActionBusy) return;
+ const next = [...enabledModelIds, modelId];
+ const previous = enabledModelIds;
+ const lifecycle = connectionDetailLifecycleRef.current;
+ const releaseSaveModels = connectionDetailActionGuard.begin('save-enabled-models');
+ if (!releaseSaveModels) return;
+ setSavingEnabledModels(true);
+ setEnabledModelIds(next);
+ let saved = false;
+ try {
+ await props.bridge.update(connection.slug, {
+ enabledModelIds: next,
+ relayModelProfiles: { ...(savedRelayProfiles ?? {}), [modelId]: { contextWindow } },
+ });
+ saved = true;
+ if (!isConnectionDetailCurrent(lifecycle)) return;
+ // The editor's draft is a second copy of this table, and while it is
+ // dirty it does not reseed from props — that is what keeps an unrelated
+ // reload from discarding typed work. So the declaration just written has
+ // to be merged in here. Without it the draft is a table that no longer
+ // contains this model, the capability-save button lights up on that
+ // difference, and its whole-table replace drops the context window the
+ // user just declared — silently, back to the unknown-model default.
+ setRelayProfileDrafts((current) => ({ ...current, [modelId]: { contextWindow } }));
+ await props.onChanged();
+ } catch (error) {
+ if (!isConnectionDetailCurrent(lifecycle)) return;
+ if (!saved) setEnabledModelIds(previous);
+ toast.error(
+ saved ? copy.refreshFailed : copy.saveModelsFailed,
+ providerPanelActionErrorMessage(error, locale),
+ );
+ } finally {
+ releaseSaveModels();
+ if (isConnectionDetailCurrent(lifecycle)) setSavingEnabledModels(false);
+ }
+ }
+
async function runTest() {
const releaseTest = connectionDetailActionGuard.beginExclusive('test');
if (!releaseTest) return;
@@ -646,6 +707,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
lastTestAtMs,
save,
updateEnabledModels,
+ addDeclaredModel,
relayProfileDraft: relayProfileDrafts,
relayProfilesDirty,
hasRelayProfileChanges,
diff --git a/apps/desktop/stories/settings/provider-settings.stories.tsx b/apps/desktop/stories/settings/provider-settings.stories.tsx
index c898b988a..77176b297 100644
--- a/apps/desktop/stories/settings/provider-settings.stories.tsx
+++ b/apps/desktop/stories/settings/provider-settings.stories.tsx
@@ -28,7 +28,7 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
-type AutoOpenTarget = 'detail' | 'add' | 'catalog' | 'oauth' | 'xai-device';
+type AutoOpenTarget = 'detail' | 'detail-static' | 'add' | 'catalog' | 'oauth' | 'xai-device';
function makeConnection(input: {
slug: string;
@@ -100,6 +100,26 @@ const configuredConnections = [
}),
];
+// A provider whose key cannot call a model-list endpoint: refresh replays the
+// array this build shipped, so 添加模型 replaces 更新模型目录 as the only way the
+// catalog can grow. `deepseek-v4-pro-beta` is a model added that way — absent
+// from `models`, declared in `relayModelProfiles` (#1584).
+const staticCatalogConnections = [
+ {
+ ...makeConnection({
+ slug: 'ark-plan',
+ name: 'Ark Agent Plan',
+ providerType: 'volcengine-agent-plan',
+ defaultModel: 'doubao-seed-2.1-turbo',
+ lastTestStatus: 'verified',
+ models: [{ id: 'doubao-seed-2.1-turbo' }, { id: 'kimi-k2.6' }],
+ modelSource: 'fetched',
+ }),
+ enabledModelIds: ['doubao-seed-2.1-turbo', 'deepseek-v4-pro-beta'],
+ relayModelProfiles: { 'deepseek-v4-pro-beta': { contextWindow: 262_144 } },
+ },
+];
+
const problemConnections = [
configuredConnections[0],
makeConnection({
@@ -364,10 +384,11 @@ function reachCatalog(root: HTMLElement): HTMLElement | null {
}
function clickAutoOpenTarget(root: HTMLElement, target: AutoOpenTarget): boolean {
- if (target === 'detail') {
+ if (target === 'detail' || target === 'detail-static') {
// ListItem's clickable surface is an invisible button inside the row, so
// the row is located by its slug hook and the button taken from within it.
- const row = root.querySelector<HTMLElement>('[data-connection-slug="zai-live"]');
+ const slug = target === 'detail' ? 'zai-live' : 'ark-plan';
+ const row = root.querySelector<HTMLElement>(`[data-connection-slug="${slug}"]`);
const detailButton = row?.querySelector('button') ?? null;
detailButton?.click();
return Boolean(detailButton);
@@ -432,6 +453,18 @@ export const ConnectionDetailPage: Story = {
),
};
+// Real path: 设置 → 模型 → click a connection whose provider has no model-list
+// endpoint — 添加模型 stands where 更新模型目录 would, and the capability section
+// lists the models Maka's bundled metadata cannot describe.
+export const StaticCatalogConnectionDetail: Story = {
+ render: () => (
+ <ProviderStory
+ bridge={createBridge({ connections: staticCatalogConnections, defaultSlug: 'ark-plan' })}
+ autoOpen="detail-static"
+ />
+ ),
+};
+
// Real path: 设置 → 模型 → 添加连接 — level two, the provider catalog.
export const AddConnectionCatalog: Story = {
render: () => (
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index cb1b8ed82..d7ac3ba86 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -5,7 +5,7 @@ Each row is one on-disk product surface file. Regenerated inventory must stay in
Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding.
-**Totals:** 189 files — blocker 0, polish 0, aligned 189.
+**Totals:** 190 files — blocker 0, polish 0, aligned 190.
## Exclusions (explicit)
@@ -81,6 +81,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/settings/personalization-settings-section.tsx` | settings-module | SegmentedControl, SegmentedControlItem | aligned — uses Astryx (SegmentedControl, SegmentedControlItem) | aligned |
| `apps/desktop/src/renderer/settings/projects-settings-page.tsx` | settings-page | Badge, Banner, Button, EmptyState, HStack, List, ListItem | aligned — uses Astryx (Badge, Banner, Button, EmptyState, HStack, List, ListItem) | aligned |
| `apps/desktop/src/renderer/settings/provider-add-form.tsx` | settings-module | Banner, Button, Collapsible, HStack, VStack | aligned — uses Astryx (Banner, Button, Collapsible, HStack, VStack) | aligned |
+| `apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx` | settings-module | Button, Dialog, DialogHeader, Layout, LayoutContent | aligned — uses Astryx (Button, Dialog, DialogHeader, Layout, LayoutContent) | aligned |
| `apps/desktop/src/renderer/settings/provider-brand-marks.tsx` | settings-module | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/settings/provider-catalog-page.tsx` | settings-page | Banner, Button, EmptyState, HStack, List, ListItem, Section, Selector, VStack | aligned — uses Astryx (Banner, Button, EmptyState, HStack, List, ListItem, Section, Selector) | aligned |
| `apps/desktop/src/renderer/settings/provider-connection-detail.tsx` | settings-module | Banner, Button, Divider, HStack, Heading, Text, VStack | aligned — uses Astryx (Banner, Button, Divider, HStack, Heading, Text, VStack) | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index 92e5d4da2..08fc17c47 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -53,6 +53,7 @@ apps/desktop/src/renderer/settings/permission-center-page.tsx
apps/desktop/src/renderer/settings/personalization-settings-section.tsx
apps/desktop/src/renderer/settings/projects-settings-page.tsx
apps/desktop/src/renderer/settings/provider-add-form.tsx
+apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx
apps/desktop/src/renderer/settings/provider-brand-marks.tsx
apps/desktop/src/renderer/settings/provider-catalog-page.tsx
apps/desktop/src/renderer/settings/provider-connection-detail.tsx
diff --git a/packages/core/src/__tests__/model-thinking.test.ts b/packages/core/src/__tests__/model-thinking.test.ts
index b1b98e7d4..d7d994514 100644
--- a/packages/core/src/__tests__/model-thinking.test.ts
+++ b/packages/core/src/__tests__/model-thinking.test.ts
@@ -105,23 +105,28 @@ test('relayModelProfile normalizes order, keeps explicit vision:false, and bound
}
});
-test('relayModelProfile gates declarations to custom OpenAI relays', () => {
+test('relayModelProfile honours a declaration on any provider', () => {
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);
+ // A declaration is a user statement about one model, and the reason to make
+ // one — Maka has no other way to learn the fact — is not confined to relays:
+ // it holds for any model newer than the bundled snapshot, and for every
+ // model on a provider with no model-list endpoint (#1584).
+ for (const providerType of [
+ 'openai-compatible',
+ 'openai-responses-compatible',
+ 'anthropic',
+ 'volcengine-agent-plan',
+ ] as const) {
+ assert.deepEqual(relayModelProfile({ providerType, relayModelProfiles: profiles }, 'm'), {
+ vision: true,
+ contextWindow: 64_000,
+ });
}
+ // Absent stays absent: an undeclared model falls through to the metadata chain.
+ assert.equal(
+ relayModelProfile({ providerType: 'anthropic', relayModelProfiles: profiles }, 'other'),
+ undefined,
+ );
});
test('isRelayProviderType only accepts the two custom OpenAI relay providers', () => {
diff --git a/packages/core/src/__tests__/runtime-policy-codec.test.ts b/packages/core/src/__tests__/runtime-policy-codec.test.ts
index 961db43d6..133ef54bc 100644
--- a/packages/core/src/__tests__/runtime-policy-codec.test.ts
+++ b/packages/core/src/__tests__/runtime-policy-codec.test.ts
@@ -304,36 +304,78 @@ test('relay model profiles round-trip canonical entries and drafts, strictly', (
enabledModelIds: [],
});
- // Profiles are a relay-only feature: non-empty tables on other providers
- // are rejected at the write seam (null/absent stay legal for cleanup).
- assert.throws(
- () =>
- normalizeCreateCatalogConnectionInput({
- expectedCatalogRevision: 0,
- connection: {
- slug: 'not-a-relay',
- name: 'Other',
- providerType: 'openai',
- enabled: true,
- enabledModelIds: ['relay-reasoner'],
- relayModelProfiles: table,
- },
- }),
- RuntimePolicyDomainDecodeError,
+ // The write seam splits the table by FIELD, not by provider (#1584).
+ // `contextWindow` and `vision` state facts about a model, and a user has
+ // them when Maka does not — a model newer than the bundled snapshot, or any
+ // model on a provider with no model-list endpoint — so they are legal
+ // everywhere.
+ const facts = { 'relay-reasoner': { vision: true, contextWindow: 128_000 } };
+ assert.deepEqual(
+ normalizeCreateCatalogConnectionInput({
+ expectedCatalogRevision: 0,
+ connection: {
+ slug: 'not-a-relay',
+ name: 'Other',
+ providerType: 'openai',
+ enabled: true,
+ enabledModelIds: ['relay-reasoner'],
+ relayModelProfiles: facts,
+ },
+ }).connection.relayModelProfiles,
+ facts,
);
- assert.throws(
- () =>
- normalizeConnectionCatalogEntryUpdateForProvider(
- {
- name: 'Other',
- enabled: true,
- enabledModelIds: ['relay-reasoner'],
- relayModelProfiles: table,
- },
- 'anthropic',
- ),
- RuntimePolicyDomainDecodeError,
+ assert.deepEqual(
+ normalizeConnectionCatalogEntryUpdateForProvider(
+ {
+ name: 'Other',
+ enabled: true,
+ enabledModelIds: ['relay-reasoner'],
+ relayModelProfiles: facts,
+ },
+ 'anthropic',
+ ).relayModelProfiles,
+ facts,
);
+
+ // `thinkingLevels` and `serviceTier` name a wire feature only the
+ // OpenAI-compatible relays accept, so they stay relay-only on both write
+ // paths: elsewhere they are a request Maka would never send.
+ for (const wireShaped of [
+ { 'relay-reasoner': { thinkingLevels: ['low'] } },
+ { 'relay-reasoner': { serviceTier: 'fast' } },
+ ]) {
+ assert.throws(
+ () =>
+ normalizeCreateCatalogConnectionInput({
+ expectedCatalogRevision: 0,
+ connection: {
+ slug: 'not-a-relay',
+ name: 'Other',
+ providerType: 'openai',
+ enabled: true,
+ enabledModelIds: ['relay-reasoner'],
+ relayModelProfiles: wireShaped,
+ },
+ }),
+ /require[s]? an OpenAI-compatible connection/,
+ JSON.stringify(wireShaped),
+ );
+ assert.throws(
+ () =>
+ normalizeConnectionCatalogEntryUpdateForProvider(
+ {
+ name: 'Other',
+ enabled: true,
+ enabledModelIds: ['relay-reasoner'],
+ relayModelProfiles: wireShaped,
+ },
+ 'anthropic',
+ ),
+ /require[s]? an OpenAI-compatible connection/,
+ JSON.stringify(wireShaped),
+ );
+ }
+
assert.equal(
normalizeConnectionCatalogEntryUpdateForProvider(
{ name: 'Other', enabled: true, enabledModelIds: [], relayModelProfiles: null },
diff --git a/packages/core/src/model-thinking.ts b/packages/core/src/model-thinking.ts
index cc9b4ee21..b702b5a4e 100644
--- a/packages/core/src/model-thinking.ts
+++ b/packages/core/src/model-thinking.ts
@@ -17,7 +17,6 @@
* 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';
@@ -102,22 +101,35 @@ export function deriveThinkingChoices(
}
/**
- * 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
- * exception in shape, not spirit, is `vision: false`: an explicit DISABLE
- * that overrides Auto, because the runtime would otherwise believe a vision
- * report the relay may emit regardless.
+ * One model as declared by the user: the facts no other source can be trusted
+ * to know. Every field is independent, and every ABSENT field means "Auto" —
+ * the provider's /models report and the metadata chain decide. The single
+ * exception in shape, not spirit, is `vision: false`: an explicit DISABLE that
+ * overrides Auto, because the runtime would otherwise believe a vision report
+ * the provider may emit regardless.
*
- * Profiles live on the connection as a first-class typed field
- * (`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 custom OpenAI
- * relay connections, and only for models in `enabledModelIds`
- * (disabling a model deletes its profile).
+ * Declarations live on the connection as a first-class typed field
+ * (`relayModelProfiles`, keyed by model id) rather than on the `models[]` rows
+ * a catalog refresh rewrites, so they sit next to the user-edited connection
+ * fields and survive it.
+ *
+ * The name is historical, and the table now splits by FIELD rather than by
+ * provider. `contextWindow` and `vision` state facts about a model: a relay's
+ * models are unknown to `model-metadata.ts`, but so is any model newer than
+ * the bundled snapshot on any provider, and a provider without a model-list
+ * endpoint cannot describe one either. Confining those to relays left the user
+ * no way to state a context window Maka had no other way to learn (#1584).
+ *
+ * `thinkingLevels` and `serviceTier` stay relay-only: they name wire features
+ * (`reasoning_effort` tiers, priority processing) that only the
+ * OpenAI-compatible relays accept. `assertProfileFieldsFitProvider` in the
+ * catalog codec is the write seam that enforces it, so reads here do not
+ * re-derive it; `supportsRelayFastServiceTier` below is a narrower read-side
+ * question — which relay MODELS carry the tier.
+ *
+ * One invariant is still enforced at the store boundaries: declarations exist
+ * only for models in `enabledModelIds` (disabling a model deletes its
+ * declaration).
*/
export interface RelayModelProfile {
readonly thinkingLevels?: readonly ThinkingLevel[];
@@ -226,10 +238,9 @@ export interface ConnectionThinkingContext {
}
/**
- * The one gated read seam for relay profiles. Profiles are an
- * `openai-compatible` feature — on every other provider the metadata chain
- * is the truth — and thinking, vision, and context window reads all enter
- * through here so the gate cannot leak. Entries ride through
+ * The one read seam for user declarations: thinking, vision, and context
+ * window reads all enter through here, so the precedence of a declaration over
+ * the metadata chain is decided in exactly one place. Entries ride through
* `normalizeRelayModelProfile` so even a hand-edited local file degrades to
* Auto instead of trusting a malformed field.
*/
@@ -237,7 +248,6 @@ export function relayModelProfile(
connection: ConnectionThinkingContext,
modelId: string,
): RelayModelProfile | undefined {
- if (!isRelayProviderType(connection.providerType)) return undefined;
return normalizeRelayModelProfile(connection.relayModelProfiles?.[modelId]);
}
diff --git a/packages/core/src/runtime-policy/connection-catalog-codec.ts b/packages/core/src/runtime-policy/connection-catalog-codec.ts
index 7f776916c..6b4a07255 100644
--- a/packages/core/src/runtime-policy/connection-catalog-codec.ts
+++ b/packages/core/src/runtime-policy/connection-catalog-codec.ts
@@ -125,9 +125,7 @@ export function normalizeConnectionCatalogEntryDraft(value: unknown): Connection
item.relayModelProfiles === undefined
? {}
: nonEmptyRelayProfiles(item.relayModelProfiles, enabledModelIds);
- if (profiles.relayModelProfiles !== undefined) {
- rejectForeignProfiles(providerType);
- }
+ assertProfileFieldsFitProvider(profiles.relayModelProfiles, providerType);
return {
slug: decodeConnectionSlug(item.slug),
name: decodeConnectionName(item.name),
@@ -190,11 +188,7 @@ export function normalizeConnectionCatalogEntryUpdateForProvider(
): ConnectionCatalogEntryUpdate {
const update = normalizeConnectionCatalogEntryUpdate(value);
const baseUrl = normalizeCatalogConnectionBaseUrl(update.baseUrl, providerType);
- // A `null` (clear stale data) or absent (untouched) instruction is legal on
- // any provider; only a non-empty table is relay-only.
- if (update.relayModelProfiles !== undefined && update.relayModelProfiles !== null) {
- rejectForeignProfiles(providerType);
- }
+ assertProfileFieldsFitProvider(update.relayModelProfiles, providerType);
return {
name: update.name,
...(baseUrl === undefined ? {} : { baseUrl }),
@@ -209,15 +203,6 @@ export function normalizeConnectionCatalogEntryUpdateForProvider(
};
}
-// 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 (!isRelayProviderType(providerType)) {
- throw domainError('relay model profiles are only supported for OpenAI-compatible connections');
- }
-}
-
/**
* The relay profiles carried by catalog entries, keyed by model id. Strict
* on purpose: writers (the settings UI) emit already-normalized tables, so
@@ -302,6 +287,41 @@ export function decodeRelayModelProfilesTable(
return Object.fromEntries(parsed);
}
+/**
+ * Which declarations a provider can actually carry.
+ *
+ * `contextWindow` and `vision` state facts about a model. A user has them when
+ * Maka does not — a model newer than the bundled snapshot, or any model on a
+ * provider with no model-list endpoint — and that need is not confined to
+ * relays (#1584), so they are legal everywhere.
+ *
+ * `thinkingLevels` and `serviceTier` name a wire feature instead. They encode
+ * into request shapes only the OpenAI-compatible relays accept —
+ * `reasoning_effort` tiers and priority processing — and
+ * `supportsRelayFastServiceTier` gates the read side by provider for the same
+ * reason. A table carrying them on another provider describes a request Maka
+ * would never send: dead state at best, and on a provider whose wire rejects
+ * the unknown value, a 400 the user cannot explain.
+ */
+function assertProfileFieldsFitProvider(
+ profiles: Readonly<Record<string, RelayModelProfile>> | null | undefined,
+ providerType: ProviderType,
+): void {
+ if (!profiles || isRelayProviderType(providerType)) return;
+ for (const [modelId, profile] of Object.entries(profiles)) {
+ if (profile.thinkingLevels !== undefined) {
+ throw domainError(
+ `declared thinking levels for ${modelId} require an OpenAI-compatible connection`,
+ );
+ }
+ if (profile.serviceTier !== undefined) {
+ throw domainError(
+ `declared service tier for ${modelId} requires an OpenAI-compatible connection`,
+ );
+ }
+ }
+}
+
// An empty table is not a state worth storing: drafts/canonical entries omit
// the key, and updates treat it as the same instruction as `null` (clear).
function nonEmptyRelayProfiles(
diff --git a/packages/runtime/src/__tests__/context-budget-mid-turn-policy.test.ts b/packages/runtime/src/__tests__/context-budget-mid-turn-policy.test.ts
index 486bb29d0..87ac196c3 100644
--- a/packages/runtime/src/__tests__/context-budget-mid-turn-policy.test.ts
+++ b/packages/runtime/src/__tests__/context-budget-mid-turn-policy.test.ts
@@ -211,10 +211,13 @@ describe('declared relay context window', () => {
assert.equal(fallback?.maxHistoryEstimatedTokens, 8_192 - 2_048);
});
- test('the same table is inert on non-relay providers', () => {
- // Declarations are an openai-compatible feature; a profiles table riding
- // along on another provider (stale import, provider flip) must not shadow
- // that provider's stored rows or metadata.
+ test('a declared context window holds on any provider', () => {
+ // A context window is a fact about the model, and the reason to declare
+ // one — Maka has no other way to learn it — is not confined to relays: it
+ // holds for a model newer than the bundled snapshot, and for every model
+ // on a provider with no model-list endpoint (#1584). What stays relay-only
+ // is the wire-shaped fields, `thinkingLevels` and `serviceTier`, which the
+ // catalog codec refuses to persist on another provider.
const other: LlmConnection = {
slug: 'other',
name: 'Other',
@@ -227,7 +230,14 @@ describe('declared relay context window', () => {
relayModelProfiles: { 'reasoner-32k': { contextWindow: 131_072 } },
};
const policy = buildDefaultContextBudgetPolicy(other, { env: {}, modelId: 'reasoner-32k' });
- assert.equal(policy?.maxHistoryEstimatedTokens, 8_192 - 2_048);
+ assert.equal(policy?.maxHistoryEstimatedTokens, 131_072 - 16_384);
+ // Absent stays absent: an undeclared model still reads the stored row.
+ const undeclared: LlmConnection = { ...other, relayModelProfiles: undefined };
+ assert.equal(
+ buildDefaultContextBudgetPolicy(undeclared, { env: {}, modelId: 'reasoner-32k' })
+ ?.maxHistoryEstimatedTokens,
+ 8_192 - 2_048,
+ );
});
});
diff --git a/packages/runtime/src/context-budget-policy.ts b/packages/runtime/src/context-budget-policy.ts
index 886c1780c..0ca6af5cd 100644
--- a/packages/runtime/src/context-budget-policy.ts
+++ b/packages/runtime/src/context-budget-policy.ts
@@ -419,9 +419,10 @@ export function resolveSelectedModelContextWindow(
): number | undefined {
const selectedModelId = modelId ?? connection.defaultModel;
if (selectedModelId === undefined) return undefined;
- // A user declaration outranks both the relay's /models report and generated
- // metadata — mirrors the declared-vision precedence in model-metadata.ts,
- // through the same provider-gated seam (relay declarations only).
+ // A user declaration outranks both the provider's /models report and
+ // generated metadata — mirrors the declared-vision precedence in
+ // model-metadata.ts. A declared context window is legal on any provider: it
+ // states a fact about the model, not a request shape (#1584).
const declared = relayModelProfile(connection, selectedModelId)?.contextWindow;
if (declared !== undefined) return declared;
const model = connection.models?.find((candidate) => candidate.id === selectedModelId);
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org