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