diff --git a/apps/web/__tests__/components/model-alias/authoring_test.ts b/apps/web/__tests__/components/model-alias/authoring_test.ts
index c0f7bc3100..45eab27468 100644
--- a/apps/web/__tests__/components/model-alias/authoring_test.ts
+++ b/apps/web/__tests__/components/model-alias/authoring_test.ts
@@ -6,7 +6,7 @@ import { computeAliasWarnings, computeModelWarning, computeRuleWarnings } from '
import { indexCatalog } from '../../../src/components/models/catalog-index';
import { catalogModel } from '../../api/model-fixture';
import type { AliasTarget, ModelAlias } from '@floway-dev/protocols/common';
-const target = (id: string, rules: AliasTarget['rules'] = {}): AliasTarget => ({ target_model_id: id, rules });
+const target = (id: string, rules: AliasTarget['rules'] = {}, enabled?: boolean): AliasTarget => ({ target_model_id: id, rules, ...(enabled === undefined ? {} : { enabled }) });
describe('model alias warnings', () => {
it('never treats an alias catalog row as a real target', () => {
@@ -32,6 +32,22 @@ describe('model alias warnings', () => {
.toEqual(['no-target']);
});
+ it('ignores disabled targets when deciding whether any target resolves', () => {
+ const catalog = indexCatalog([catalogModel('gpt-5')]);
+ expect(computeAliasWarnings({ name: 'fresh', targets: [target('missing', {}, false), target('gpt-5')] }, catalog)).toEqual([]);
+ expect(computeAliasWarnings({ name: 'fresh', targets: [target('missing', {}, false), target('also-missing')] }, catalog).map(warning => warning.type))
+ .toEqual(['no-target']);
+ });
+
+ it('warns when every configured target is disabled', () => {
+ const catalog = indexCatalog([catalogModel('gpt-5')]);
+ expect(computeAliasWarnings({ name: 'fresh', targets: [target('gpt-5', {}, false)] }, catalog).map(warning => warning.type))
+ .toEqual(['all-targets-disabled']);
+ expect(computeAliasWarnings({ name: 'fresh', targets: [target('gpt-5', {}, false), target('')] }, catalog).map(warning => warning.type))
+ .toEqual(['all-targets-disabled']);
+ expect(computeAliasWarnings({ name: 'fresh', targets: [target('')] }, catalog)).toEqual([]);
+ });
+
it('warns when pinned rules exceed advertised capabilities', () => {
const catalog = catalogModel('reasoner', { chat: { reasoning: { effort: { supported: ['low'], default: 'low' }, budget_tokens: { min: 100, max: 1000 } } } });
const warnings = computeRuleWarnings({ reasoning: { effort: 'high', budget_tokens: 5000, adaptive: true } }, catalog);
@@ -57,6 +73,17 @@ describe('announced metadata', () => {
]));
expect(result.chat?.reasoning).toBeUndefined();
});
+
+ it('excludes disabled targets from the announced-metadata intersection', () => {
+ const result = computeAnnouncedMetadata([
+ target('a', {}, false),
+ target('b'),
+ ], 'chat', indexCatalog([
+ catalogModel('a', { contextWindow: 1000, chat: { modalities: { input: ['text'], output: ['text'] } } }),
+ catalogModel('b', { contextWindow: 2000, chat: { modalities: { input: ['text'], output: ['text'] } } }),
+ ]));
+ expect(result.limits).toEqual({ max_context_window_tokens: 2000 });
+ });
});
describe('alias wire body', () => {
@@ -82,6 +109,16 @@ describe('alias wire body', () => {
expect(body).not.toHaveProperty('sort_order');
});
+ it('normalizes legacy targets to explicit enabled=true in defaults', () => {
+ expect(aliasDefaults(existing).targets[0].enabled).toBe(true);
+ });
+
+ it('sends explicit enabled flags for every target', () => {
+ const values = aliasDefaults(existing);
+ values.targets = [target('a', {}, false)];
+ expect(aliasBody(values)).toMatchObject({ targets: [{ target_model_id: 'a', enabled: false }] });
+ });
+
it('drops chat rules and announced metadata for image aliases', () => {
const values = aliasDefaults(existing);
values.kind = 'image'; values.manualMetadata = true;
diff --git a/apps/web/src/components/model-alias/announced-metadata.ts b/apps/web/src/components/model-alias/announced-metadata.ts
index 5279f0dd76..7fca6e2d6b 100644
--- a/apps/web/src/components/model-alias/announced-metadata.ts
+++ b/apps/web/src/components/model-alias/announced-metadata.ts
@@ -1,5 +1,6 @@
import type { ControlPlaneModel } from '../../api/types';
import type { CatalogIndex } from '../models/catalog-index';
+import { isAliasTargetEnabled } from '@floway-dev/protocols/common';
import type {
AliasTarget,
AnnouncedMetadata,
@@ -64,6 +65,7 @@ export const computeAnnouncedMetadata = (
catalog: CatalogIndex,
): AnnouncedMetadata => {
const available = targets
+ .filter(isAliasTargetEnabled)
.map(target => ({ target, model: catalog.get(target.target_model_id) }))
.filter((entry): entry is { target: AliasTarget; model: ControlPlaneModel } => entry.model?.kind === kind);
if (!available.length) return {};
diff --git a/apps/web/src/components/model-alias/dialog.tsx b/apps/web/src/components/model-alias/dialog.tsx
index 6db0280ee3..da7d19e9b8 100644
--- a/apps/web/src/components/model-alias/dialog.tsx
+++ b/apps/web/src/components/model-alias/dialog.tsx
@@ -49,7 +49,7 @@ export function AliasDialog({ aliases, models, onOpenChange, open, onSaved, reco
kind: z.enum(MODEL_KINDS),
selection: z.enum(['first-available', 'random']),
visible: z.boolean(),
- targets: z.array(z.object({ target_model_id: z.string(), rules: z.any().refine(value => value !== undefined) })).min(1),
+ targets: z.array(z.object({ target_model_id: z.string(), enabled: z.boolean().optional(), rules: z.any().refine(value => value !== undefined) })).min(1),
manualMetadata: z.boolean(),
announcedMetadata: z.any().refine(value => value !== undefined),
}).superRefine((values, ctx) => {
diff --git a/apps/web/src/components/model-alias/form-data.ts b/apps/web/src/components/model-alias/form-data.ts
index 0d44530499..aba5f5578a 100644
--- a/apps/web/src/components/model-alias/form-data.ts
+++ b/apps/web/src/components/model-alias/form-data.ts
@@ -22,7 +22,7 @@ export interface AliasFormValues {
announcedMetadata: AnnouncedMetadata;
}
-export const blankTarget = (): AliasTarget => ({ target_model_id: '', rules: {} });
+export const blankTarget = (): AliasTarget => ({ target_model_id: '', rules: {}, enabled: true });
// An image alias announces nothing: its /v1/models entry carries no limits and
// no chat block, so there is no operator override to hold.
@@ -44,7 +44,9 @@ export const aliasDefaults = (alias: ModelAlias | null): AliasFormValues => {
kind: alias.kind,
selection: alias.selection,
visible: alias.visible_in_models_list,
- targets: structuredClone(alias.targets),
+ // Normalize legacy rows whose `enabled` predates the field: missing
+ // means enabled, so the edit form always carries an explicit boolean.
+ targets: alias.targets.map(target => ({ ...target, enabled: target.enabled !== false })),
manualMetadata: alias.announced_metadata !== null,
announcedMetadata: structuredClone(alias.announced_metadata ?? {}),
} : {
@@ -70,6 +72,7 @@ export const aliasBody = (values: AliasFormValues): AliasWriteBody => {
visible_in_models_list: values.visible,
targets: values.targets.map(target => ({
target_model_id: target.target_model_id.trim(),
+ enabled: target.enabled !== false,
rules: values.kind === 'chat' ? { ...trimRules(target.rules) } : {},
})),
announced_metadata: values.manualMetadata && kindAnnouncesMetadata(values.kind)
diff --git a/apps/web/src/components/model-alias/target-row.tsx b/apps/web/src/components/model-alias/target-row.tsx
index 3cfba8bbe8..447c4b80fb 100644
--- a/apps/web/src/components/model-alias/target-row.tsx
+++ b/apps/web/src/components/model-alias/target-row.tsx
@@ -13,7 +13,7 @@ import { useTranslation } from '../../i18n/translation';
import { filterModelOptions } from '../../lib/model-query';
import type { CatalogIndex } from '../models/catalog-index';
import { useDangerTextClass } from '../ui/danger';
-import { Combobox, Dropdown, Input } from '../ui/fluent-form-controls';
+import { Combobox, Dropdown, Input, Switch } from '../ui/fluent-form-controls';
import { TWO_COLUMN_FORM_CLASS } from '../ui/layout';
import { ReorderButtons } from '../ui/reorder-buttons';
import { TooltipIconButton } from '../ui/tooltip-icon-button';
@@ -68,7 +68,7 @@ export function AliasTargetRow({
return (
-
+