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
24 changes: 2 additions & 22 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {
type ComposerDraft,
} from "../../state/use-composer-drafts";
import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
import { resolveSelectableModelSelection } from "../../lib/modelOptions";
import { buildModelMenuActions, resolveSelectableModelSelection } from "../../lib/modelOptions";
import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
Expand Down Expand Up @@ -543,27 +543,7 @@ export function NewTaskDraftScreen(props: {
);

const modelMenuActions = useMemo(
() =>
flow.providerGroups.map((group) => ({
id: `provider:${group.providerKey}`,
title: group.providerLabel,
subtitle: group.models.find(
(model) =>
flow.selectedModel &&
model.selection.instanceId === flow.selectedModel.instanceId &&
model.selection.model === flow.selectedModel.model,
)?.label,
subactions: group.models.map((option) => ({
id: `model:${option.key}`,
title: option.label,
state:
flow.selectedModel &&
option.selection.instanceId === flow.selectedModel.instanceId &&
option.selection.model === flow.selectedModel.model
? ("on" as const)
: undefined,
})),
})),
() => buildModelMenuActions(flow.providerGroups, flow.selectedModel),
[flow.providerGroups, flow.selectedModel],
);
const providerOptionDescriptors = useMemo(
Expand Down
22 changes: 2 additions & 20 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import {
import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
import { ProviderIcon } from "../../components/ProviderIcon";
import type { DraftComposerImageAttachment } from "../../lib/composerImages";
import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
import { buildModelMenuActions, buildModelOptions, groupByProvider } from "../../lib/modelOptions";
import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
import type { RemoteClientConnectionState } from "../../lib/connection";
import {
Expand Down Expand Up @@ -606,25 +606,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
[providerOptionDescriptors],
);
const modelMenuActions = useMemo(
() =>
providerGroups.map((group) => ({
id: `provider:${group.providerKey}`,
title: group.providerLabel,
subtitle: group.models.find(
(model) =>
model.selection.instanceId === currentModelSelection.instanceId &&
model.selection.model === currentModelSelection.model,
)?.label,
subactions: group.models.map((option) => ({
id: `model:${option.key}`,
title: option.label,
state:
option.selection.instanceId === currentModelSelection.instanceId &&
option.selection.model === currentModelSelection.model
? ("on" as const)
: undefined,
})),
})),
() => buildModelMenuActions(providerGroups, currentModelSelection),
[providerGroups, currentModelSelection],
);

Expand Down
85 changes: 84 additions & 1 deletion apps/mobile/src/lib/modelOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,92 @@ import { describe, expect, it } from "vite-plus/test";

import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts";

import { buildModelOptions, resolveSelectableModelSelection } from "./modelOptions";
import {
buildModelMenuActions,
buildModelOptions,
groupByProvider,
resolveSelectableModelSelection,
} from "./modelOptions";

describe("mobile model options", () => {
it("folds legacy models into a provider-scoped menu", () => {
const config = {
providers: [
{
instanceId: "codex",
driver: "codex",
displayName: "Codex",
enabled: true,
installed: true,
auth: { status: "authenticated" },
models: [
{
slug: "gpt-5.6-sol",
name: "GPT-5.6 Sol",
isCustom: false,
capabilities: null,
},
{
slug: "gpt-5.4",
name: "GPT-5.4",
isCustom: false,
isLegacy: true,
capabilities: null,
},
],
},
],
} as unknown as ServerConfig;

const actions = buildModelMenuActions(groupByProvider(buildModelOptions(config, null)), null);

expect(actions).toMatchObject([
{
title: "Codex",
subactions: [{ id: "model:codex:gpt-5.6-sol", title: "GPT-5.6 Sol" }],
},
{
id: "legacy-models:codex",
title: "Codex legacy models",
subactions: [{ id: "model:codex:gpt-5.4", title: "GPT-5.4" }],
},
]);
});

it("omits an empty provider menu when every model is legacy", () => {
const config = {
providers: [
{
instanceId: "codex",
driver: "codex",
displayName: "Codex",
enabled: true,
installed: true,
auth: { status: "authenticated" },
models: [
{
slug: "gpt-5.4",
name: "GPT-5.4",
isCustom: false,
isLegacy: true,
capabilities: null,
},
],
},
],
} as unknown as ServerConfig;

expect(
buildModelMenuActions(groupByProvider(buildModelOptions(config, null)), null),
).toMatchObject([
{
id: "legacy-models:codex",
title: "Codex legacy models",
subactions: [{ id: "model:codex:gpt-5.4" }],
},
]);
});

it("normalizes a legacy fallback selection against current capabilities", () => {
const config = {
providers: [
Expand Down
54 changes: 54 additions & 0 deletions apps/mobile/src/lib/modelOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
ModelSelection,
ServerConfig as T3ServerConfig,
} from "@t3tools/contracts";
import type { MenuAction } from "@react-native-menu/menu";
import {
buildProviderOptionSelectionsFromDescriptors,
getProviderOptionDescriptors,
Expand All @@ -16,6 +17,7 @@ export type ModelOption = {
readonly providerLabel: string;
readonly providerDriver: string;
readonly isDefault: boolean;
readonly isLegacy: boolean;
readonly capabilities: ModelCapabilities | null;
readonly selection: ModelSelection;
};
Expand Down Expand Up @@ -105,6 +107,7 @@ export function buildModelOptions(
providerLabel,
providerDriver: provider.driver,
isDefault: model.isDefault === true,
isLegacy: model.isLegacy === true,
capabilities: model.capabilities,
selection: normalizeSelectionOptions(
{
Expand Down Expand Up @@ -135,6 +138,7 @@ export function buildModelOptions(
providerLabel,
providerDriver: fallbackModelSelection.instanceId,
isDefault: false,
isLegacy: false,
capabilities: null,
selection: fallbackModelSelection,
});
Expand Down Expand Up @@ -164,3 +168,53 @@ export function groupByProvider(options: ReadonlyArray<ModelOption>): ReadonlyAr
models: group.models,
}));
}

function modelMenuAction(option: ModelOption, selectedModel: ModelSelection | null): MenuAction {
return {
id: `model:${option.key}`,
title: option.label,
state:
option.selection.instanceId === selectedModel?.instanceId &&
option.selection.model === selectedModel.model
? "on"
: undefined,
};
}

export function buildModelMenuActions(
groups: ReadonlyArray<ProviderGroup>,
selectedModel: ModelSelection | null,
): MenuAction[] {
return groups.flatMap((group) => {
const currentModels = group.models.filter((model) => !model.isLegacy);
const legacyModels = group.models.filter((model) => model.isLegacy);
const selected = group.models.find(
(model) =>
model.selection.instanceId === selectedModel?.instanceId &&
model.selection.model === selectedModel.model,
);

return [
...(currentModels.length > 0
? [
{
id: `provider:${group.providerKey}`,
title: group.providerLabel,
subtitle: selected && !selected.isLegacy ? selected.label : undefined,
subactions: currentModels.map((option) => modelMenuAction(option, selectedModel)),
},
]
: []),
...(legacyModels.length > 0
? [
{
id: `legacy-models:${group.providerKey}`,
title: `${group.providerLabel} legacy models`,
subtitle: selected?.isLegacy ? selected.label : undefined,
subactions: legacyModels.map((option) => modelMenuAction(option, selectedModel)),
},
]
: []),
];
Comment thread
t3dotgg marked this conversation as resolved.
});
}
16 changes: 16 additions & 0 deletions apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,27 @@ import * as Schema from "effect/Schema";
import {
buildClaudeCapabilitiesProbeQueryOptions,
CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES,
isLegacyClaudeModel,
probeClaudeCapabilities,
} from "./ClaudeProvider.ts";

const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);

it("keeps only the Claude 5 family out of legacy models", () => {
assert.deepStrictEqual(
["claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8"].map((model) => [
model,
isLegacyClaudeModel(model),
]),
[
["claude-fable-5", false],
["claude-opus-5", false],
["claude-sonnet-5", false],
["claude-opus-4-8", true],
],
);
});

it("isolates Claude capability probes without dropping workspace setting sources", () => {
const abortController = new AbortController();
const options = buildClaudeCapabilitiesProbeQueryOptions({
Expand Down
12 changes: 11 additions & 1 deletion apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,13 @@ const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169";
const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154";
const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111";

const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
const CURRENT_CLAUDE_MODELS = new Set(["claude-fable-5", "claude-opus-5", "claude-sonnet-5"]);

export function isLegacyClaudeModel(model: string): boolean {
return !CURRENT_CLAUDE_MODELS.has(model);
}

const CLAUDE_MODEL_CATALOG: ReadonlyArray<ServerProviderModel> = [
{
slug: "claude-fable-5",
name: "Claude Fable 5",
Expand Down Expand Up @@ -309,6 +315,10 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
},
];

const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = CLAUDE_MODEL_CATALOG.map((model) =>
isLegacyClaudeModel(model.slug) ? { ...model, isLegacy: true } : model,
);

function supportsClaudeOpus5(version: string | null | undefined): boolean {
return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false;
}
Expand Down
21 changes: 20 additions & 1 deletion apps/server/src/provider/Layers/CodexProvider.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
import { assert, it } from "@effect/vitest";

import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts";
import {
applyPreferredCodexDefaultModel,
isLegacyCodexModel,
mapCodexModelCapabilities,
} from "./CodexProvider.ts";

it("keeps only the GPT-5.6 Codex family out of legacy models", () => {
assert.deepStrictEqual(
["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol", "gpt-5.4"].map((model) => [
model,
isLegacyCodexModel(model),
]),
[
["gpt-5.6-luna", false],
["gpt-5.6-terra", false],
["gpt-5.6-sol", false],
["gpt-5.4", true],
],
);
});

it("maps current Codex model capability fields", () => {
const capabilities = mapCodexModelCapabilities({
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ const REASONING_EFFORT_LABELS: Readonly<Record<string, string>> = {
};

const DEFAULT_SERVICE_TIER_ID = "default";
const CURRENT_CODEX_MODELS = new Set(["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"]);

export function isLegacyCodexModel(model: string): boolean {
return !CURRENT_CODEX_MODELS.has(model);
}

function reasoningEffortLabel(reasoningEffort: string): string {
return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort;
Expand Down Expand Up @@ -190,6 +195,7 @@ function parseCodexModelListResponse(
name: toDisplayName(model),
isCustom: false,
...(model.isDefault ? { isDefault: true } : {}),
...(isLegacyCodexModel(model.model) ? { isLegacy: true } : {}),
capabilities: mapCodexModelCapabilities(model),
}));
}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/chat/ModelListRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Button } from "../ui/button";
import { Kbd } from "../ui/kbd";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { cn } from "~/lib/utils";
import { modelPickerModelKey } from "./modelPickerKeys";

export const ModelListRow = memo(function ModelListRow(props: {
index: number;
Expand Down Expand Up @@ -46,7 +47,7 @@ export const ModelListRow = memo(function ModelListRow(props: {
<ComboboxItem
hideIndicator
index={props.index}
value={`${props.instanceId}:${props.model.slug}`}
value={modelPickerModelKey(props.instanceId, props.model.slug)}
disabled={Boolean(props.disabledReason)}
contentClassName="flex w-full items-center gap-3"
className={cn(
Expand Down
Loading
Loading