Skip to content

Commit 1c5fd39

Browse files
authored
fix: stop treating a model list as an allowlist (#3330)
* fix(core,runtime,runtime-host,storage): stop treating a model list as an allowlist A Volcengine Ark Agent Plan key could not select the models its own plan serves. The plan's key reaches only the inference data plane, so Maka had no way to enumerate the account and fell back to the array this build ships. Every gate then read that array as the set of models the account has, and refused everything else. The array is not that set, and no array Maka holds is. A `/models` response describes one call at one moment, scoped to whatever the provider chose to report; a shipped snapshot describes the provider, not the account; and four providers have no list endpoint at all. Only the user knows what their account can run, and only the provider can settle it — by answering the request. So the allowlist goes, rather than growing another exception: - `authorizeConnectionModel` in `llm-connections.ts` is the one seam that answers "can this connection run this model". The user's selection is the authorization. The observed row still wins wherever it exists, for its wire metadata and capabilities, but its absence authorizes nothing and vetoes nothing. - A fetch no longer prunes what the user chose. `reconcileConnectionAfterModelFetch` seeds a first default only for a connection that has never had a list to pick from, and otherwise returns the selection untouched. - `filterDiscoveredModels` no longer intersects a live response with the shipped snapshot, which was silently dropping models from six providers whose real catalogs outgrew it. - Readiness, the connection probe, the hosted execution target and the catalog coordinator all consult that one seam. `connection-model-admission.ts` and its hardcoded DeepSeek exception are deleted. What the picker SAYS is unchanged in spirit and now purely advisory: `classifyConnectionModelInventory` still separates a live response from a snapshot, and a model the live response omitted is annotated `not_in_live_list` — a warning the user can act on, not a refusal. Fixes #1584 Generated-by: Claude Code * 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 * fix(desktop): hold the add-model draft until the write lands The dialog closed on submit and cleared both fields while the write was still in flight, so a rejection left the user with a toast and nothing to retry from — and an exact model id is not something anyone reproduces from memory, which is the whole reason this dialog asks for one. `addDeclaredModel` now returns whether the write landed, matching `save` and `saveRelayProfiles` in the same hook, and the dialog closes only on success. While the write is in flight the confirm button shows a saving state and dismissal is refused, as in `goal-dialog`. Generated-by: Claude Code
1 parent e44ddce commit 1c5fd39

36 files changed

Lines changed: 1341 additions & 405 deletions

apps/desktop/src/renderer/locales/settings-provider-copy.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,16 @@ const zhCopy = {
6969
credentialUnknownDetail: '模型凭据状态暂时没刷新成功,已避免把未知状态显示成未登录或未配置。',
7070
testConnection: '测试连接',
7171
updateModels: '更新模型目录', endpoint: '服务地址',
72+
addModel: '添加模型',
73+
addModelConfirm: '添加',
74+
addModelIdField: '模型 ID',
75+
addModelIdFieldHelp: '需与服务商完全一致,区分大小写。',
76+
addModelIdPlaceholder: 'deepseek-v4-pro-beta',
77+
addModelIdRequired: '请填写模型 ID。',
78+
addModelIdDuplicate: '该模型已在列表中。',
79+
addModelContextWindow: '上下文窗口',
80+
addModelContextWindowHelp: '服务商模型页给出的最大 token 数。缺少它 Maka 只能按 32k 处理,长对话会被提前截断。',
81+
addModelContextWindowRequired: '请填写上下文窗口。',
7282
credentials: '连接', dangerZone: '删除连接', deleteRowHelp: '此操作不可撤销。',
7383
credentialsHelp: '密钥只保存在本机。',
7484
credentialsHelpAccount: '登录令牌只保存在本机。',
@@ -205,6 +215,17 @@ const enCopy: ProviderSettingsCopy = {
205215
credentialUnknownDetail: 'Model credential status could not be refreshed, so the connection is not being mislabeled as signed out or unconfigured.',
206216
testConnection: 'Test connection',
207217
updateModels: 'Update model catalog', endpoint: 'Service URL',
218+
addModel: 'Add model',
219+
addModelConfirm: 'Add',
220+
addModelIdField: 'Model ID',
221+
addModelIdFieldHelp: 'Must match the provider exactly, including case.',
222+
addModelIdPlaceholder: 'deepseek-v4-pro-beta',
223+
addModelIdRequired: 'Enter a model ID.',
224+
addModelIdDuplicate: 'This model is already in the list.',
225+
addModelContextWindow: 'Context window',
226+
addModelContextWindowHelp:
227+
"The maximum token count from the provider's model page. Without it Maka can only assume 32k, and long conversations get truncated early.",
228+
addModelContextWindowRequired: 'Enter a context window.',
208229
credentials: 'Connection', dangerZone: 'Delete connection', deleteRowHelp: 'This cannot be undone.',
209230
credentialsHelp: 'The key stays on this machine.',
210231
credentialsHelpAccount: 'The sign-in token stays on this machine.',
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { useState, type FormEvent } from 'react';
2+
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
3+
import { FormLayout } from '@astryxdesign/core/FormLayout';
4+
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
5+
import { Button, HStack, NumberInput, TextInput, useUiLocale } from '@maka/ui';
6+
import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
7+
8+
/**
9+
* Introduce a model by exact id, for a provider whose catalog cannot grow on
10+
* its own: without a model-list endpoint, refresh replays the array this build
11+
* shipped, so a model the user's plan already serves has no other way in
12+
* (#1584).
13+
*
14+
* Two fields. The id enters `enabledModelIds`, which is the authorization. The
15+
* context window is the one fact nothing else can supply: an id Maka has never
16+
* seen resolves no window, and the history budget falls back to a flat 32k
17+
* (context-budget-policy.ts) — three percent of a 1M-token window.
18+
*
19+
* Everything else a user can declare is edited in the capability section below
20+
* the model list, which shows a row for exactly the models Maka cannot
21+
* describe — every model added here, the moment it is added.
22+
*/
23+
export function AddModelDialog(props: {
24+
isOpen: boolean;
25+
existingModelIds: readonly string[];
26+
/** Another write is in flight; the store would drop this one on the floor. */
27+
isSubmitDisabled?: boolean;
28+
onOpenChange(open: boolean): void;
29+
/** Resolves to whether the write landed; the draft is held until it did. */
30+
onSubmit(id: string, contextWindow: number): Promise<boolean>;
31+
}) {
32+
const copy = getProviderSettingsCopy(useUiLocale()).detail;
33+
const [id, setId] = useState('');
34+
const [contextWindow, setContextWindow] = useState<number | null>(null);
35+
const [submitAttempted, setSubmitAttempted] = useState(false);
36+
const [isSaving, setSaving] = useState(false);
37+
38+
const trimmedId = id.trim();
39+
const idError = !trimmedId
40+
? copy.addModelIdRequired
41+
: props.existingModelIds.includes(trimmedId)
42+
? copy.addModelIdDuplicate
43+
: null;
44+
// Required, not defaulted: an unknown window falls back to a flat 32k history
45+
// budget, and guessing higher on the user's behalf would trade a wasted
46+
// window for requests the provider rejects outright. Whoever types an exact
47+
// model id is reading the provider's own model page, where this is stated.
48+
const contextWindowError = contextWindow ? null : copy.addModelContextWindowRequired;
49+
50+
function close() {
51+
setId('');
52+
setContextWindow(null);
53+
setSubmitAttempted(false);
54+
props.onOpenChange(false);
55+
}
56+
57+
// Closing on submit would clear the draft before the write settles, and an
58+
// exact model id is not something a user can reproduce from memory. The
59+
// failure is reported by the caller's toast; what this owes them is the
60+
// typed text, still there to retry from.
61+
async function submit(event: FormEvent) {
62+
event.preventDefault();
63+
setSubmitAttempted(true);
64+
if (idError || !contextWindow || isSaving) return;
65+
setSaving(true);
66+
try {
67+
if (await props.onSubmit(trimmedId, contextWindow)) close();
68+
} finally {
69+
setSaving(false);
70+
}
71+
}
72+
73+
return (
74+
<Dialog
75+
isOpen={props.isOpen}
76+
onOpenChange={(open) => {
77+
// A write in flight owns the draft until it settles: dismissing here
78+
// would discard the very text the retry needs.
79+
if (!open && !isSaving) close();
80+
}}
81+
purpose="form"
82+
width={480}
83+
>
84+
<Layout
85+
header={
86+
<DialogHeader
87+
title={copy.addModel}
88+
onOpenChange={(open) => {
89+
if (!open && !isSaving) close();
90+
}}
91+
/>
92+
}
93+
content={
94+
<LayoutContent>
95+
<form id="maka-add-model-form" onSubmit={(event) => void submit(event)}>
96+
<FormLayout>
97+
{/* The exact id, kept verbatim through selection and inference
98+
— `deepseek-v4-pro-beta` is a different model from
99+
`deepseek-v4-pro`, and only the user knows which one their
100+
plan actually serves. */}
101+
<TextInput
102+
label={copy.addModelIdField}
103+
description={copy.addModelIdFieldHelp}
104+
isRequired
105+
hasAutoFocus
106+
value={id}
107+
placeholder={copy.addModelIdPlaceholder}
108+
onChange={setId}
109+
status={
110+
submitAttempted && idError ? { type: 'error', message: idError } : undefined
111+
}
112+
/>
113+
<NumberInput
114+
label={copy.addModelContextWindow}
115+
description={copy.addModelContextWindowHelp}
116+
isRequired
117+
value={contextWindow}
118+
hasClear
119+
isIntegerOnly
120+
min={1}
121+
onChange={setContextWindow}
122+
status={
123+
submitAttempted && contextWindowError
124+
? { type: 'error', message: contextWindowError }
125+
: undefined
126+
}
127+
/>
128+
</FormLayout>
129+
</form>
130+
</LayoutContent>
131+
}
132+
footer={
133+
<LayoutFooter>
134+
{/* One button, as in scheduled-task-form-dialog: the header's close
135+
control and Escape are already two ways out, so a footer cancel
136+
would be a third route to the same place. */}
137+
<HStack gap={2} hAlign="end">
138+
<Button
139+
variant="primary"
140+
type="submit"
141+
form="maka-add-model-form"
142+
isDisabled={props.isSubmitDisabled || isSaving}
143+
isLoading={isSaving}
144+
label={copy.addModelConfirm}
145+
/>
146+
</HStack>
147+
</LayoutFooter>
148+
}
149+
/>
150+
</Dialog>
151+
);
152+
}

apps/desktop/src/renderer/settings/provider-connection-detail.tsx

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
VStack,
1313
} from '@astryxdesign/core';
1414
import { isRelayProviderType, PROVIDER_DEFAULTS } from '@maka/core/llm-connections';
15+
import { hasModelMetadata } from '@maka/core/model-metadata';
1516
import {
1617
DECLARABLE_RELAY_THINKING_LEVELS,
1718
THINKING_LEVELS,
@@ -33,6 +34,7 @@ import { PasswordInput } from './password-input';
3334
import { SettingsExpandableRow } from './settings-expandable-row';
3435
import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
3536
import { providerDisplay } from './provider-display';
37+
import { AddModelDialog } from './provider-add-model-dialog';
3638
import { EnabledModelManager } from './provider-enabled-model-manager';
3739
import { useActionGuard } from './use-action-guard';
3840
import { useRuntimeHostSettingsTarget } from './runtime-host-settings-target.js';
@@ -149,6 +151,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
149151
savedBaseUrl,
150152
save,
151153
updateEnabledModels,
154+
addDeclaredModel,
152155
relayProfileDraft,
153156
hasRelayProfileChanges,
154157
setDraftThinkingLevels,
@@ -161,22 +164,34 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
161164
remove,
162165
refreshAfterRelogin,
163166
} = useConnectionDetail(props);
164-
// Capability switches only exist for custom OpenAI relays: built-in
165-
// providers declare their thinking support in model metadata, a custom
166-
// relay's backing model is unknown until the user says what it can do. The
167-
// declaration is per model — a relay can front both a reasoner and a plain
168-
// instruct model.
169-
const showsCapabilities = isRelayProviderType(connection.providerType);
167+
// A model gets capability switches when Maka cannot describe it otherwise.
168+
// On a custom OpenAI relay that is every model: the id is whatever the
169+
// operator chose, so even one that collides with a known name may front
170+
// something else entirely. Elsewhere it is the models the bundled metadata
171+
// has never heard of — a model newer than this build, or one the user typed
172+
// in on a provider whose key cannot call a model-list endpoint, which no
173+
// refresh will ever describe (#1584).
174+
//
175+
// A model that already carries a declaration always keeps its row, or a
176+
// stale declaration would be uneditable and unclearable.
177+
const isRelay = isRelayProviderType(connection.providerType);
170178
// Rows are the enabled models, exactly — the store prunes a model's profile
171179
// the moment it is disabled, so no declaration can ever belong to a row
172180
// this list does not show. The editor edits the per-model draft; 保存
173181
// commits the whole table in one write.
174-
const capabilityModelIds = enabledModelIds;
182+
const capabilityModelIds = enabledModelIds.filter(
183+
(modelId) =>
184+
isRelay ||
185+
relayProfileDraft[modelId] !== undefined ||
186+
!hasModelMetadata(connection.providerType, modelId),
187+
);
188+
const showsCapabilities = capabilityModelIds.length > 0;
175189
// One row is a form at a time, the way the settings-sidebar template does it.
176190
// Opening a row discards the other's draft: leaving an abandoned draft in
177191
// state meant it reappeared when the user came back to that row, and — until
178192
// `save` became per-field — rode along with the next save.
179193
const [editingRow, setEditingRow] = useState<'key' | 'endpoint' | 'headers' | 'body' | null>(null);
194+
const [addModelOpen, setAddModelOpen] = useState(false);
180195
const [savedHeaderNames, setSavedHeaderNames] = useState<readonly string[]>([]);
181196
const [headerDrafts, setHeaderDrafts] = useState<RequestHeaderDraft[]>([]);
182197
const savedBodyText = formatRequestBodyOverlay(connection.requestBodyOverlay);
@@ -521,10 +536,31 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
521536
would pass a MouseEvent as `opts`. */}
522537
<HStack gap={2} vAlign="center" wrap="wrap">
523538
<Button variant="secondary" isDisabled={allActionsBusy || !hasUsableCredential} clickAction={() => runTest()} label={copy.testConnection} />
539+
{/* Both, wherever refresh exists. Refresh is the fast path and stays
540+
first, but having a model-list endpoint does not mean the endpoint
541+
answers for this account: a self-hosted gateway on
542+
`openai-compatible` may not serve /models at all, and a provider's
543+
list can lag a model the account already has. Making the two
544+
alternatives left those users with no way in (#1584). */}
524545
{supportsRemoteDiscovery && (
525546
<Button variant="ghost" isDisabled={allActionsBusy || !hasUsableCredential} clickAction={() => refreshModels()} label={copy.updateModels} />
526547
)}
548+
<Button variant="ghost" isDisabled={allActionsBusy} clickAction={() => setAddModelOpen(true)} label={copy.addModel} />
527549
</HStack>
550+
<AddModelDialog
551+
isOpen={addModelOpen}
552+
/* The catalog, not just the selection: `models` is usually a proper
553+
superset of what the user enabled. Checking only the selection lets
554+
a listed-but-unchecked id through, and the dialog then requires a
555+
hand-typed context window that overrides the one Maka already
556+
knows. */
557+
existingModelIds={[...enabledModelIds, ...(connection.models ?? []).map(({ id }) => id)]}
558+
/* A write started after the dialog opened would make the store drop
559+
this submission silently, taking the typed id with it. */
560+
isSubmitDisabled={allActionsBusy}
561+
onOpenChange={setAddModelOpen}
562+
onSubmit={addDeclaredModel}
563+
/>
528564
</DetailSection>
529565
)}
530566
{showsCapabilities && !retired && (
@@ -568,6 +604,12 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
568604
left, one compact control on the right (the 模型功能
569605
row language). A CheckboxList wall was the reason this
570606
section looked like a form from a different app. */}
607+
{/* Relay-only, like 快速模式 below: a declared level encodes
608+
into `reasoning_effort`, a wire field only the
609+
OpenAI-compatible relays accept. The catalog codec
610+
refuses to persist one elsewhere, so offering the
611+
control would promise an edit that cannot be saved. */}
612+
{isRelay && (
571613
<CapabilityRow label={copy.thinkingEffort} description={copy.thinkingEffortHelp}>
572614
{/* DropdownMenu, not MultiSelector: levels have a
573615
canonical order (low → max) that must not shuffle —
@@ -609,6 +651,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
609651
))}
610652
</DropdownMenu>
611653
</CapabilityRow>
654+
)}
612655
<CapabilityRow label={copy.visionInput} description={copy.visionInputHelp}>
613656
<Selector
614657
label={`${copy.visionInput}${modelId}`}

apps/desktop/src/renderer/settings/use-connection-detail.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
244244
slug: connection.slug,
245245
providerType: connection.providerType,
246246
defaultModel: connection.defaultModel,
247+
enabledModelIds,
247248
models: modelSource === 'fetched' || models.length > 0 ? models : undefined,
248249
modelSource,
249250
modelsFetchedAt: connection.modelsFetchedAt,
@@ -484,6 +485,70 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
484485
}
485486
}
486487

488+
/**
489+
* Introduce a model the provider's catalog does not list.
490+
*
491+
* Only offered where refresh cannot help: a provider with no model-list
492+
* endpoint replays the array this build shipped, so a model the user's plan
493+
* serves but Maka has never heard of has no other way in (#1584).
494+
*
495+
* The id enters `enabledModelIds` — the same user-selection authority a
496+
* catalogued model uses, so nothing here pretends the provider advertised
497+
* it — and the context window enters `relayModelProfiles`, which is where a
498+
* user states a fact no other source knows. Both go in ONE write: the store
499+
* requires every declaration to key an enabled model, so a table written
500+
* ahead of its id would be rejected.
501+
*
502+
* The saved table is the base, not the unsaved draft: adding a model must
503+
* not silently commit edits the user has open in the capability section.
504+
* The draft is then caught up by hand, because a dirty draft deliberately
505+
* does not reseed from props — see `relayProfileDraftReseedPlan`.
506+
*/
507+
async function addDeclaredModel(id: string, contextWindow: number): Promise<boolean> {
508+
const modelId = id.trim();
509+
if (!modelId || enabledModelIds.includes(modelId)) return false;
510+
if (connectionDetailActionGuard.has('save-enabled-models') || detailActionBusy) return false;
511+
const next = [...enabledModelIds, modelId];
512+
const previous = enabledModelIds;
513+
const lifecycle = connectionDetailLifecycleRef.current;
514+
const releaseSaveModels = connectionDetailActionGuard.begin('save-enabled-models');
515+
if (!releaseSaveModels) return false;
516+
setSavingEnabledModels(true);
517+
setEnabledModelIds(next);
518+
let saved = false;
519+
try {
520+
await props.bridge.update(connection.slug, {
521+
enabledModelIds: next,
522+
relayModelProfiles: { ...(savedRelayProfiles ?? {}), [modelId]: { contextWindow } },
523+
});
524+
saved = true;
525+
if (!isConnectionDetailCurrent(lifecycle)) return saved;
526+
// The editor's draft is a second copy of this table, and while it is
527+
// dirty it does not reseed from props — that is what keeps an unrelated
528+
// reload from discarding typed work. So the declaration just written has
529+
// to be merged in here. Without it the draft is a table that no longer
530+
// contains this model, the capability-save button lights up on that
531+
// difference, and its whole-table replace drops the context window the
532+
// user just declared — silently, back to the unknown-model default.
533+
setRelayProfileDrafts((current) => ({ ...current, [modelId]: { contextWindow } }));
534+
await props.onChanged();
535+
} catch (error) {
536+
if (!isConnectionDetailCurrent(lifecycle)) return saved;
537+
if (!saved) setEnabledModelIds(previous);
538+
toast.error(
539+
saved ? copy.refreshFailed : copy.saveModelsFailed,
540+
providerPanelActionErrorMessage(error, locale),
541+
);
542+
} finally {
543+
releaseSaveModels();
544+
if (isConnectionDetailCurrent(lifecycle)) setSavingEnabledModels(false);
545+
}
546+
// Whether the write landed. The dialog holds the typed id and context
547+
// window until it did: a rejected write leaves nothing to retype from, and
548+
// an exact model id is not something a user can reproduce from memory.
549+
return saved;
550+
}
551+
487552
async function runTest() {
488553
const releaseTest = connectionDetailActionGuard.beginExclusive('test');
489554
if (!releaseTest) return;
@@ -653,6 +718,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
653718
lastTestAtMs,
654719
save,
655720
updateEnabledModels,
721+
addDeclaredModel,
656722
relayProfileDraft: relayProfileDrafts,
657723
relayProfilesDirty,
658724
hasRelayProfileChanges,

0 commit comments

Comments
 (0)