Skip to content
Draft
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
39 changes: 38 additions & 1 deletion apps/web/__tests__/components/model-alias/authoring_test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/web/src/components/model-alias/announced-metadata.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 {};
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/model-alias/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/components/model-alias/form-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 ?? {}),
} : {
Expand All @@ -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)
Expand Down
12 changes: 9 additions & 3 deletions apps/web/src/components/model-alias/target-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -68,7 +68,7 @@ export function AliasTargetRow({

return (
<div className="border-0 border-t border-solid border-fui-divider pt-2" role="group" aria-label={t('dashboard.modelAliases.target.label', { number: index + 1 })}>
<div className="grid grid-cols-[32px_minmax(180px,1fr)_134px] gap-2 items-center py-2 max-[620px]:grid-cols-[32px_minmax(0,1fr)]">
<div className="grid grid-cols-[32px_minmax(180px,1fr)_auto] gap-2 items-center py-2 max-[620px]:grid-cols-[32px_minmax(0,1fr)]">
<Tooltip content={toggleLabel} relationship="label">
<Button
appearance="subtle"
Expand All @@ -94,7 +94,13 @@ export function AliasTargetRow({
>
{options.map(id => <Option className="font-mono" key={id} text={id}>{id}</Option>)}
</Combobox>
<div className="grid grid-cols-4 gap-0.5 w-[134px] max-[620px]:col-span-2 max-[620px]:justify-self-end">
<div className="grid grid-cols-[auto_32px_32px_32px_32px] gap-0.5 items-center max-[620px]:col-span-2 max-[620px]:justify-self-end">
<Switch
aria-label={t('dashboard.modelAliases.target.enabled')}
checked={target.enabled !== false}
disabled={disabled}
onChange={(_, data) => onChange({ ...target, enabled: data.checked })}
/>
{modelWarning
? <Tooltip content={modelAliasWarningText(modelWarning, t)} relationship="description"><span className="winui-focus-rect grid h-8 w-8 place-items-center" tabIndex={0}><WarningRegular aria-label={t('dashboard.modelAliases.warnings.label')} fontSize={20} /></span></Tooltip>
: <span aria-hidden className="h-8 w-8" />}
Expand Down
20 changes: 15 additions & 5 deletions apps/web/src/components/model-alias/warnings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ControlPlaneModel } from '../../api/types';
import type { TFunction } from '../../i18n/translation';
import type { CatalogIndex } from '../models/catalog-index';
import { isAliasTargetEnabled } from '@floway-dev/protocols/common';
import type { AliasTarget, ChatAliasRules, ModelKind } from '@floway-dev/protocols/common';

export const realModelIdsOfKind = (models: readonly ControlPlaneModel[] | null | undefined, kind: ModelKind) => {
Expand Down Expand Up @@ -63,10 +64,11 @@ export const computeModelWarning = (

export type AliasWarning =
| { type: 'shadow'; key: 'shadow'; values: { id: string; display: string } }
| { type: 'no-target'; key: 'noTarget'; values?: undefined };
| { type: 'no-target'; key: 'noTarget'; values?: undefined }
| { type: 'all-targets-disabled'; key: 'allTargetsDisabled'; values?: undefined };

export const computeAliasWarnings = (
alias: { name: string; targets: readonly Pick<AliasTarget, 'target_model_id'>[] },
alias: { name: string; targets: readonly AliasTarget[] },
catalog: CatalogIndex | null,
): AliasWarning[] => {
const warnings: AliasWarning[] = [];
Expand All @@ -77,9 +79,16 @@ export const computeAliasWarnings = (
}
// A new alias opens on one blank row, so warning before anything is typed
// would report the starting state as a fault.
const entered = alias.targets.filter(target => target.target_model_id !== '');
if (catalog !== null && entered.length > 0 && !entered.some(target => catalog.has(target.target_model_id))) {
warnings.push({ type: 'no-target', key: 'noTarget' });
const entered = alias.targets.filter(target => isAliasTargetEnabled(target) && target.target_model_id !== '');
if (entered.length > 0) {
if (catalog !== null && !entered.some(target => catalog.has(target.target_model_id))) {
warnings.push({ type: 'no-target', key: 'noTarget' });
}
} else if (alias.targets.some(target => target.target_model_id !== '')) {
// The alias has at least one configured model but none are active.
// Saving is still allowed; the request-time resolver simply has no
// target to route to and returns a normal model-missing 404.
warnings.push({ type: 'all-targets-disabled', key: 'allTargetsDisabled' });
}
return warnings;
};
Expand All @@ -91,6 +100,7 @@ export const modelAliasWarningText = (
switch (warning.key) {
case 'shadow': return t('dashboard.modelAliases.warnings.shadow', warning.values);
case 'noTarget': return t('dashboard.modelAliases.warnings.noTarget');
case 'allTargetsDisabled': return t('dashboard.modelAliases.warnings.allTargetsDisabled');
case 'unknownTarget': return t('dashboard.modelAliases.warnings.unknownTarget', warning.values);
case 'wrongKind': return t('dashboard.modelAliases.warnings.wrongKind', warning.values);
case 'notAdvertisedEffort': return t('dashboard.modelAliases.warnings.notAdvertisedEffort');
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1180,10 +1180,10 @@ const en = {
kind: { chat: 'Chat', embedding: 'Embedding', image: 'Image', rerank: 'Rerank', transcription: 'Transcription' },
selection: { first: 'First available', random: 'Random' },
visibility: { visible: 'Visible', hidden: 'Hidden' },
target: { heading: 'Models', description: 'Targets are tried in order when using First available. Select a suggestion or enter any model ID.', label: 'Target {{number, number}}', modelId: 'Target model ID', placeholder: 'target model id', toggle: 'Toggle target rules', moveUp: 'Move target up', moveDown: 'Move target down', remove: 'Remove target', count_one: '{{count, number}} target', count_other: '{{count, number}} targets' },
target: { heading: 'Models', description: 'Targets are tried in order when using First available. Select a suggestion or enter any model ID.', label: 'Target {{number, number}}', modelId: 'Target model ID', placeholder: 'target model id', toggle: 'Toggle target rules', enabled: 'Enable target routing', moveUp: 'Move target up', moveDown: 'Move target down', remove: 'Remove target', count_one: '{{count, number}} target', count_other: '{{count, number}} targets' },
rules: { effort: 'Reasoning effort', budget: 'Reasoning budget tokens', adaptive: 'Adaptive reasoning', adaptiveAuto: 'Auto (defer to model)', adaptiveOn: 'On (force adaptive)', adaptiveOff: 'Off (force non-adaptive)', summary: 'Reasoning summary', verbosity: 'Verbosity', serviceTier: 'Service tier' },
metadata: { heading: 'Announce metadata manually', description: 'Capabilities reported for this alias by /v1/models', manual: 'Announce metadata manually', limits: 'Token limits', context: 'Context window', prompt: 'Prompt tokens', output: 'Output tokens', modalities: 'Modalities', imageInput: 'Image input', reasoning: 'Reasoning', effortEnabled: 'Effort levels', budgetEnabled: 'Budget tokens', adaptive: 'Adaptive', mandatory: 'Mandatory', efforts: 'Supported efforts', effortsHint: 'Comma-separated; order is preserved.', defaultEffort: 'Default effort', minBudget: 'Minimum budget', maxBudget: 'Maximum budget' },
warnings: { label: 'Alias warning', shadow: 'Alias ID shadows the real model {{id}} {{display}}.', noTarget: 'No target currently resolves to a model on this gateway.', unknownTarget: '{{id}} does not currently resolve to an enabled model.', wrongKind: '{{id}} is a {{actual}} model, but this alias is {{expected}}.', notAdvertisedEffort: 'Target does not advertise reasoning effort.', unsupportedEffort: 'Target advertises effort levels: {{values}}.', adaptiveBudgetConflict: 'Adaptive reasoning cannot be combined with a fixed budget.', notAdvertisedBudget: 'Target does not advertise a reasoning budget.', budgetBelow: 'Below target minimum ({{value, number}}).', budgetAbove: 'Above target maximum ({{value, number}}).', notAdvertisedAdaptive: 'Target does not advertise adaptive reasoning.', ruleAdvisory: 'One or more rules may not be supported by this target.' },
warnings: { label: 'Alias warning', shadow: 'Alias ID shadows the real model {{id}} {{display}}.', noTarget: 'No target currently resolves to a model on this gateway.', allTargetsDisabled: 'All targets are disabled; this alias will not route requests until you enable one.', unknownTarget: '{{id}} does not currently resolve to an enabled model.', wrongKind: '{{id}} is a {{actual}} model, but this alias is {{expected}}.', notAdvertisedEffort: 'Target does not advertise reasoning effort.', unsupportedEffort: 'Target advertises effort levels: {{values}}.', adaptiveBudgetConflict: 'Adaptive reasoning cannot be combined with a fixed budget.', notAdvertisedBudget: 'Target does not advertise a reasoning budget.', budgetBelow: 'Below target minimum ({{value, number}}).', budgetAbove: 'Above target maximum ({{value, number}}).', notAdvertisedAdaptive: 'Target does not advertise adaptive reasoning.', ruleAdvisory: 'One or more rules may not be supported by this target.' },
validation: { nameRequired: 'Enter an alias ID.', duplicate: 'An alias with this ID already exists.', targetRequired: 'Enter a target model ID.', budget: 'Reasoning budget must be a non-negative integer.', adaptiveBudget: 'Adaptive reasoning cannot be combined with a fixed budget.', metadataNumber: 'Enter a whole number of tokens, zero or greater.', metadataRange: 'Maximum budget must be greater than or equal to minimum budget.' },
delete: { title: 'Delete alias', message: 'Delete alias {{name}}? This cannot be undone.' },
toast: { save: { pending: 'Saving alias {{name}}', success: 'Saved alias {{name}}' }, delete: { pending: 'Deleting alias {{name}}', success: 'Deleted alias {{name}}' } },
Expand Down
Loading