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 (
-
+