feat(multimodel): unify provider catalog and branding

This commit is contained in:
777genius 2026-04-10 22:36:32 +03:00
parent 53bcea337f
commit 35970000b6
13 changed files with 7295 additions and 277 deletions

6713
bun.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -20,14 +20,16 @@ const bundledDeps = prodDeps.filter(d => d !== 'node-pty' && d !== 'agent-teams-
// but they have pure JS fallbacks when the native module isn't available.
function nativeModuleStub(): Plugin {
const STUB_ID = '\0native-stub'
const NODE_MODULE_RE = /\.node(?:\?.*)?$/
return {
name: 'native-module-stub',
enforce: 'pre',
resolveId(source) {
if (source.endsWith('.node')) return STUB_ID
if (NODE_MODULE_RE.test(source)) return `${STUB_ID}:${source}`
return null
},
load(id) {
if (id === STUB_ID) return 'export default {}'
if (id.startsWith(STUB_ID) || NODE_MODULE_RE.test(id)) return 'export default {}'
return null
}
}
@ -71,6 +73,9 @@ export default defineConfig({
externalizeDeps: {
exclude: bundledDeps
},
commonjsOptions: {
strictRequires: [/node_modules\/.*ssh2\//],
},
sourcemap: 'hidden',
outDir: 'dist-electron/main',
rollupOptions: {

View file

@ -55,7 +55,7 @@
"standalone:build": "electron-vite build && vite build --config docker/vite.standalone.config.ts",
"standalone:start": "node dist-standalone/index.cjs",
"prepare": "husky",
"postinstall": "electron-rebuild -f -o node-pty || echo 'node-pty rebuild failed (terminal will be disabled)'"
"postinstall": "electron-rebuild -f -o node-pty,ssh2,cpu-features || echo 'native Electron rebuild failed (terminal/ssh features may be degraded)'"
},
"lint-staged": {
"src/**/*.{ts,tsx,js,jsx}": [
@ -331,5 +331,11 @@
"ignoreBinaries": [
"pkg"
]
}
},
"workspaces": [
"agent-teams-controller",
"mcp-server",
"landing",
"packages/agent-graph"
]
}

View file

@ -0,0 +1,206 @@
import { useId } from 'react';
import type { CliProviderId } from '@shared/types';
type ProviderBrandLogoId = CliProviderId | 'opencode';
type BrandLogoProps = Readonly<{
className?: string;
}>;
interface ProviderBrandLogoProps {
readonly providerId: ProviderBrandLogoId;
readonly className?: string;
}
const AnthropicBrandLogo = ({ className }: BrandLogoProps): React.JSX.Element => {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
<g fill="#D97757">
{Array.from({ length: 10 }).map((_, index) => (
<rect
key={index}
x="10.75"
y="1.8"
width="2.5"
height="7.7"
rx="1.2"
transform={`rotate(${index * 36} 12 12)`}
/>
))}
<circle cx="12" cy="12" r="3.1" />
</g>
</svg>
);
};
const CodexBrandLogo = ({ className }: BrandLogoProps): React.JSX.Element => {
const gradientId = useId();
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
<rect x="1.25" y="1.25" width="21.5" height="21.5" rx="6.5" fill="#F8FAFC" />
<path
d="M17.6 10.2a4.95 4.95 0 0 0-8.58-2.43 3.7 3.7 0 0 0-4.25 5.73A3.46 3.46 0 0 0 6.34 20h10.12a3.65 3.65 0 0 0 1.14-7.14Z"
fill={`url(#${gradientId})`}
/>
<path
d="M9.05 9.55 11.4 12l-2.35 2.45"
fill="none"
stroke="#FFFFFF"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13.1 14.45h3.05"
fill="none"
stroke="#FFFFFF"
strokeWidth="1.7"
strokeLinecap="round"
/>
<defs>
<linearGradient
id={gradientId}
x1="12"
y1="6.4"
x2="12"
y2="20"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#A5B4FC" />
<stop offset="0.55" stopColor="#6F8CFF" />
<stop offset="1" stopColor="#3B46FF" />
</linearGradient>
</defs>
</svg>
);
};
const GeminiBrandLogo = ({ className }: BrandLogoProps): React.JSX.Element => {
const gradientId = useId();
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
<path
d="M12 2.25c.62 3.9 1.6 6.57 3.18 8.15 1.58 1.58 4.25 2.56 8.15 3.18-3.9.62-6.57 1.6-8.15 3.18-1.58 1.58-2.56 4.25-3.18 8.15-.62-3.9-1.6-6.57-3.18-8.15-1.58-1.58-4.25-2.56-8.15-3.18 3.9-.62 6.57-1.6 8.15-3.18C10.4 8.82 11.38 6.15 12 2.25Z"
fill={`url(#${gradientId})`}
/>
<defs>
<linearGradient
id={gradientId}
x1="4"
y1="4"
x2="20"
y2="20"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#9F7AEA" />
<stop offset="1" stopColor="#60A5FA" />
</linearGradient>
</defs>
</svg>
);
};
const OpenCodeBrandLogo = ({ className }: BrandLogoProps): React.JSX.Element => {
const backgroundId = useId();
const frameId = useId();
const frameStrokeId = useId();
const coreId = useId();
const coreStrokeId = useId();
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
<defs>
<linearGradient
id={backgroundId}
x1="4"
y1="3"
x2="20"
y2="21"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#303030" />
<stop offset="1" stopColor="#161616" />
</linearGradient>
<linearGradient
id={frameId}
x1="7"
y1="4.5"
x2="17"
y2="19.5"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#f4f4f4" />
<stop offset="0.35" stopColor="#d9d9d9" />
<stop offset="0.68" stopColor="#a8a8a8" />
<stop offset="1" stopColor="#ececec" />
</linearGradient>
<linearGradient
id={frameStrokeId}
x1="7"
y1="4.5"
x2="17"
y2="19.5"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#ffffff" stopOpacity="0.9" />
<stop offset="1" stopColor="#5a5a5a" stopOpacity="0.9" />
</linearGradient>
<linearGradient id={coreId} x1="12" y1="7" x2="12" y2="17" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#121212" />
<stop offset="0.42" stopColor="#3e3b33" />
<stop offset="1" stopColor="#16140f" />
</linearGradient>
<linearGradient
id={coreStrokeId}
x1="9"
y1="7"
x2="15"
y2="17"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#f2f2f2" stopOpacity="0.95" />
<stop offset="1" stopColor="#6e6e6e" stopOpacity="0.85" />
</linearGradient>
</defs>
<rect x="1.5" y="1.5" width="21" height="21" rx="5.2" fill={`url(#${backgroundId})`} />
<path
d="M7 4.25h10c.3 0 .55.25.55.55v14.4c0 .3-.25.55-.55.55H7c-.3 0-.55-.25-.55-.55V4.8c0-.3.25-.55.55-.55Z"
fill={`url(#${frameId})`}
stroke={`url(#${frameStrokeId})`}
strokeWidth="0.55"
/>
<path
d="M8.95 7.25h6.1c.22 0 .4.18.4.4v8.7c0 .22-.18.4-.4.4h-6.1a.4.4 0 0 1-.4-.4v-8.7c0-.22.18-.4.4-.4Z"
fill={`url(#${coreId})`}
stroke={`url(#${coreStrokeId})`}
strokeWidth="0.45"
/>
<path
d="M9.25 7.6h5.5"
stroke="#ffffff"
strokeOpacity="0.18"
strokeWidth="0.45"
strokeLinecap="round"
/>
</svg>
);
};
export const ProviderBrandLogo = ({
providerId,
className,
}: ProviderBrandLogoProps): React.JSX.Element => {
switch (providerId) {
case 'anthropic':
return <AnthropicBrandLogo className={className} />;
case 'codex':
return <CodexBrandLogo className={className} />;
case 'gemini':
return <GeminiBrandLogo className={className} />;
case 'opencode':
return <OpenCodeBrandLogo className={className} />;
}
};

View file

@ -11,16 +11,21 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
import { SettingsToggle } from '@renderer/components/settings/components';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel';
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
import { useStore } from '@renderer/store';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import { formatBytes } from '@renderer/utils/formatters';
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
import {
getTeamModelBadgeLabel,
getVisibleTeamProviderModels,
} from '@renderer/utils/teamModelCatalog';
import {
AlertTriangle,
CheckCircle,
@ -265,32 +270,25 @@ function formatProviderStatus(provider: CliProviderStatus): string {
}
function formatProviderModels(provider: CliProviderStatus): string | null {
return provider.models.length > 0 ? provider.models.join(', ') : null;
const visibleModels = getVisibleTeamProviderModels(provider.providerId, provider.models);
return visibleModels.length > 0 ? visibleModels.join(', ') : null;
}
function formatModelBadgeLabel(providerId: CliProviderId, model: string): string {
if (providerId === 'anthropic') {
return model.replace(/^claude-/, '');
}
if (providerId === 'codex') {
return model.replace(/^gpt-/, '');
}
if (providerId === 'gemini') {
return model.replace(/^gemini-/, '');
}
return model;
return getTeamModelBadgeLabel(providerId, model) ?? model;
}
function ModelBadges({
const ModelBadges = ({
providerId,
models,
}: {
providerId: CliProviderId;
models: string[];
}): React.JSX.Element {
readonly providerId: CliProviderId;
readonly models: string[];
}): React.JSX.Element => {
const visibleModels = getVisibleTeamProviderModels(providerId, models);
return (
<div className="flex flex-wrap gap-1.5">
{models.map((model) => (
{visibleModels.map((model) => (
<span
key={model}
className="rounded-md border px-1.5 py-px font-mono text-[10px] leading-4"
@ -305,9 +303,9 @@ function ModelBadges({
))}
</div>
);
}
};
function ProviderDetailSkeleton(): React.JSX.Element {
const ProviderDetailSkeleton = (): React.JSX.Element => {
return (
<div className="mt-1 space-y-2">
<div
@ -329,7 +327,7 @@ function ProviderDetailSkeleton(): React.JSX.Element {
</div>
</div>
);
}
};
function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean {
return (
@ -535,14 +533,23 @@ const InstalledBanner = ({
return (
<div
key={provider.providerId}
className="grid min-h-[132px] grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 rounded-md px-2 py-2"
className="grid min-h-[132px] grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 rounded-md p-2"
style={{ backgroundColor: 'rgba(255, 255, 255, 0.02)' }}
>
<div className="col-span-2 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium" style={{ color: 'var(--color-text)' }}>
{provider.displayName}
<span className="flex items-center gap-2">
<ProviderBrandLogo
providerId={provider.providerId}
className="size-4 shrink-0"
/>
<span
className="text-xs font-medium"
style={{ color: 'var(--color-text)' }}
>
{provider.displayName}
</span>
</span>
<span
className="text-xs"
@ -557,7 +564,7 @@ const InstalledBanner = ({
<ProviderDetailSkeleton />
) : (
<div
className="mt-1 flex min-h-[2.75rem] flex-wrap items-center gap-x-3 gap-y-1 text-[11px]"
className="mt-1 flex min-h-11 flex-wrap items-center gap-x-3 gap-y-1 text-[11px]"
style={{ color: 'var(--color-text-muted)' }}
>
{provider.backend?.label && (
@ -1058,7 +1065,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
}
// ── Completed ──────────────────────────────────────────────────────────
if (installerState === 'completed' && (!cliStatus || !cliStatus.installed)) {
if (installerState === 'completed' && !cliStatus?.installed) {
return <InstallCompletedNotice version={completedVersion} />;
}

View file

@ -9,14 +9,19 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
import { SettingsToggle } from '@renderer/components/settings/components';
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
import { useStore } from '@renderer/store';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import { formatBytes } from '@renderer/utils/formatters';
import {
getTeamModelBadgeLabel,
getVisibleTeamProviderModels,
} from '@renderer/utils/teamModelCatalog';
import {
AlertTriangle,
CheckCircle,
@ -35,28 +40,20 @@ import { SettingsSectionHeader } from '../components';
import type { CliProviderId, CliProviderStatus } from '@shared/types';
function formatModelBadgeLabel(providerId: CliProviderId, model: string): string {
if (providerId === 'anthropic') {
return model.replace(/^claude-/, '');
}
if (providerId === 'codex') {
return model.replace(/^gpt-/, '');
}
if (providerId === 'gemini') {
return model.replace(/^gemini-/, '');
}
return model;
return getTeamModelBadgeLabel(providerId, model) ?? model;
}
function ModelBadges({
const ModelBadges = ({
providerId,
models,
}: {
providerId: CliProviderId;
models: string[];
}): React.JSX.Element {
readonly providerId: CliProviderId;
readonly models: string[];
}): React.JSX.Element => {
const visibleModels = getVisibleTeamProviderModels(providerId, models);
return (
<div className="flex flex-wrap gap-1.5">
{models.map((model) => (
{visibleModels.map((model) => (
<span
key={model}
className="rounded-md border px-1.5 py-px font-mono text-[10px] leading-4"
@ -71,9 +68,9 @@ function ModelBadges({
))}
</div>
);
}
};
function ProviderDetailSkeleton(): React.JSX.Element {
const ProviderDetailSkeleton = (): React.JSX.Element => {
return (
<div className="mt-1 space-y-2">
<div
@ -95,7 +92,7 @@ function ProviderDetailSkeleton(): React.JSX.Element {
</div>
</div>
);
}
};
function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean {
return (
@ -458,11 +455,17 @@ export const CliStatusSection = (): React.JSX.Element | null => {
<div className="col-span-2 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-xs">
<span
className="font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
{provider.displayName}
<span className="flex items-center gap-2">
<ProviderBrandLogo
providerId={provider.providerId}
className="size-4 shrink-0"
/>
<span
className="font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
{provider.displayName}
</span>
</span>
<span
style={{
@ -482,7 +485,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
<ProviderDetailSkeleton />
) : (
<div
className="mt-1 flex min-h-[2.75rem] flex-wrap gap-x-3 gap-y-1 text-[11px]"
className="mt-1 flex min-h-11 flex-wrap gap-x-3 gap-y-1 text-[11px]"
style={{ color: 'var(--color-text-muted)' }}
>
{provider.backend?.label && (

View file

@ -15,10 +15,14 @@ import {
isGeminiUiFrozen,
} from '@renderer/utils/geminiUiFreeze';
import {
doesTeamModelCarryProviderBrand,
getTeamModelLabel as getCatalogTeamModelLabel,
getTeamProviderLabel as getCatalogTeamProviderLabel,
getTeamProviderModelOptions,
getTeamModelUiDisabledReason,
normalizeTeamModelForUi,
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
} from '@renderer/utils/teamModelAvailability';
} from '@renderer/utils/teamModelCatalog';
import { Check, ChevronDown, Info } from 'lucide-react';
// --- Provider SVG Icons (real brand logos from Simple Icons, monochrome currentColor) ---
@ -145,65 +149,12 @@ const PROVIDERS: ProviderDef[] = [
const OPENCODE_UI_DISABLED_REASON = 'OpenCode in development';
const ANTHROPIC_MODEL_OPTIONS = [
{ value: '', label: 'Default' },
{ value: 'opus', label: 'Opus 4.6' },
{ value: 'sonnet', label: 'Sonnet 4.6' },
{ value: 'haiku', label: 'Haiku 4.5' },
] as const;
const CODEX_MODEL_OPTIONS = [
{ value: '', label: 'Default' },
{ value: 'gpt-5.4', label: 'GPT-5.4' },
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' },
{ value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
{ value: 'gpt-5.3-codex-spark', label: 'GPT-5.3 Codex Spark' },
{ value: 'gpt-5.2', label: 'GPT-5.2' },
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
] as const;
const GEMINI_MODEL_OPTIONS = [
{ value: '', label: 'Default' },
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
{ value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' },
] as const;
const MODEL_LABEL_OVERRIDES: Record<string, string> = {
'claude-sonnet-4-6': 'Sonnet 4.6',
'claude-sonnet-4-6[1m]': 'Sonnet 4.6 (1M)',
'claude-opus-4-6': 'Opus 4.6',
'claude-opus-4-6[1m]': 'Opus 4.6 (1M)',
'claude-haiku-4-5-20251001': 'Haiku 4.5',
'gpt-5.4': 'GPT-5.4',
'gpt-5.4-mini': 'GPT-5.4 Mini',
'gpt-5.3-codex': 'GPT-5.3 Codex',
'gpt-5.3-codex-spark': 'GPT-5.3 Spark',
'gpt-5.2-codex': 'GPT-5.2 Codex',
'gpt-5.2': 'GPT-5.2',
'gpt-5.1-codex-mini': 'GPT-5.1 Mini',
'gpt-5.1-codex-max': 'GPT-5.1 Max',
'gemini-2.5-pro': 'Gemini 2.5 Pro',
'gemini-2.5-flash': 'Gemini 2.5 Flash',
'gemini-2.5-flash-lite': 'Gemini 2.5 Flash Lite',
};
export function getTeamModelLabel(model: string): string {
return MODEL_LABEL_OVERRIDES[model] ?? model;
return getCatalogTeamModelLabel(model) ?? model;
}
export function getTeamProviderLabel(providerId: 'anthropic' | 'codex' | 'gemini'): string {
switch (providerId) {
case 'codex':
return 'Codex';
case 'gemini':
return 'Gemini';
case 'anthropic':
default:
return 'Anthropic';
}
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
}
export function getTeamEffortLabel(effort: string): string {
@ -221,16 +172,7 @@ export function formatTeamModelSummary(
const modelLabel = model.trim() ? getTeamModelLabel(model.trim()) : 'Default';
const effortLabel = effort?.trim() ? getTeamEffortLabel(effort) : '';
const normalizedProvider = providerLabel.trim().toLowerCase();
const normalizedModel = modelLabel.trim().toLowerCase();
const modelAlreadyCarriesProviderBrand =
modelLabel !== 'Default' &&
(normalizedModel.startsWith(normalizedProvider) ||
(providerId === 'anthropic' && normalizedModel.startsWith('claude')) ||
(providerId === 'codex' && normalizedModel.startsWith('codex')) ||
(providerId === 'codex' && normalizedModel.startsWith('gpt')) ||
(providerId === 'gemini' && normalizedModel.startsWith('gemini')));
const modelAlreadyCarriesProviderBrand = doesTeamModelCarryProviderBrand(providerId, modelLabel);
const providerActsAsBackendOnly =
providerId !== 'anthropic' && modelLabel !== 'Default' && !modelAlreadyCarriesProviderBrand;
@ -336,12 +278,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
}, [normalizedValue, onValueChange, value]);
const modelOptions = useMemo(() => {
const fallback =
effectiveProviderId === 'codex'
? CODEX_MODEL_OPTIONS
: effectiveProviderId === 'gemini'
? GEMINI_MODEL_OPTIONS
: ANTHROPIC_MODEL_OPTIONS;
const fallback = getTeamProviderModelOptions(effectiveProviderId);
if (effectiveProviderId === 'anthropic' || runtimeModels.length === 0) {
return [...fallback];
}
@ -469,10 +406,6 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
Codex and Gemini require Multimodel mode.
</p>
)}
{disableGeminiOption && isGeminiUiFrozen() && (
<p className="text-[11px] text-[var(--color-text-muted)]">{GEMINI_UI_DISABLED_REASON}.</p>
)}
<div
className="grid gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}

View file

@ -1,4 +1,9 @@
import { displayMemberName } from '@renderer/utils/memberHelpers';
import {
doesTeamModelCarryProviderBrand,
getTeamModelLabel,
getTeamProviderLabel,
} from '@renderer/utils/teamModelCatalog';
import type { InboxMessage } from '@shared/types';
@ -36,58 +41,6 @@ function parseProviderId(value: string | undefined): TeamProviderId | null {
return null;
}
function getTeamModelLabel(model: string): string {
const trimmed = model.trim();
switch (trimmed) {
case 'gemini-2.5-pro':
return 'Gemini 2.5 Pro';
case 'gemini-2.5-flash':
return 'Gemini 2.5 Flash';
case 'gemini-2.5-flash-lite':
return 'Gemini 2.5 Flash Lite';
case 'gpt-5.4':
return 'GPT-5.4';
case 'gpt-5.4-mini':
return 'GPT-5.4 Mini';
case 'gpt-5.3-codex':
return 'GPT-5.3 Codex';
case 'gpt-5.3-codex-spark':
return 'GPT-5.3 Codex Spark';
case 'gpt-5.2':
return 'GPT-5.2';
case 'gpt-5.2-codex':
return 'GPT-5.2 Codex';
case 'gpt-5.1-codex-mini':
return 'GPT-5.1 Codex Mini';
case 'gpt-5.1-codex-max':
return 'GPT-5.1 Codex Max';
case 'claude-sonnet-4-6':
return 'Sonnet 4.6';
case 'claude-sonnet-4-6[1m]':
return 'Sonnet 4.6 (1M)';
case 'claude-opus-4-6':
return 'Opus 4.6';
case 'claude-opus-4-6[1m]':
return 'Opus 4.6 (1M)';
case 'claude-haiku-4-5-20251001':
return 'Haiku 4.5';
default:
return trimmed || 'Default';
}
}
function getTeamProviderLabel(providerId: TeamProviderId): string {
switch (providerId) {
case 'codex':
return 'Codex';
case 'gemini':
return 'Gemini';
case 'anthropic':
default:
return 'Anthropic';
}
}
function getTeamEffortLabel(effort: string | undefined): string {
const trimmed = effort?.trim() ?? '';
return trimmed ? trimmed.charAt(0).toUpperCase() + trimmed.slice(1) : 'Default';
@ -105,18 +58,13 @@ function buildRuntimeSummary(
effort: string | undefined
): string | undefined {
if (providerId) {
const providerLabel = getTeamProviderLabel(providerId);
const modelLabel = model ? getTeamModelLabel(model) : 'Default';
const providerLabel = getTeamProviderLabel(providerId) ?? 'Anthropic';
const modelLabel = model ? (getTeamModelLabel(model) ?? model) : 'Default';
const effortLabel = getTeamEffortLabel(effort);
const normalizedProvider = providerLabel.trim().toLowerCase();
const normalizedModel = modelLabel.trim().toLowerCase();
const modelAlreadyCarriesProviderBrand =
modelLabel !== 'Default' &&
(normalizedModel.startsWith(normalizedProvider) ||
(providerId === 'anthropic' && normalizedModel.startsWith('claude')) ||
(providerId === 'codex' &&
(normalizedModel.startsWith('codex') || normalizedModel.startsWith('gpt'))) ||
(providerId === 'gemini' && normalizedModel.startsWith('gemini')));
const modelAlreadyCarriesProviderBrand = doesTeamModelCarryProviderBrand(
providerId,
modelLabel
);
const providerActsAsBackendOnly =
providerId !== 'anthropic' && modelLabel !== 'Default' && !modelAlreadyCarriesProviderBrand;
@ -129,9 +77,9 @@ function buildRuntimeSummary(
return parts.filter(Boolean).join(' · ');
}
const modelLabel = model ? getTeamModelLabel(model) : '';
const modelLabel = model ? (getTeamModelLabel(model) ?? model) : '';
const effortLabel = effort ? getTeamEffortLabel(effort) : '';
const providerLabel = providerId ? getTeamProviderLabel(providerId) : '';
const providerLabel = providerId ? (getTeamProviderLabel(providerId) ?? '') : '';
return [providerLabel, modelLabel, effortLabel].filter(Boolean).join(' · ') || undefined;
}

View file

@ -1,36 +1,10 @@
import type { TeamProviderId } from '@shared/types';
export const TEAM_MODEL_UI_DISABLED_BADGE_LABEL = 'Disabled';
export const GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL = 'gpt-5.1-codex-mini';
export const GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL = 'gpt-5.3-codex-spark';
export const GPT_5_1_CODEX_MINI_UI_DISABLED_REASON =
'Temporarily disabled for team agents - this model has been less reliable with task and reply tool contracts.';
export const GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON =
'Temporarily disabled for team agents - this model has been less reliable with bootstrap, task, and reply tool contracts.';
export function getTeamModelUiDisabledReason(
providerId: TeamProviderId | undefined,
model: string | undefined
): string | null {
if (providerId === 'codex' && model === GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL) {
return GPT_5_1_CODEX_MINI_UI_DISABLED_REASON;
}
if (providerId === 'codex' && model === GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL) {
return GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON;
}
return null;
}
export function isTeamModelUiDisabled(
providerId: TeamProviderId | undefined,
model: string | undefined
): boolean {
return getTeamModelUiDisabledReason(providerId, model) !== null;
}
export function normalizeTeamModelForUi(
providerId: TeamProviderId | undefined,
model: string | undefined
): string {
return isTeamModelUiDisabled(providerId, model) ? '' : (model ?? '');
}
export {
GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL,
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL,
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
getTeamModelUiDisabledReason,
isTeamModelUiDisabled,
normalizeTeamModelForUi,
} from './teamModelCatalog';

View file

@ -0,0 +1,231 @@
import type { CliProviderId, TeamProviderId } from '@shared/types';
type SupportedProviderId = CliProviderId | TeamProviderId;
export interface TeamProviderModelOption {
value: string;
label: string;
badgeLabel?: string;
uiDisabledReason?: string;
}
export const TEAM_MODEL_UI_DISABLED_BADGE_LABEL = 'Disabled';
export const GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL = 'gpt-5.1-codex-mini';
export const GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL = 'gpt-5.3-codex-spark';
export const GPT_5_1_CODEX_MINI_UI_DISABLED_REASON =
'Temporarily disabled for team agents - this model has been less reliable with task and reply tool contracts.';
export const GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON =
'Temporarily disabled for team agents - this model has been less reliable with bootstrap, task, and reply tool contracts.';
const TEAM_PROVIDER_LABELS: Record<SupportedProviderId, string> = {
anthropic: 'Anthropic',
codex: 'Codex',
gemini: 'Gemini',
};
const TEAM_MODEL_LABEL_OVERRIDES: Record<string, string> = {
default: 'Default',
opus: 'Opus 4.6',
sonnet: 'Sonnet 4.6',
haiku: 'Haiku 4.5',
'claude-sonnet-4-6': 'Sonnet 4.6',
'claude-sonnet-4-6[1m]': 'Sonnet 4.6 (1M)',
'claude-opus-4-6': 'Opus 4.6',
'claude-opus-4-6[1m]': 'Opus 4.6 (1M)',
'claude-haiku-4-5-20251001': 'Haiku 4.5',
'gpt-5.4': 'GPT-5.4',
'gpt-5.4-mini': 'GPT-5.4 Mini',
'gpt-5.3-codex': 'GPT-5.3 Codex',
'gpt-5.3-codex-spark': 'GPT-5.3 Codex Spark',
'gpt-5.2': 'GPT-5.2',
'gpt-5.2-codex': 'GPT-5.2 Codex',
'gpt-5.1-codex-mini': 'GPT-5.1 Codex Mini',
'gpt-5.1-codex-max': 'GPT-5.1 Codex Max',
'gemini-2.5-pro': 'Gemini 2.5 Pro',
'gemini-2.5-flash': 'Gemini 2.5 Flash',
'gemini-2.5-flash-lite': 'Gemini 2.5 Flash Lite',
};
const TEAM_PROVIDER_MODEL_OPTIONS: Record<SupportedProviderId, readonly TeamProviderModelOption[]> =
{
anthropic: [
{ value: '', label: 'Default', badgeLabel: 'Default' },
{ value: 'opus', label: 'Opus 4.6', badgeLabel: 'Opus 4.6' },
{ value: 'sonnet', label: 'Sonnet 4.6', badgeLabel: 'Sonnet 4.6' },
{ value: 'haiku', label: 'Haiku 4.5', badgeLabel: 'Haiku 4.5' },
],
codex: [
{ value: '', label: 'Default', badgeLabel: 'Default' },
{ value: 'gpt-5.4', label: 'GPT-5.4', badgeLabel: '5.4' },
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', badgeLabel: '5.4-mini' },
{ value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex', badgeLabel: '5.3-codex' },
{
value: 'gpt-5.3-codex-spark',
label: 'GPT-5.3 Codex Spark',
badgeLabel: '5.3-codex-spark',
uiDisabledReason: GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
},
{ value: 'gpt-5.2', label: 'GPT-5.2', badgeLabel: '5.2' },
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex', badgeLabel: '5.2-codex' },
{
value: 'gpt-5.1-codex-mini',
label: 'GPT-5.1 Codex Mini',
badgeLabel: '5.1-codex-mini',
uiDisabledReason: GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
},
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max', badgeLabel: '5.1-codex-max' },
],
gemini: [
{ value: '', label: 'Default', badgeLabel: 'Default' },
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', badgeLabel: '2.5-pro' },
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', badgeLabel: '2.5-flash' },
{
value: 'gemini-2.5-flash-lite',
label: 'Gemini 2.5 Flash Lite',
badgeLabel: '2.5-flash-lite',
},
],
};
const TEAM_PROVIDER_MODEL_ORDER: Record<SupportedProviderId, Map<string, number>> = {
anthropic: new Map(
TEAM_PROVIDER_MODEL_OPTIONS.anthropic.map((option, index) => [option.value, index])
),
codex: new Map(TEAM_PROVIDER_MODEL_OPTIONS.codex.map((option, index) => [option.value, index])),
gemini: new Map(TEAM_PROVIDER_MODEL_OPTIONS.gemini.map((option, index) => [option.value, index])),
};
function getKnownTeamProviderModelOption(
providerId: SupportedProviderId | undefined,
model: string | undefined
): TeamProviderModelOption | undefined {
const trimmed = model?.trim();
if (!providerId || !trimmed) {
return undefined;
}
return TEAM_PROVIDER_MODEL_OPTIONS[providerId].find((option) => option.value === trimmed);
}
export function getTeamProviderModelOptions(
providerId: SupportedProviderId
): readonly TeamProviderModelOption[] {
return TEAM_PROVIDER_MODEL_OPTIONS[providerId];
}
export function getTeamProviderLabel(
providerId: SupportedProviderId | undefined
): string | undefined {
if (!providerId) {
return undefined;
}
return TEAM_PROVIDER_LABELS[providerId];
}
export function getTeamModelLabel(model: string | undefined): string | undefined {
const trimmed = model?.trim();
if (!trimmed) {
return undefined;
}
return TEAM_MODEL_LABEL_OVERRIDES[trimmed] ?? trimmed;
}
export function getTeamModelBadgeLabel(
providerId: SupportedProviderId,
model: string | undefined
): string | undefined {
const trimmed = model?.trim();
if (!trimmed) {
return undefined;
}
const knownOption = getKnownTeamProviderModelOption(providerId, trimmed);
if (knownOption?.badgeLabel) {
return knownOption.badgeLabel;
}
if (providerId === 'anthropic') {
return trimmed.replace(/^claude-/, '');
}
if (providerId === 'codex') {
return trimmed.replace(/^gpt-/, '');
}
if (providerId === 'gemini') {
return trimmed.replace(/^gemini-/, '');
}
return trimmed;
}
export function sortTeamProviderModels(
providerId: SupportedProviderId,
models: readonly string[]
): string[] {
const seen = new Set<string>();
const deduped = models.filter((model) => {
const trimmed = model.trim();
if (!trimmed || seen.has(trimmed)) {
return false;
}
seen.add(trimmed);
return true;
});
const order = TEAM_PROVIDER_MODEL_ORDER[providerId];
return [...deduped].sort((left, right) => {
const leftRank = order.get(left) ?? Number.MAX_SAFE_INTEGER;
const rightRank = order.get(right) ?? Number.MAX_SAFE_INTEGER;
if (leftRank !== rightRank) {
return leftRank - rightRank;
}
return left.localeCompare(right);
});
}
export function getVisibleTeamProviderModels(
providerId: SupportedProviderId,
models: readonly string[]
): string[] {
return sortTeamProviderModels(providerId, models).filter(
(model) => !isTeamModelUiDisabled(providerId, model)
);
}
export function getTeamModelUiDisabledReason(
providerId: SupportedProviderId | undefined,
model: string | undefined
): string | null {
return getKnownTeamProviderModelOption(providerId, model)?.uiDisabledReason ?? null;
}
export function isTeamModelUiDisabled(
providerId: SupportedProviderId | undefined,
model: string | undefined
): boolean {
return getTeamModelUiDisabledReason(providerId, model) !== null;
}
export function normalizeTeamModelForUi(
providerId: SupportedProviderId | undefined,
model: string | undefined
): string {
return isTeamModelUiDisabled(providerId, model) ? '' : (model ?? '');
}
export function doesTeamModelCarryProviderBrand(
providerId: SupportedProviderId | undefined,
modelLabel: string | undefined
): boolean {
const providerLabel = getTeamProviderLabel(providerId);
const normalizedProvider = providerLabel?.trim().toLowerCase();
const normalizedModel = modelLabel?.trim().toLowerCase();
if (!providerId || !normalizedProvider || !normalizedModel || modelLabel === 'Default') {
return false;
}
return (
normalizedModel.startsWith(normalizedProvider) ||
(providerId === 'anthropic' && normalizedModel.startsWith('claude')) ||
(providerId === 'codex' &&
(normalizedModel.startsWith('codex') || normalizedModel.startsWith('gpt'))) ||
(providerId === 'gemini' && normalizedModel.startsWith('gemini'))
);
}

View file

@ -1,44 +1,18 @@
import type { TeamProviderId } from '@shared/types';
const MODEL_LABEL_OVERRIDES: Record<string, string> = {
default: 'Default',
'claude-sonnet-4-6': 'Sonnet 4.6',
'claude-sonnet-4-6[1m]': 'Sonnet 4.6 (1M)',
'claude-opus-4-6': 'Opus 4.6',
'claude-opus-4-6[1m]': 'Opus 4.6 (1M)',
'claude-haiku-4-5-20251001': 'Haiku 4.5',
'gpt-5.4': 'GPT-5.4',
'gpt-5.4-mini': 'GPT-5.4 Mini',
'gpt-5.3-codex': 'GPT-5.3 Codex',
'gpt-5.3-codex-spark': 'GPT-5.3 Codex Spark',
'gpt-5.2': 'GPT-5.2',
'gpt-5.2-codex': 'GPT-5.2 Codex',
'gpt-5.1-codex-mini': 'GPT-5.1 Codex Mini',
'gpt-5.1-codex-max': 'GPT-5.1 Codex Max',
'gemini-2.5-pro': 'Gemini 2.5 Pro',
'gemini-2.5-flash': 'Gemini 2.5 Flash',
'gemini-2.5-flash-lite': 'Gemini 2.5 Flash Lite',
};
import {
doesTeamModelCarryProviderBrand,
getTeamModelLabel,
getTeamProviderLabel,
} from './teamModelCatalog';
export function getTeamRuntimeModelLabel(model: string | undefined): string | undefined {
const trimmed = model?.trim();
if (!trimmed) return undefined;
return MODEL_LABEL_OVERRIDES[trimmed] ?? trimmed;
return getTeamModelLabel(model);
}
export function getTeamRuntimeProviderLabel(
providerId: TeamProviderId | undefined
): string | undefined {
switch (providerId) {
case 'codex':
return 'Codex';
case 'gemini':
return 'Gemini';
case 'anthropic':
return 'Anthropic';
default:
return undefined;
}
return getTeamProviderLabel(providerId);
}
export function getTeamRuntimeEffortLabel(effort: string | undefined): string | undefined {
@ -60,17 +34,7 @@ export function formatTeamRuntimeSummary(
return undefined;
}
const normalizedProvider = providerLabel?.trim().toLowerCase();
const normalizedModel = modelLabel?.trim().toLowerCase();
const modelAlreadyCarriesProviderBrand =
Boolean(modelLabel) &&
Boolean(normalizedProvider) &&
Boolean(normalizedModel) &&
(normalizedModel!.startsWith(normalizedProvider!) ||
(providerId === 'anthropic' && normalizedModel!.startsWith('claude')) ||
(providerId === 'codex' &&
(normalizedModel!.startsWith('codex') || normalizedModel!.startsWith('gpt'))) ||
(providerId === 'gemini' && normalizedModel!.startsWith('gemini')));
const modelAlreadyCarriesProviderBrand = doesTeamModelCarryProviderBrand(providerId, modelLabel);
const providerActsAsBackendOnly =
providerId !== 'anthropic' && Boolean(modelLabel) && !modelAlreadyCarriesProviderBrand;

View file

@ -171,7 +171,8 @@ describe('TeamModelSelector disabled Codex models', () => {
});
expect(host.textContent).toContain('OpenCode');
expect(host.textContent?.match(/In development/g)?.length ?? 0).toBeGreaterThanOrEqual(2);
expect(host.textContent).not.toContain('Gemini in development');
expect(host.textContent?.match(/In development/g)?.length ?? 0).toBeGreaterThanOrEqual(1);
const buttons = Array.from(host.querySelectorAll('button'));
const openCodeButton = buttons.find((button) => button.textContent?.includes('OpenCode'));

View file

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { getVisibleTeamProviderModels } from '@renderer/utils/teamModelCatalog';
describe('teamModelCatalog', () => {
it('filters UI-disabled Codex models from provider badge lists', () => {
expect(
getVisibleTeamProviderModels('codex', [
'gpt-5.4',
'gpt-5.4-mini',
'gpt-5.3-codex',
'gpt-5.3-codex-spark',
'gpt-5.2',
'gpt-5.2-codex',
'gpt-5.1-codex-mini',
'gpt-5.1-codex-max',
])
).toEqual([
'gpt-5.4',
'gpt-5.4-mini',
'gpt-5.3-codex',
'gpt-5.2',
'gpt-5.2-codex',
'gpt-5.1-codex-max',
]);
});
});