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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/desktop/src/renderer/locales/settings-provider-copy.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,6 +69,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: '登录令牌只保存在本机。',
Expand DownExpand Up@@ -205,6 +215,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.',
Expand Down
152 changes: 152 additions & 0 deletions apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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;
/** Resolves to whether the write landed; the draft is held until it did. */
onSubmit(id: string, contextWindow: number): Promise<boolean>;
}) {
const copy = getProviderSettingsCopy(useUiLocale()).detail;
const [id, setId] = useState('');
const [contextWindow, setContextWindow] = useState<number | null>(null);
const [submitAttempted, setSubmitAttempted] = useState(false);
const [isSaving, setSaving] = 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);
}

// Closing on submit would clear the draft before the write settles, and an
// exact model id is not something a user can reproduce from memory. The
// failure is reported by the caller's toast; what this owes them is the
// typed text, still there to retry from.
async function submit(event: FormEvent) {
event.preventDefault();
setSubmitAttempted(true);
if (idError || !contextWindow || isSaving) return;
setSaving(true);
try {
if (await props.onSubmit(trimmedId, contextWindow)) close();
} finally {
setSaving(false);
}
}
Comment thread
Astro-Han marked this conversation as resolved.

return (
<Dialog
isOpen={props.isOpen}
onOpenChange={(open) => {
// A write in flight owns the draft until it settles: dismissing here
// would discard the very text the retry needs.
if (!open && !isSaving) close();
}}
purpose="form"
width={480}
>
<Layout
header={
<DialogHeader
title={copy.addModel}
onOpenChange={(open) => {
if (!open && !isSaving) close();
}}
/>
}
content={
<LayoutContent>
<form id="maka-add-model-form" onSubmit={(event) => void submit(event)}>
<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 || isSaving}
isLoading={isSaving}
label={copy.addModelConfirm}
/>
</HStack>
</LayoutFooter>
}
/>
</Dialog>
);
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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';
Expand DownExpand Up@@ -149,6 +151,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
savedBaseUrl,
save,
updateEnabledModels,
addDeclaredModel,
relayProfileDraft,
hasRelayProfileChanges,
setDraftThinkingLevels,
Expand All@@ -161,22 +164,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);
Expand DownExpand Up@@ -521,10 +536,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={addDeclaredModel}
/>
</DetailSection>
)}
{showsCapabilities && !retired && (
Expand DownExpand Up@@ -568,6 +604,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 —
Expand DownExpand Up@@ -609,6 +651,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
))}
</DropdownMenu>
</CapabilityRow>
)}
<CapabilityRow label={copy.visionInput} description={copy.visionInputHelp}>
<Selector
label={`${copy.visionInput} — ${modelId}`}
Expand Down
66 changes: 66 additions & 0 deletions apps/desktop/src/renderer/settings/use-connection-detail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -244,6 +244,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,
Expand DownExpand Up@@ -484,6 +485,70 @@ 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): Promise<boolean> {
const modelId = id.trim();
if (!modelId || enabledModelIds.includes(modelId)) return false;
if (connectionDetailActionGuard.has('save-enabled-models') || detailActionBusy) return false;
const next = [...enabledModelIds, modelId];
const previous = enabledModelIds;
const lifecycle = connectionDetailLifecycleRef.current;
const releaseSaveModels = connectionDetailActionGuard.begin('save-enabled-models');
if (!releaseSaveModels) return false;
setSavingEnabledModels(true);
setEnabledModelIds(next);
let saved = false;
try {
await props.bridge.update(connection.slug, {
enabledModelIds: next,
relayModelProfiles: { ...(savedRelayProfiles ?? {}), [modelId]: { contextWindow } },
Comment thread
Astro-Han marked this conversation as resolved.
});
saved = true;
if (!isConnectionDetailCurrent(lifecycle)) return saved;
// 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 saved;
if (!saved) setEnabledModelIds(previous);
toast.error(
saved ? copy.refreshFailed : copy.saveModelsFailed,
providerPanelActionErrorMessage(error, locale),
);
} finally {
releaseSaveModels();
if (isConnectionDetailCurrent(lifecycle)) setSavingEnabledModels(false);
}
// Whether the write landed. The dialog holds the typed id and context
// window until it did: a rejected write leaves nothing to retype from, and
// an exact model id is not something a user can reproduce from memory.
return saved;
}

async function runTest() {
const releaseTest = connectionDetailActionGuard.beginExclusive('test');
if (!releaseTest) return;
Expand DownExpand Up@@ -653,6 +718,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
lastTestAtMs,
save,
updateEnabledModels,
addDeclaredModel,
relayProfileDraft: relayProfileDrafts,
relayProfilesDirty,
hasRelayProfileChanges,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix: stop treating a model list as an allowlist by Astro-Han · Pull Request #3330 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/desktop/src/renderer/locales/settings-provider-copy.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,6 +69,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: '登录令牌只保存在本机。',
Expand DownExpand Up@@ -205,6 +215,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.',
Expand Down
152 changes: 152 additions & 0 deletions apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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;
/** Resolves to whether the write landed; the draft is held until it did. */
onSubmit(id: string, contextWindow: number): Promise<boolean>;
}) {
const copy = getProviderSettingsCopy(useUiLocale()).detail;
const [id, setId] = useState('');
const [contextWindow, setContextWindow] = useState<number | null>(null);
const [submitAttempted, setSubmitAttempted] = useState(false);
const [isSaving, setSaving] = 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);
}

// Closing on submit would clear the draft before the write settles, and an
// exact model id is not something a user can reproduce from memory. The
// failure is reported by the caller's toast; what this owes them is the
// typed text, still there to retry from.
async function submit(event: FormEvent) {
event.preventDefault();
setSubmitAttempted(true);
if (idError || !contextWindow || isSaving) return;
setSaving(true);
try {
if (await props.onSubmit(trimmedId, contextWindow)) close();
} finally {
setSaving(false);
}
}
Comment thread
Astro-Han marked this conversation as resolved.

return (
<Dialog
isOpen={props.isOpen}
onOpenChange={(open) => {
// A write in flight owns the draft until it settles: dismissing here
// would discard the very text the retry needs.
if (!open && !isSaving) close();
}}
purpose="form"
width={480}
>
<Layout
header={
<DialogHeader
title={copy.addModel}
onOpenChange={(open) => {
if (!open && !isSaving) close();
}}
/>
}
content={
<LayoutContent>
<form id="maka-add-model-form" onSubmit={(event) => void submit(event)}>
<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 || isSaving}
isLoading={isSaving}
label={copy.addModelConfirm}
/>
</HStack>
</LayoutFooter>
}
/>
</Dialog>
);
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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';
Expand DownExpand Up@@ -149,6 +151,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
savedBaseUrl,
save,
updateEnabledModels,
addDeclaredModel,
relayProfileDraft,
hasRelayProfileChanges,
setDraftThinkingLevels,
Expand All@@ -161,22 +164,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);
Expand DownExpand Up@@ -521,10 +536,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={addDeclaredModel}
/>
</DetailSection>
)}
{showsCapabilities && !retired && (
Expand DownExpand Up@@ -568,6 +604,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 —
Expand DownExpand Up@@ -609,6 +651,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
))}
</DropdownMenu>
</CapabilityRow>
)}
<CapabilityRow label={copy.visionInput} description={copy.visionInputHelp}>
<Selector
label={`${copy.visionInput} — ${modelId}`}
Expand Down
66 changes: 66 additions & 0 deletions apps/desktop/src/renderer/settings/use-connection-detail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -244,6 +244,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,
Expand DownExpand Up@@ -484,6 +485,70 @@ 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): Promise<boolean> {
const modelId = id.trim();
if (!modelId || enabledModelIds.includes(modelId)) return false;
if (connectionDetailActionGuard.has('save-enabled-models') || detailActionBusy) return false;
const next = [...enabledModelIds, modelId];
const previous = enabledModelIds;
const lifecycle = connectionDetailLifecycleRef.current;
const releaseSaveModels = connectionDetailActionGuard.begin('save-enabled-models');
if (!releaseSaveModels) return false;
setSavingEnabledModels(true);
setEnabledModelIds(next);
let saved = false;
try {
await props.bridge.update(connection.slug, {
enabledModelIds: next,
relayModelProfiles: { ...(savedRelayProfiles ?? {}), [modelId]: { contextWindow } },
Comment thread
Astro-Han marked this conversation as resolved.
});
saved = true;
if (!isConnectionDetailCurrent(lifecycle)) return saved;
// 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 saved;
if (!saved) setEnabledModelIds(previous);
toast.error(
saved ? copy.refreshFailed : copy.saveModelsFailed,
providerPanelActionErrorMessage(error, locale),
);
} finally {
releaseSaveModels();
if (isConnectionDetailCurrent(lifecycle)) setSavingEnabledModels(false);
}
// Whether the write landed. The dialog holds the typed id and context
// window until it did: a rejected write leaves nothing to retype from, and
// an exact model id is not something a user can reproduce from memory.
return saved;
}

async function runTest() {
const releaseTest = connectionDetailActionGuard.beginExclusive('test');
if (!releaseTest) return;
Expand DownExpand Up@@ -653,6 +718,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
lastTestAtMs,
save,
updateEnabledModels,
addDeclaredModel,
relayProfileDraft: relayProfileDrafts,
relayProfilesDirty,
hasRelayProfileChanges,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: stop treating a model list as an allowlist by Astro-Han · Pull Request #3330 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/desktop/src/renderer/locales/settings-provider-copy.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,6 +69,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: '登录令牌只保存在本机。',
Expand DownExpand Up@@ -205,6 +215,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.',
Expand Down
152 changes: 152 additions & 0 deletions apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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;
/** Resolves to whether the write landed; the draft is held until it did. */
onSubmit(id: string, contextWindow: number): Promise<boolean>;
}) {
const copy = getProviderSettingsCopy(useUiLocale()).detail;
const [id, setId] = useState('');
const [contextWindow, setContextWindow] = useState<number | null>(null);
const [submitAttempted, setSubmitAttempted] = useState(false);
const [isSaving, setSaving] = 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);
}

// Closing on submit would clear the draft before the write settles, and an
// exact model id is not something a user can reproduce from memory. The
// failure is reported by the caller's toast; what this owes them is the
// typed text, still there to retry from.
async function submit(event: FormEvent) {
event.preventDefault();
setSubmitAttempted(true);
if (idError || !contextWindow || isSaving) return;
setSaving(true);
try {
if (await props.onSubmit(trimmedId, contextWindow)) close();
} finally {
setSaving(false);
}
}
Comment thread
Astro-Han marked this conversation as resolved.

return (
<Dialog
isOpen={props.isOpen}
onOpenChange={(open) => {
// A write in flight owns the draft until it settles: dismissing here
// would discard the very text the retry needs.
if (!open && !isSaving) close();
}}
purpose="form"
width={480}
>
<Layout
header={
<DialogHeader
title={copy.addModel}
onOpenChange={(open) => {
if (!open && !isSaving) close();
}}
/>
}
content={
<LayoutContent>
<form id="maka-add-model-form" onSubmit={(event) => void submit(event)}>
<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 || isSaving}
isLoading={isSaving}
label={copy.addModelConfirm}
/>
</HStack>
</LayoutFooter>
}
/>
</Dialog>
);
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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';
Expand DownExpand Up@@ -149,6 +151,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
savedBaseUrl,
save,
updateEnabledModels,
addDeclaredModel,
relayProfileDraft,
hasRelayProfileChanges,
setDraftThinkingLevels,
Expand All@@ -161,22 +164,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);
Expand DownExpand Up@@ -521,10 +536,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={addDeclaredModel}
/>
</DetailSection>
)}
{showsCapabilities && !retired && (
Expand DownExpand Up@@ -568,6 +604,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 —
Expand DownExpand Up@@ -609,6 +651,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
))}
</DropdownMenu>
</CapabilityRow>
)}
<CapabilityRow label={copy.visionInput} description={copy.visionInputHelp}>
<Selector
label={`${copy.visionInput} — ${modelId}`}
Expand Down
66 changes: 66 additions & 0 deletions apps/desktop/src/renderer/settings/use-connection-detail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -244,6 +244,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,
Expand DownExpand Up@@ -484,6 +485,70 @@ 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): Promise<boolean> {
const modelId = id.trim();
if (!modelId || enabledModelIds.includes(modelId)) return false;
if (connectionDetailActionGuard.has('save-enabled-models') || detailActionBusy) return false;
const next = [...enabledModelIds, modelId];
const previous = enabledModelIds;
const lifecycle = connectionDetailLifecycleRef.current;
const releaseSaveModels = connectionDetailActionGuard.begin('save-enabled-models');
if (!releaseSaveModels) return false;
setSavingEnabledModels(true);
setEnabledModelIds(next);
let saved = false;
try {
await props.bridge.update(connection.slug, {
enabledModelIds: next,
relayModelProfiles: { ...(savedRelayProfiles ?? {}), [modelId]: { contextWindow } },
Comment thread
Astro-Han marked this conversation as resolved.
});
saved = true;
if (!isConnectionDetailCurrent(lifecycle)) return saved;
// 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 saved;
if (!saved) setEnabledModelIds(previous);
toast.error(
saved ? copy.refreshFailed : copy.saveModelsFailed,
providerPanelActionErrorMessage(error, locale),
);
} finally {
releaseSaveModels();
if (isConnectionDetailCurrent(lifecycle)) setSavingEnabledModels(false);
}
// Whether the write landed. The dialog holds the typed id and context
// window until it did: a rejected write leaves nothing to retype from, and
// an exact model id is not something a user can reproduce from memory.
return saved;
}

async function runTest() {
const releaseTest = connectionDetailActionGuard.beginExclusive('test');
if (!releaseTest) return;
Expand DownExpand Up@@ -653,6 +718,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
lastTestAtMs,
save,
updateEnabledModels,
addDeclaredModel,
relayProfileDraft: relayProfileDrafts,
relayProfilesDirty,
hasRelayProfileChanges,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: stop treating a model list as an allowlist by Astro-Han · Pull Request #3330 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/desktop/src/renderer/locales/settings-provider-copy.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,6 +69,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: '登录令牌只保存在本机。',
Expand DownExpand Up@@ -205,6 +215,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.',
Expand Down
152 changes: 152 additions & 0 deletions apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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;
/** Resolves to whether the write landed; the draft is held until it did. */
onSubmit(id: string, contextWindow: number): Promise<boolean>;
}) {
const copy = getProviderSettingsCopy(useUiLocale()).detail;
const [id, setId] = useState('');
const [contextWindow, setContextWindow] = useState<number | null>(null);
const [submitAttempted, setSubmitAttempted] = useState(false);
const [isSaving, setSaving] = 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);
}

// Closing on submit would clear the draft before the write settles, and an
// exact model id is not something a user can reproduce from memory. The
// failure is reported by the caller's toast; what this owes them is the
// typed text, still there to retry from.
async function submit(event: FormEvent) {
event.preventDefault();
setSubmitAttempted(true);
if (idError || !contextWindow || isSaving) return;
setSaving(true);
try {
if (await props.onSubmit(trimmedId, contextWindow)) close();
} finally {
setSaving(false);
}
}
Comment thread
Astro-Han marked this conversation as resolved.

return (
<Dialog
isOpen={props.isOpen}
onOpenChange={(open) => {
// A write in flight owns the draft until it settles: dismissing here
// would discard the very text the retry needs.
if (!open && !isSaving) close();
}}
purpose="form"
width={480}
>
<Layout
header={
<DialogHeader
title={copy.addModel}
onOpenChange={(open) => {
if (!open && !isSaving) close();
}}
/>
}
content={
<LayoutContent>
<form id="maka-add-model-form" onSubmit={(event) => void submit(event)}>
<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 || isSaving}
isLoading={isSaving}
label={copy.addModelConfirm}
/>
</HStack>
</LayoutFooter>
}
/>
</Dialog>
);
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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';
Expand DownExpand Up@@ -149,6 +151,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
savedBaseUrl,
save,
updateEnabledModels,
addDeclaredModel,
relayProfileDraft,
hasRelayProfileChanges,
setDraftThinkingLevels,
Expand All@@ -161,22 +164,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);
Expand DownExpand Up@@ -521,10 +536,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={addDeclaredModel}
/>
</DetailSection>
)}
{showsCapabilities && !retired && (
Expand DownExpand Up@@ -568,6 +604,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 —
Expand DownExpand Up@@ -609,6 +651,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
))}
</DropdownMenu>
</CapabilityRow>
)}
<CapabilityRow label={copy.visionInput} description={copy.visionInputHelp}>
<Selector
label={`${copy.visionInput} — ${modelId}`}
Expand Down
66 changes: 66 additions & 0 deletions apps/desktop/src/renderer/settings/use-connection-detail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -244,6 +244,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,
Expand DownExpand Up@@ -484,6 +485,70 @@ 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): Promise<boolean> {
const modelId = id.trim();
if (!modelId || enabledModelIds.includes(modelId)) return false;
if (connectionDetailActionGuard.has('save-enabled-models') || detailActionBusy) return false;
const next = [...enabledModelIds, modelId];
const previous = enabledModelIds;
const lifecycle = connectionDetailLifecycleRef.current;
const releaseSaveModels = connectionDetailActionGuard.begin('save-enabled-models');
if (!releaseSaveModels) return false;
setSavingEnabledModels(true);
setEnabledModelIds(next);
let saved = false;
try {
await props.bridge.update(connection.slug, {
enabledModelIds: next,
relayModelProfiles: { ...(savedRelayProfiles ?? {}), [modelId]: { contextWindow } },
Comment thread
Astro-Han marked this conversation as resolved.
});
saved = true;
if (!isConnectionDetailCurrent(lifecycle)) return saved;
// 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 saved;
if (!saved) setEnabledModelIds(previous);
toast.error(
saved ? copy.refreshFailed : copy.saveModelsFailed,
providerPanelActionErrorMessage(error, locale),
);
} finally {
releaseSaveModels();
if (isConnectionDetailCurrent(lifecycle)) setSavingEnabledModels(false);
}
// Whether the write landed. The dialog holds the typed id and context
// window until it did: a rejected write leaves nothing to retype from, and
// an exact model id is not something a user can reproduce from memory.
return saved;
}

async function runTest() {
const releaseTest = connectionDetailActionGuard.beginExclusive('test');
if (!releaseTest) return;
Expand DownExpand Up@@ -653,6 +718,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
lastTestAtMs,
save,
updateEnabledModels,
addDeclaredModel,
relayProfileDraft: relayProfileDrafts,
relayProfilesDirty,
hasRelayProfileChanges,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix: stop treating a model list as an allowlist by Astro-Han · Pull Request #3330 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/desktop/src/renderer/locales/settings-provider-copy.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,6 +69,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: '登录令牌只保存在本机。',
Expand DownExpand Up@@ -205,6 +215,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.',
Expand Down
152 changes: 152 additions & 0 deletions apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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;
/** Resolves to whether the write landed; the draft is held until it did. */
onSubmit(id: string, contextWindow: number): Promise<boolean>;
}) {
const copy = getProviderSettingsCopy(useUiLocale()).detail;
const [id, setId] = useState('');
const [contextWindow, setContextWindow] = useState<number | null>(null);
const [submitAttempted, setSubmitAttempted] = useState(false);
const [isSaving, setSaving] = 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);
}

// Closing on submit would clear the draft before the write settles, and an
// exact model id is not something a user can reproduce from memory. The
// failure is reported by the caller's toast; what this owes them is the
// typed text, still there to retry from.
async function submit(event: FormEvent) {
event.preventDefault();
setSubmitAttempted(true);
if (idError || !contextWindow || isSaving) return;
setSaving(true);
try {
if (await props.onSubmit(trimmedId, contextWindow)) close();
} finally {
setSaving(false);
}
}
Comment thread
Astro-Han marked this conversation as resolved.

return (
<Dialog
isOpen={props.isOpen}
onOpenChange={(open) => {
// A write in flight owns the draft until it settles: dismissing here
// would discard the very text the retry needs.
if (!open && !isSaving) close();
}}
purpose="form"
width={480}
>
<Layout
header={
<DialogHeader
title={copy.addModel}
onOpenChange={(open) => {
if (!open && !isSaving) close();
}}
/>
}
content={
<LayoutContent>
<form id="maka-add-model-form" onSubmit={(event) => void submit(event)}>
<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 || isSaving}
isLoading={isSaving}
label={copy.addModelConfirm}
/>
</HStack>
</LayoutFooter>
}
/>
</Dialog>
);
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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';
Expand DownExpand Up@@ -149,6 +151,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
savedBaseUrl,
save,
updateEnabledModels,
addDeclaredModel,
relayProfileDraft,
hasRelayProfileChanges,
setDraftThinkingLevels,
Expand All@@ -161,22 +164,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);
Expand DownExpand Up@@ -521,10 +536,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={addDeclaredModel}
/>
</DetailSection>
)}
{showsCapabilities && !retired && (
Expand DownExpand Up@@ -568,6 +604,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 —
Expand DownExpand Up@@ -609,6 +651,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
))}
</DropdownMenu>
</CapabilityRow>
)}
<CapabilityRow label={copy.visionInput} description={copy.visionInputHelp}>
<Selector
label={`${copy.visionInput} — ${modelId}`}
Expand Down
66 changes: 66 additions & 0 deletions apps/desktop/src/renderer/settings/use-connection-detail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -244,6 +244,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,
Expand DownExpand Up@@ -484,6 +485,70 @@ 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): Promise<boolean> {
const modelId = id.trim();
if (!modelId || enabledModelIds.includes(modelId)) return false;
if (connectionDetailActionGuard.has('save-enabled-models') || detailActionBusy) return false;
const next = [...enabledModelIds, modelId];
const previous = enabledModelIds;
const lifecycle = connectionDetailLifecycleRef.current;
const releaseSaveModels = connectionDetailActionGuard.begin('save-enabled-models');
if (!releaseSaveModels) return false;
setSavingEnabledModels(true);
setEnabledModelIds(next);
let saved = false;
try {
await props.bridge.update(connection.slug, {
enabledModelIds: next,
relayModelProfiles: { ...(savedRelayProfiles ?? {}), [modelId]: { contextWindow } },
Comment thread
Astro-Han marked this conversation as resolved.
});
saved = true;
if (!isConnectionDetailCurrent(lifecycle)) return saved;
// 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 saved;
if (!saved) setEnabledModelIds(previous);
toast.error(
saved ? copy.refreshFailed : copy.saveModelsFailed,
providerPanelActionErrorMessage(error, locale),
);
} finally {
releaseSaveModels();
if (isConnectionDetailCurrent(lifecycle)) setSavingEnabledModels(false);
}
// Whether the write landed. The dialog holds the typed id and context
// window until it did: a rejected write leaves nothing to retype from, and
// an exact model id is not something a user can reproduce from memory.
return saved;
}

async function runTest() {
const releaseTest = connectionDetailActionGuard.beginExclusive('test');
if (!releaseTest) return;
Expand DownExpand Up@@ -653,6 +718,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
lastTestAtMs,
save,
updateEnabledModels,
addDeclaredModel,
relayProfileDraft: relayProfileDrafts,
relayProfilesDirty,
hasRelayProfileChanges,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: stop treating a model list as an allowlist by Astro-Han · Pull Request #3330 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/desktop/src/renderer/locales/settings-provider-copy.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,6 +69,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: '登录令牌只保存在本机。',
Expand DownExpand Up@@ -205,6 +215,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.',
Expand Down
152 changes: 152 additions & 0 deletions apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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;
/** Resolves to whether the write landed; the draft is held until it did. */
onSubmit(id: string, contextWindow: number): Promise<boolean>;
}) {
const copy = getProviderSettingsCopy(useUiLocale()).detail;
const [id, setId] = useState('');
const [contextWindow, setContextWindow] = useState<number | null>(null);
const [submitAttempted, setSubmitAttempted] = useState(false);
const [isSaving, setSaving] = 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);
}

// Closing on submit would clear the draft before the write settles, and an
// exact model id is not something a user can reproduce from memory. The
// failure is reported by the caller's toast; what this owes them is the
// typed text, still there to retry from.
async function submit(event: FormEvent) {
event.preventDefault();
setSubmitAttempted(true);
if (idError || !contextWindow || isSaving) return;
setSaving(true);
try {
if (await props.onSubmit(trimmedId, contextWindow)) close();
} finally {
setSaving(false);
}
}
Comment thread
Astro-Han marked this conversation as resolved.

return (
<Dialog
isOpen={props.isOpen}
onOpenChange={(open) => {
// A write in flight owns the draft until it settles: dismissing here
// would discard the very text the retry needs.
if (!open && !isSaving) close();
}}
purpose="form"
width={480}
>
<Layout
header={
<DialogHeader
title={copy.addModel}
onOpenChange={(open) => {
if (!open && !isSaving) close();
}}
/>
}
content={
<LayoutContent>
<form id="maka-add-model-form" onSubmit={(event) => void submit(event)}>
<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 || isSaving}
isLoading={isSaving}
label={copy.addModelConfirm}
/>
</HStack>
</LayoutFooter>
}
/>
</Dialog>
);
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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';
Expand DownExpand Up@@ -149,6 +151,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
savedBaseUrl,
save,
updateEnabledModels,
addDeclaredModel,
relayProfileDraft,
hasRelayProfileChanges,
setDraftThinkingLevels,
Expand All@@ -161,22 +164,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);
Expand DownExpand Up@@ -521,10 +536,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={addDeclaredModel}
/>
</DetailSection>
)}
{showsCapabilities && !retired && (
Expand DownExpand Up@@ -568,6 +604,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 —
Expand DownExpand Up@@ -609,6 +651,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
))}
</DropdownMenu>
</CapabilityRow>
)}
<CapabilityRow label={copy.visionInput} description={copy.visionInputHelp}>
<Selector
label={`${copy.visionInput} — ${modelId}`}
Expand Down
66 changes: 66 additions & 0 deletions apps/desktop/src/renderer/settings/use-connection-detail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -244,6 +244,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,
Expand DownExpand Up@@ -484,6 +485,70 @@ 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): Promise<boolean> {
const modelId = id.trim();
if (!modelId || enabledModelIds.includes(modelId)) return false;
if (connectionDetailActionGuard.has('save-enabled-models') || detailActionBusy) return false;
const next = [...enabledModelIds, modelId];
const previous = enabledModelIds;
const lifecycle = connectionDetailLifecycleRef.current;
const releaseSaveModels = connectionDetailActionGuard.begin('save-enabled-models');
if (!releaseSaveModels) return false;
setSavingEnabledModels(true);
setEnabledModelIds(next);
let saved = false;
try {
await props.bridge.update(connection.slug, {
enabledModelIds: next,
relayModelProfiles: { ...(savedRelayProfiles ?? {}), [modelId]: { contextWindow } },
Comment thread
Astro-Han marked this conversation as resolved.
});
saved = true;
if (!isConnectionDetailCurrent(lifecycle)) return saved;
// 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 saved;
if (!saved) setEnabledModelIds(previous);
toast.error(
saved ? copy.refreshFailed : copy.saveModelsFailed,
providerPanelActionErrorMessage(error, locale),
);
} finally {
releaseSaveModels();
if (isConnectionDetailCurrent(lifecycle)) setSavingEnabledModels(false);
}
// Whether the write landed. The dialog holds the typed id and context
// window until it did: a rejected write leaves nothing to retype from, and
// an exact model id is not something a user can reproduce from memory.
return saved;
}

async function runTest() {
const releaseTest = connectionDetailActionGuard.beginExclusive('test');
if (!releaseTest) return;
Expand DownExpand Up@@ -653,6 +718,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
lastTestAtMs,
save,
updateEnabledModels,
addDeclaredModel,
relayProfileDraft: relayProfileDrafts,
relayProfilesDirty,
hasRelayProfileChanges,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: stop treating a model list as an allowlist by Astro-Han · Pull Request #3330 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/desktop/src/renderer/locales/settings-provider-copy.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,6 +69,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: '登录令牌只保存在本机。',
Expand DownExpand Up@@ -205,6 +215,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.',
Expand Down
152 changes: 152 additions & 0 deletions apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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;
/** Resolves to whether the write landed; the draft is held until it did. */
onSubmit(id: string, contextWindow: number): Promise<boolean>;
}) {
const copy = getProviderSettingsCopy(useUiLocale()).detail;
const [id, setId] = useState('');
const [contextWindow, setContextWindow] = useState<number | null>(null);
const [submitAttempted, setSubmitAttempted] = useState(false);
const [isSaving, setSaving] = 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);
}

// Closing on submit would clear the draft before the write settles, and an
// exact model id is not something a user can reproduce from memory. The
// failure is reported by the caller's toast; what this owes them is the
// typed text, still there to retry from.
async function submit(event: FormEvent) {
event.preventDefault();
setSubmitAttempted(true);
if (idError || !contextWindow || isSaving) return;
setSaving(true);
try {
if (await props.onSubmit(trimmedId, contextWindow)) close();
} finally {
setSaving(false);
}
}
Comment thread
Astro-Han marked this conversation as resolved.

return (
<Dialog
isOpen={props.isOpen}
onOpenChange={(open) => {
// A write in flight owns the draft until it settles: dismissing here
// would discard the very text the retry needs.
if (!open && !isSaving) close();
}}
purpose="form"
width={480}
>
<Layout
header={
<DialogHeader
title={copy.addModel}
onOpenChange={(open) => {
if (!open && !isSaving) close();
}}
/>
}
content={
<LayoutContent>
<form id="maka-add-model-form" onSubmit={(event) => void submit(event)}>
<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 || isSaving}
isLoading={isSaving}
label={copy.addModelConfirm}
/>
</HStack>
</LayoutFooter>
}
/>
</Dialog>
);
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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';
Expand DownExpand Up@@ -149,6 +151,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
savedBaseUrl,
save,
updateEnabledModels,
addDeclaredModel,
relayProfileDraft,
hasRelayProfileChanges,
setDraftThinkingLevels,
Expand All@@ -161,22 +164,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);
Expand DownExpand Up@@ -521,10 +536,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={addDeclaredModel}
/>
</DetailSection>
)}
{showsCapabilities && !retired && (
Expand DownExpand Up@@ -568,6 +604,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 —
Expand DownExpand Up@@ -609,6 +651,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
))}
</DropdownMenu>
</CapabilityRow>
)}
<CapabilityRow label={copy.visionInput} description={copy.visionInputHelp}>
<Selector
label={`${copy.visionInput} — ${modelId}`}
Expand Down
66 changes: 66 additions & 0 deletions apps/desktop/src/renderer/settings/use-connection-detail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -244,6 +244,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,
Expand DownExpand Up@@ -484,6 +485,70 @@ 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): Promise<boolean> {
const modelId = id.trim();
if (!modelId || enabledModelIds.includes(modelId)) return false;
if (connectionDetailActionGuard.has('save-enabled-models') || detailActionBusy) return false;
const next = [...enabledModelIds, modelId];
const previous = enabledModelIds;
const lifecycle = connectionDetailLifecycleRef.current;
const releaseSaveModels = connectionDetailActionGuard.begin('save-enabled-models');
if (!releaseSaveModels) return false;
setSavingEnabledModels(true);
setEnabledModelIds(next);
let saved = false;
try {
await props.bridge.update(connection.slug, {
enabledModelIds: next,
relayModelProfiles: { ...(savedRelayProfiles ?? {}), [modelId]: { contextWindow } },
Comment thread
Astro-Han marked this conversation as resolved.
});
saved = true;
if (!isConnectionDetailCurrent(lifecycle)) return saved;
// 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 saved;
if (!saved) setEnabledModelIds(previous);
toast.error(
saved ? copy.refreshFailed : copy.saveModelsFailed,
providerPanelActionErrorMessage(error, locale),
);
} finally {
releaseSaveModels();
if (isConnectionDetailCurrent(lifecycle)) setSavingEnabledModels(false);
}
// Whether the write landed. The dialog holds the typed id and context
// window until it did: a rejected write leaves nothing to retype from, and
// an exact model id is not something a user can reproduce from memory.
return saved;
}

async function runTest() {
const releaseTest = connectionDetailActionGuard.beginExclusive('test');
if (!releaseTest) return;
Expand DownExpand Up@@ -653,6 +718,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
lastTestAtMs,
save,
updateEnabledModels,
addDeclaredModel,
relayProfileDraft: relayProfileDrafts,
relayProfilesDirty,
hasRelayProfileChanges,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix: stop treating a model list as an allowlist by Astro-Han · Pull Request #3330 · apache/maka · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/desktop/src/renderer/locales/settings-provider-copy.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -69,6 +69,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: '登录令牌只保存在本机。',
Expand DownExpand Up@@ -205,6 +215,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.',
Expand Down
152 changes: 152 additions & 0 deletions apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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;
/** Resolves to whether the write landed; the draft is held until it did. */
onSubmit(id: string, contextWindow: number): Promise<boolean>;
}) {
const copy = getProviderSettingsCopy(useUiLocale()).detail;
const [id, setId] = useState('');
const [contextWindow, setContextWindow] = useState<number | null>(null);
const [submitAttempted, setSubmitAttempted] = useState(false);
const [isSaving, setSaving] = 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);
}

// Closing on submit would clear the draft before the write settles, and an
// exact model id is not something a user can reproduce from memory. The
// failure is reported by the caller's toast; what this owes them is the
// typed text, still there to retry from.
async function submit(event: FormEvent) {
event.preventDefault();
setSubmitAttempted(true);
if (idError || !contextWindow || isSaving) return;
setSaving(true);
try {
if (await props.onSubmit(trimmedId, contextWindow)) close();
} finally {
setSaving(false);
}
}
Comment thread
Astro-Han marked this conversation as resolved.

return (
<Dialog
isOpen={props.isOpen}
onOpenChange={(open) => {
// A write in flight owns the draft until it settles: dismissing here
// would discard the very text the retry needs.
if (!open && !isSaving) close();
}}
purpose="form"
width={480}
>
<Layout
header={
<DialogHeader
title={copy.addModel}
onOpenChange={(open) => {
if (!open && !isSaving) close();
}}
/>
}
content={
<LayoutContent>
<form id="maka-add-model-form" onSubmit={(event) => void submit(event)}>
<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 || isSaving}
isLoading={isSaving}
label={copy.addModelConfirm}
/>
</HStack>
</LayoutFooter>
}
/>
</Dialog>
);
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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';
Expand DownExpand Up@@ -149,6 +151,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
savedBaseUrl,
save,
updateEnabledModels,
addDeclaredModel,
relayProfileDraft,
hasRelayProfileChanges,
setDraftThinkingLevels,
Expand All@@ -161,22 +164,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);
Expand DownExpand Up@@ -521,10 +536,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={addDeclaredModel}
/>
</DetailSection>
)}
{showsCapabilities && !retired && (
Expand DownExpand Up@@ -568,6 +604,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 —
Expand DownExpand Up@@ -609,6 +651,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
))}
</DropdownMenu>
</CapabilityRow>
)}
<CapabilityRow label={copy.visionInput} description={copy.visionInputHelp}>
<Selector
label={`${copy.visionInput} — ${modelId}`}
Expand Down
66 changes: 66 additions & 0 deletions apps/desktop/src/renderer/settings/use-connection-detail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -244,6 +244,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,
Expand DownExpand Up@@ -484,6 +485,70 @@ 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): Promise<boolean> {
const modelId = id.trim();
if (!modelId || enabledModelIds.includes(modelId)) return false;
if (connectionDetailActionGuard.has('save-enabled-models') || detailActionBusy) return false;
const next = [...enabledModelIds, modelId];
const previous = enabledModelIds;
const lifecycle = connectionDetailLifecycleRef.current;
const releaseSaveModels = connectionDetailActionGuard.begin('save-enabled-models');
if (!releaseSaveModels) return false;
setSavingEnabledModels(true);
setEnabledModelIds(next);
let saved = false;
try {
await props.bridge.update(connection.slug, {
enabledModelIds: next,
relayModelProfiles: { ...(savedRelayProfiles ?? {}), [modelId]: { contextWindow } },
Comment thread
Astro-Han marked this conversation as resolved.
});
saved = true;
if (!isConnectionDetailCurrent(lifecycle)) return saved;
// 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 saved;
if (!saved) setEnabledModelIds(previous);
toast.error(
saved ? copy.refreshFailed : copy.saveModelsFailed,
providerPanelActionErrorMessage(error, locale),
);
} finally {
releaseSaveModels();
if (isConnectionDetailCurrent(lifecycle)) setSavingEnabledModels(false);
}
// Whether the write landed. The dialog holds the typed id and context
// window until it did: a rejected write leaves nothing to retype from, and
// an exact model id is not something a user can reproduce from memory.
return saved;
}

async function runTest() {
const releaseTest = connectionDetailActionGuard.beginExclusive('test');
if (!releaseTest) return;
Expand DownExpand Up@@ -653,6 +718,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
lastTestAtMs,
save,
updateEnabledModels,
addDeclaredModel,
relayProfileDraft: relayProfileDrafts,
relayProfilesDirty,
hasRelayProfileChanges,
Expand Down
Loading