From 40fcb6bdf8d726f35c6d3aec82d0cce6345c9dcd Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Fri, 6 Mar 2026 15:48:37 -0600 Subject: [PATCH] fix: restrict spark to non-plus users & fast mode gpt5.4 (#180) * fix: restrict spark to non-plus users & fast mode gpt5.4 * nit * tests * tests --------- Co-authored-by: Theo Browne --- apps/server/src/codexAppServerManager.test.ts | 116 ++++++++++++++++- apps/server/src/codexAppServerManager.ts | 118 ++++++++++++++++-- .../Layers/ProviderCommandReactor.ts | 7 ++ apps/server/src/orchestration/decider.ts | 1 + .../src/provider/Layers/CodexAdapter.ts | 1 + apps/web/src/appSettings.test.ts | 21 ++++ apps/web/src/appSettings.ts | 39 +++++- apps/web/src/components/ChatView.tsx | 34 ++++- apps/web/src/routes/_chat.settings.tsx | 58 ++++++++- packages/contracts/src/orchestration.ts | 18 +++ packages/contracts/src/provider.ts | 7 ++ 11 files changed, 399 insertions(+), 21 deletions(-) diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index f2e11ca292e39b1a00e3917620d29d7666c2d66b..af75b1c6d8720b85f9e758ce586ea7e5fc4c9b30 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -13,6 +13,8 @@ import { classifyCodexStderrLine, isRecoverableThreadResumeError, normalizeCodexModelSlug, + readCodexAccountSnapshot, + resolveCodexModelForAccount, } from "./codexAppServerManager"; const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); @@ -21,14 +23,20 @@ function createSendTurnHarness() { const manager = new CodexAppServerManager(); const context = { session: { - sessionId: "sess_1", provider: "codex", status: "ready", threadId: "thread_1", + runtimeMode: "full-access", + model: "gpt-5.3-codex", resumeCursor: { threadId: "thread_1" }, createdAt: "2026-02-10T00:00:00.000Z", updatedAt: "2026-02-10T00:00:00.000Z", }, + account: { + type: "unknown", + planType: null, + sparkEnabled: true, + }, }; const requireSession = vi @@ -58,10 +66,11 @@ function createThreadControlHarness() { const manager = new CodexAppServerManager(); const context = { session: { - sessionId: "sess_1", provider: "codex", status: "ready", threadId: "thread_1", + runtimeMode: "full-access", + model: "gpt-5.3-codex", resumeCursor: { threadId: "thread_1" }, createdAt: "2026-02-10T00:00:00.000Z", updatedAt: "2026-02-10T00:00:00.000Z", @@ -89,10 +98,11 @@ function createPendingUserInputHarness() { const manager = new CodexAppServerManager(); const context = { session: { - sessionId: "sess_1", provider: "codex", status: "ready", threadId: "thread_1", + runtimeMode: "full-access", + model: "gpt-5.3-codex", resumeCursor: { threadId: "thread_1" }, createdAt: "2026-02-10T00:00:00.000Z", updatedAt: "2026-02-10T00:00:00.000Z", @@ -195,6 +205,70 @@ describe("isRecoverableThreadResumeError", () => { }); }); +describe("readCodexAccountSnapshot", () => { + it("disables spark for chatgpt plus accounts", () => { + expect( + readCodexAccountSnapshot({ + type: "chatgpt", + email: "plus@example.com", + planType: "plus", + }), + ).toEqual({ + type: "chatgpt", + planType: "plus", + sparkEnabled: false, + }); + }); + + it("keeps spark enabled for chatgpt pro accounts", () => { + expect( + readCodexAccountSnapshot({ + type: "chatgpt", + email: "pro@example.com", + planType: "pro", + }), + ).toEqual({ + type: "chatgpt", + planType: "pro", + sparkEnabled: true, + }); + }); + + it("keeps spark enabled for api key accounts", () => { + expect( + readCodexAccountSnapshot({ + type: "apiKey", + }), + ).toEqual({ + type: "apiKey", + planType: null, + sparkEnabled: true, + }); + }); +}); + +describe("resolveCodexModelForAccount", () => { + it("falls back from spark to default for unsupported chatgpt plans", () => { + expect( + resolveCodexModelForAccount("gpt-5.3-codex-spark", { + type: "chatgpt", + planType: "plus", + sparkEnabled: false, + }), + ).toBe("gpt-5.3-codex"); + }); + + it("keeps spark for supported plans", () => { + expect( + resolveCodexModelForAccount("gpt-5.3-codex-spark", { + type: "chatgpt", + planType: "pro", + sparkEnabled: true, + }), + ).toBe("gpt-5.3-codex-spark"); + }); +}); + describe("startSession", () => { it("enables Codex experimental api capabilities during initialize", () => { expect(buildCodexInitializeParams()).toEqual({ @@ -259,8 +333,8 @@ describe("sendTurn", () => { }, ], model: "gpt-5.3", - effort: "high", serviceTier: "fast", + effort: "high", }); expect(result).toEqual({ @@ -283,8 +357,8 @@ describe("sendTurn", () => { }, ], model: "gpt-5.3-codex", - effort: "high", serviceTier: "fast", + effort: "high", }); expect(updateSession).toHaveBeenCalledWith(context, { status: "running", @@ -314,6 +388,7 @@ describe("sendTurn", () => { url: "data:image/png;base64,BBBB", }, ], + model: "gpt-5.3-codex", }); }); @@ -377,6 +452,37 @@ describe("sendTurn", () => { }); }); + it("keeps the session model when interaction mode is set without an explicit model", async () => { + const { manager, context, sendRequest } = createSendTurnHarness(); + context.session.model = "gpt-5.2-codex"; + + await manager.sendTurn({ + threadId: asThreadId("thread_1"), + input: "Plan this with my current session model", + interactionMode: "plan", + }); + + expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { + threadId: "thread_1", + input: [ + { + type: "text", + text: "Plan this with my current session model", + text_elements: [], + }, + ], + model: "gpt-5.2-codex", + collaborationMode: { + mode: "plan", + settings: { + model: "gpt-5.2-codex", + reasoning_effort: "medium", + developer_instructions: CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, + }, + }, + }); + }); + it("rejects empty turn input", async () => { const { manager } = createSendTurnHarness(); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 6e83d211ad91e9bbcb13c2b76346bbc47b1d08a8..c80b9f75f821a013766ddcc9388b3f86e9945764 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -58,6 +58,7 @@ interface CodexUserInputAnswer { interface CodexSessionContext { session: ProviderSession; + account: CodexAccountSnapshot; child: ChildProcessWithoutNullStreams; output: readline.Interface; pending: Map; @@ -89,13 +90,30 @@ interface JsonRpcNotification { params?: unknown; } +type CodexPlanType = + | "free" + | "go" + | "plus" + | "pro" + | "team" + | "business" + | "enterprise" + | "edu" + | "unknown"; + +interface CodexAccountSnapshot { + readonly type: "apiKey" | "chatgpt" | "unknown"; + readonly planType: CodexPlanType | null; + readonly sparkEnabled: boolean; +} + export interface CodexAppServerSendTurnInput { readonly threadId: ThreadId; readonly input?: string; readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>; readonly model?: string; + readonly serviceTier?: string | null; readonly effort?: string; - readonly serviceTier?: string; readonly interactionMode?: ProviderInteractionMode; } @@ -135,6 +153,50 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ "unknown thread", "does not exist", ]; +const CODEX_DEFAULT_MODEL = "gpt-5.3-codex"; +const CODEX_SPARK_MODEL = "gpt-5.3-codex-spark"; +const CODEX_SPARK_DISABLED_PLAN_TYPES = new Set(["free", "go", "plus"]); + +function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + return value as Record; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export function readCodexAccountSnapshot(response: unknown): CodexAccountSnapshot { + const record = asObject(response); + const account = asObject(record?.account) ?? record; + const accountType = asString(account?.type); + + if (accountType === "apiKey") { + return { + type: "apiKey", + planType: null, + sparkEnabled: true, + }; + } + + if (accountType === "chatgpt") { + const planType = (account?.planType as CodexPlanType | null) ?? "unknown"; + return { + type: "chatgpt", + planType, + sparkEnabled: !CODEX_SPARK_DISABLED_PLAN_TYPES.has(planType), + }; + } + + return { + type: "unknown", + planType: null, + sparkEnabled: true, + }; +} + export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `# Plan Mode (Conversational) You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. @@ -287,6 +349,17 @@ function mapCodexRuntimeMode(runtimeMode: RuntimeMode): { }; } +export function resolveCodexModelForAccount( + model: string | undefined, + account: CodexAccountSnapshot, +): string | undefined { + if (model !== CODEX_SPARK_MODEL || account.sparkEnabled) { + return model; + } + + return CODEX_DEFAULT_MODEL; +} + /** * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe` * wrapper, leaving the actual command running. Use `taskkill /T` to kill the @@ -475,6 +548,11 @@ export class CodexAppServerManager extends EventEmitter; model?: string; + serviceTier?: string | null; effort?: string; - serviceTier?: string; collaborationMode?: { mode: "default" | "plan"; settings: { @@ -671,16 +770,19 @@ export class CodexAppServerManager extends EventEmitter; readonly provider?: ProviderKind; readonly model?: string; + readonly serviceTier?: ProviderServiceTier | null; readonly modelOptions?: ProviderModelOptions; readonly interactionMode?: "default" | "plan"; readonly createdAt: string; @@ -331,6 +335,7 @@ const make = Effect.gen(function* () { yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.provider !== undefined ? { provider: input.provider } : {}), ...(input.model !== undefined ? { model: input.model } : {}), + ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), }); const normalizedInput = toNonEmptyProviderInput(input.messageText); @@ -350,6 +355,7 @@ const make = Effect.gen(function* () { ...(normalizedInput ? { input: normalizedInput } : {}), ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), ...(modelForTurn !== undefined ? { model: modelForTurn } : {}), + ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), }); @@ -464,6 +470,7 @@ const make = Effect.gen(function* () { ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}), ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), + ...(event.payload.serviceTier !== undefined ? { serviceTier: event.payload.serviceTier } : {}), ...(event.payload.modelOptions !== undefined ? { modelOptions: event.payload.modelOptions } : {}), interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 7fe8eb746d8caeec66803488a67ae33ebeeb80df..f036416429268327ffe1d768b77245b69ab9b23a 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -301,6 +301,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" messageId: command.message.messageId, ...(command.provider !== undefined ? { provider: command.provider } : {}), ...(command.model !== undefined ? { model: command.model } : {}), + ...(command.serviceTier !== undefined ? { serviceTier: command.serviceTier } : {}), ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}), assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, runtimeMode: diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index a31ba2907e0946b836627215aa7c15350398eb22..9a6271b792393cce80185a2b9c7ad8a07f44faeb 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1354,6 +1354,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => threadId: input.threadId, ...(input.input !== undefined ? { input: input.input } : {}), ...(input.model !== undefined ? { model: input.model } : {}), + ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), ...(input.modelOptions?.codex?.reasoningEffort !== undefined ? { effort: input.modelOptions.codex.reasoningEffort } : {}), diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 6c6cbac495f68b77cad8cf4b43189d606bc4dce1..5ab5d3c90ad6d78ee34bc8e7975f92b1d29bd6bf 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -4,6 +4,8 @@ import { getAppModelOptions, getSlashModelOptions, normalizeCustomModelSlugs, + resolveAppServiceTier, + shouldShowFastTierIcon, resolveAppModelSelection, } from "./appSettings"; @@ -82,3 +84,22 @@ describe("getSlashModelOptions", () => { expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]); }); }); + +describe("resolveAppServiceTier", () => { + it("maps automatic to no override", () => { + expect(resolveAppServiceTier("auto")).toBeNull(); + }); + + it("preserves explicit service tier overrides", () => { + expect(resolveAppServiceTier("fast")).toBe("fast"); + expect(resolveAppServiceTier("flex")).toBe("flex"); + }); +}); + +describe("shouldShowFastTierIcon", () => { + it("shows the fast-tier icon only for gpt-5.4 on fast tier", () => { + expect(shouldShowFastTierIcon("gpt-5.4", "fast")).toBe(true); + expect(shouldShowFastTierIcon("gpt-5.4", "auto")).toBe(false); + expect(shouldShowFastTierIcon("gpt-5.3-codex", "fast")).toBe(false); + }); +}); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 5ed218fb254e751012d2bd5e5d00ca7e8e02469e..e58f7af4a6796b2985fcaadae3c79d6c1259bf08 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,11 +1,31 @@ import { useCallback, useSyncExternalStore } from "react"; import { Option, Schema } from "effect"; -import { type ProviderKind } from "@t3tools/contracts"; +import { type ProviderKind, type ProviderServiceTier } from "@t3tools/contracts"; import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; +export const APP_SERVICE_TIER_OPTIONS = [ + { + value: "auto", + label: "Automatic", + description: "Use Codex defaults without forcing a service tier.", + }, + { + value: "fast", + label: "Fast", + description: "Request the fast service tier when the model supports it.", + }, + { + value: "flex", + label: "Flex", + description: "Request the flex service tier when the model supports it.", + }, +] as const; +export type AppServiceTier = (typeof APP_SERVICE_TIER_OPTIONS)[number]["value"]; +const AppServiceTierSchema = Schema.Literals(["auto", "fast", "flex"]); +const MODELS_WITH_FAST_SUPPORT = new Set(["gpt-5.4"]); const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), }; @@ -21,6 +41,7 @@ const AppSettingsSchema = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe( Schema.withConstructorDefault(() => Option.some(false)), ), + codexServiceTier: AppServiceTierSchema.pipe(Schema.withConstructorDefault(() => Option.some("auto"))), customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), @@ -32,6 +53,22 @@ export interface AppModelOption { isCustom: boolean; } +export function resolveAppServiceTier(serviceTier: AppServiceTier): ProviderServiceTier | null { + return serviceTier === "auto" ? null : serviceTier; +} + +export function shouldShowFastTierIcon( + model: string | null | undefined, + serviceTier: AppServiceTier, +): boolean { + const normalizedModel = normalizeModelSlug(model); + return ( + resolveAppServiceTier(serviceTier) === "fast" && + normalizedModel !== null && + MODELS_WITH_FAST_SUPPORT.has(normalizedModel) + ); +} + const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); let listeners: Array<() => void> = []; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 446831c8a9c609cd68e6c78e1ea8b47bb48f9116..750e1ffc78d51744ef0bf644fff5b25bd13e712e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -140,6 +140,7 @@ import { XIcon, CopyIcon, CheckIcon, + ZapIcon, } from "lucide-react"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; @@ -196,7 +197,14 @@ import { Toggle } from "./ui/toggle"; import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -import { getAppModelOptions, resolveAppModelSelection, useAppSettings } from "../appSettings"; +import { + getAppModelOptions, + resolveAppModelSelection, + resolveAppServiceTier, + shouldShowFastTierIcon, + type AppServiceTier, + useAppSettings, +} from "../appSettings"; import { type ComposerImageAttachment, type DraftThreadEnvMode, @@ -388,6 +396,7 @@ type ComposerCommandItem = model: ModelSlug; label: string; description: string; + showFastBadge: boolean; }; type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; @@ -497,7 +506,12 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { model ) : null} - {props.item.label} + + {props.item.type === "model" && props.item.showFastBadge ? ( + + ) : null} + {props.item.label} + {props.item.description} ); @@ -754,6 +768,8 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThread.messages.length > 0 || activeThread.session !== null), ); + const selectedServiceTierSetting = settings.codexServiceTier; + const selectedServiceTier = resolveAppServiceTier(selectedServiceTierSetting); const lockedProvider: ProviderKind | null = hasThreadStarted ? (sessionProvider ?? selectedProviderByThreadId ?? null) : null; @@ -1205,8 +1221,10 @@ export default function ChatView({ threadId }: ChatViewProps) { model: slug, label: name, description: `${providerLabel} ยท ${slug}`, + showFastBadge: + provider === "codex" && shouldShowFastTierIcon(slug, selectedServiceTierSetting), })); - }, [composerTrigger, searchableModelOptions, workspaceEntries]); + }, [composerTrigger, searchableModelOptions, selectedServiceTierSetting, workspaceEntries]); const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = useMemo( () => @@ -2539,6 +2557,7 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: turnAttachments, }, model: selectedModel || undefined, + serviceTier: selectedServiceTier, ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), @@ -3534,6 +3553,7 @@ export default function ChatView({ threadId }: ChatViewProps) { model={selectedModelForPickerWithCustomFallback} lockedProvider={lockedProvider} modelOptionsByProvider={modelOptionsByProvider} + serviceTierSetting={selectedServiceTierSetting} onProviderModelChange={onProviderModelSelect} /> @@ -5220,6 +5240,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { model: ModelSlug; lockedProvider: ProviderKind | null; modelOptionsByProvider: Record>; + serviceTierSetting: AppServiceTier; disabled?: boolean; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; }) { @@ -5252,6 +5273,9 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { > @@ -5294,6 +5318,10 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { value={modelOption.slug} onClick={() => setIsMenuOpen(false)} > + {option.value === "codex" && + shouldShowFastTierIcon(modelOption.slug, props.serviceTierSetting) ? ( + + ) : null} {modelOption.name} ))} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 8dfeb210f284599ff6471310724df721061c87fe..cc4a39a27212109529be4c8ea7102d1193ee12cd 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -3,8 +3,14 @@ import { useQuery } from "@tanstack/react-query"; import { useCallback, useState } from "react"; import { type ProviderKind } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; +import { ZapIcon } from "lucide-react"; -import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; +import { + APP_SERVICE_TIER_OPTIONS, + MAX_CUSTOM_MODEL_LENGTH, + shouldShowFastTierIcon, + useAppSettings, +} from "../appSettings"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; @@ -12,6 +18,7 @@ import { ensureNativeApi } from "../nativeApi"; import { preferredTerminalEditor } from "../terminal-links"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../components/ui/select"; import { Switch } from "../components/ui/switch"; import { SidebarInset } from "~/components/ui/sidebar"; @@ -96,6 +103,7 @@ function SettingsRouteView() { const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; + const codexServiceTier = settings.codexServiceTier; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const openKeybindingsFile = useCallback(() => { @@ -302,6 +310,43 @@ function SettingsRouteView() {
+ + {MODEL_PROVIDER_SETTINGS.map((providerSettings) => { const provider = providerSettings.provider; const customModels = getCustomModelsForProvider(settings, provider); @@ -400,9 +445,14 @@ function SettingsRouteView() { key={`${provider}:${slug}`} className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background px-3 py-2" > - - {slug} - +
+ {provider === "codex" && shouldShowFastTierIcon(slug, codexServiceTier) ? ( + + ) : null} + + {slug} + +