authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-03-06 15:48:37-06:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-06 13:48:37-08:00
log40fcb6bdf8d726f35c6d3aec82d0cce6345c9dcd
tree7a92e1d9f889c6beb5b0710074fb991995deaafa
parentd58ff5de26b9753c3dbd13cb55e532ccd44ba0d2
signature Signed by PGP key B5690EEEBB952194

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 <me@t3.gg>

11 files changed, 400 insertions(+), 22 deletions(-)

apps/server/src/codexAppServerManager.test.ts+111-5
......@@ -13,6 +13,8 @@ import {
1313 classifyCodexStderrLine,
1414 isRecoverableThreadResumeError,
1515 normalizeCodexModelSlug,
16 readCodexAccountSnapshot,
17 resolveCodexModelForAccount,
1618} from "./codexAppServerManager";
1719
1820const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
......@@ -21,14 +23,20 @@ function createSendTurnHarness() {
2123 const manager = new CodexAppServerManager();
2224 const context = {
2325 session: {
24 sessionId: "sess_1",
2526 provider: "codex",
2627 status: "ready",
2728 threadId: "thread_1",
29 runtimeMode: "full-access",
30 model: "gpt-5.3-codex",
2831 resumeCursor: { threadId: "thread_1" },
2932 createdAt: "2026-02-10T00:00:00.000Z",
3033 updatedAt: "2026-02-10T00:00:00.000Z",
3134 },
35 account: {
36 type: "unknown",
37 planType: null,
38 sparkEnabled: true,
39 },
3240 };
3341
3442 const requireSession = vi
......@@ -58,10 +66,11 @@ function createThreadControlHarness() {
5866 const manager = new CodexAppServerManager();
5967 const context = {
6068 session: {
61 sessionId: "sess_1",
6269 provider: "codex",
6370 status: "ready",
6471 threadId: "thread_1",
72 runtimeMode: "full-access",
73 model: "gpt-5.3-codex",
6574 resumeCursor: { threadId: "thread_1" },
6675 createdAt: "2026-02-10T00:00:00.000Z",
6776 updatedAt: "2026-02-10T00:00:00.000Z",
......@@ -89,10 +98,11 @@ function createPendingUserInputHarness() {
8998 const manager = new CodexAppServerManager();
9099 const context = {
91100 session: {
92 sessionId: "sess_1",
93101 provider: "codex",
94102 status: "ready",
95103 threadId: "thread_1",
104 runtimeMode: "full-access",
105 model: "gpt-5.3-codex",
96106 resumeCursor: { threadId: "thread_1" },
97107 createdAt: "2026-02-10T00:00:00.000Z",
98108 updatedAt: "2026-02-10T00:00:00.000Z",
......@@ -195,6 +205,70 @@ describe("isRecoverableThreadResumeError", () => {
195205 });
196206});
197207
208describe("readCodexAccountSnapshot", () => {
209 it("disables spark for chatgpt plus accounts", () => {
210 expect(
211 readCodexAccountSnapshot({
212 type: "chatgpt",
213 email: "plus@example.com",
214 planType: "plus",
215 }),
216 ).toEqual({
217 type: "chatgpt",
218 planType: "plus",
219 sparkEnabled: false,
220 });
221 });
222
223 it("keeps spark enabled for chatgpt pro accounts", () => {
224 expect(
225 readCodexAccountSnapshot({
226 type: "chatgpt",
227 email: "pro@example.com",
228 planType: "pro",
229 }),
230 ).toEqual({
231 type: "chatgpt",
232 planType: "pro",
233 sparkEnabled: true,
234 });
235 });
236
237 it("keeps spark enabled for api key accounts", () => {
238 expect(
239 readCodexAccountSnapshot({
240 type: "apiKey",
241 }),
242 ).toEqual({
243 type: "apiKey",
244 planType: null,
245 sparkEnabled: true,
246 });
247 });
248});
249
250describe("resolveCodexModelForAccount", () => {
251 it("falls back from spark to default for unsupported chatgpt plans", () => {
252 expect(
253 resolveCodexModelForAccount("gpt-5.3-codex-spark", {
254 type: "chatgpt",
255 planType: "plus",
256 sparkEnabled: false,
257 }),
258 ).toBe("gpt-5.3-codex");
259 });
260
261 it("keeps spark for supported plans", () => {
262 expect(
263 resolveCodexModelForAccount("gpt-5.3-codex-spark", {
264 type: "chatgpt",
265 planType: "pro",
266 sparkEnabled: true,
267 }),
268 ).toBe("gpt-5.3-codex-spark");
269 });
270});
271
198272describe("startSession", () => {
199273 it("enables Codex experimental api capabilities during initialize", () => {
200274 expect(buildCodexInitializeParams()).toEqual({
......@@ -259,8 +333,8 @@ describe("sendTurn", () => {
259333 },
260334 ],
261335 model: "gpt-5.3",
262 effort: "high",
263336 serviceTier: "fast",
337 effort: "high",
264338 });
265339
266340 expect(result).toEqual({
......@@ -283,8 +357,8 @@ describe("sendTurn", () => {
283357 },
284358 ],
285359 model: "gpt-5.3-codex",
286 effort: "high",
287360 serviceTier: "fast",
361 effort: "high",
288362 });
289363 expect(updateSession).toHaveBeenCalledWith(context, {
290364 status: "running",
......@@ -314,6 +388,7 @@ describe("sendTurn", () => {
314388 url: "data:image/png;base64,BBBB",
315389 },
316390 ],
391 model: "gpt-5.3-codex",
317392 });
318393 });
319394
......@@ -377,6 +452,37 @@ describe("sendTurn", () => {
377452 });
378453 });
379454
455 it("keeps the session model when interaction mode is set without an explicit model", async () => {
456 const { manager, context, sendRequest } = createSendTurnHarness();
457 context.session.model = "gpt-5.2-codex";
458
459 await manager.sendTurn({
460 threadId: asThreadId("thread_1"),
461 input: "Plan this with my current session model",
462 interactionMode: "plan",
463 });
464
465 expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", {
466 threadId: "thread_1",
467 input: [
468 {
469 type: "text",
470 text: "Plan this with my current session model",
471 text_elements: [],
472 },
473 ],
474 model: "gpt-5.2-codex",
475 collaborationMode: {
476 mode: "plan",
477 settings: {
478 model: "gpt-5.2-codex",
479 reasoning_effort: "medium",
480 developer_instructions: CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS,
481 },
482 },
483 });
484 });
485
380486 it("rejects empty turn input", async () => {
381487 const { manager } = createSendTurnHarness();
382488
apps/server/src/codexAppServerManager.ts+110-8
......@@ -58,6 +58,7 @@ interface CodexUserInputAnswer {
5858
5959interface CodexSessionContext {
6060 session: ProviderSession;
61 account: CodexAccountSnapshot;
6162 child: ChildProcessWithoutNullStreams;
6263 output: readline.Interface;
6364 pending: Map<PendingRequestKey, PendingRequest>;
......@@ -89,13 +90,30 @@ interface JsonRpcNotification {
8990 params?: unknown;
9091}
9192
93type CodexPlanType =
94 | "free"
95 | "go"
96 | "plus"
97 | "pro"
98 | "team"
99 | "business"
100 | "enterprise"
101 | "edu"
102 | "unknown";
103
104interface CodexAccountSnapshot {
105 readonly type: "apiKey" | "chatgpt" | "unknown";
106 readonly planType: CodexPlanType | null;
107 readonly sparkEnabled: boolean;
108}
109
92110export interface CodexAppServerSendTurnInput {
93111 readonly threadId: ThreadId;
94112 readonly input?: string;
95113 readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>;
96114 readonly model?: string;
115 readonly serviceTier?: string | null;
97116 readonly effort?: string;
98 readonly serviceTier?: string;
99117 readonly interactionMode?: ProviderInteractionMode;
100118}
101119
......@@ -135,6 +153,50 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [
135153 "unknown thread",
136154 "does not exist",
137155];
156const CODEX_DEFAULT_MODEL = "gpt-5.3-codex";
157const CODEX_SPARK_MODEL = "gpt-5.3-codex-spark";
158const CODEX_SPARK_DISABLED_PLAN_TYPES = new Set<CodexPlanType>(["free", "go", "plus"]);
159
160function asObject(value: unknown): Record<string, unknown> | undefined {
161 if (!value || typeof value !== "object") {
162 return undefined;
163 }
164 return value as Record<string, unknown>;
165}
166
167function asString(value: unknown): string | undefined {
168 return typeof value === "string" ? value : undefined;
169}
170
171export function readCodexAccountSnapshot(response: unknown): CodexAccountSnapshot {
172 const record = asObject(response);
173 const account = asObject(record?.account) ?? record;
174 const accountType = asString(account?.type);
175
176 if (accountType === "apiKey") {
177 return {
178 type: "apiKey",
179 planType: null,
180 sparkEnabled: true,
181 };
182 }
183
184 if (accountType === "chatgpt") {
185 const planType = (account?.planType as CodexPlanType | null) ?? "unknown";
186 return {
187 type: "chatgpt",
188 planType,
189 sparkEnabled: !CODEX_SPARK_DISABLED_PLAN_TYPES.has(planType),
190 };
191 }
192
193 return {
194 type: "unknown",
195 planType: null,
196 sparkEnabled: true,
197 };
198}
199
138200export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Plan Mode (Conversational)
139201
140202You 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): {
287349 };
288350}
289351
352export function resolveCodexModelForAccount(
353 model: string | undefined,
354 account: CodexAccountSnapshot,
355): string | undefined {
356 if (model !== CODEX_SPARK_MODEL || account.sparkEnabled) {
357 return model;
358 }
359
360 return CODEX_DEFAULT_MODEL;
361}
362
290363/**
291364 * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe`
292365 * wrapper, leaving the actual command running. Use `taskkill /T` to kill the
......@@ -475,6 +548,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
475548
476549 context = {
477550 session,
551 account: {
552 type: "unknown",
553 planType: null,
554 sparkEnabled: true,
555 },
478556 child,
479557 output,
480558 pending: new Map(),
......@@ -492,12 +570,33 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
492570 await this.sendRequest(context, "initialize", buildCodexInitializeParams());
493571
494572 this.writeMessage(context, { method: "initialized" });
573 try {
574 const modelListResponse = await this.sendRequest(context, "model/list", {});
575 console.log("codex model/list response", modelListResponse);
576 } catch (error) {
577 console.log("codex model/list failed", error);
578 }
579 try {
580 const accountReadResponse = await this.sendRequest(context, "account/read", {});
581 console.log("codex account/read response", accountReadResponse);
582 context.account = readCodexAccountSnapshot(accountReadResponse);
583 console.log("codex subscription status", {
584 type: context.account.type,
585 planType: context.account.planType,
586 sparkEnabled: context.account.sparkEnabled,
587 });
588 } catch (error) {
589 console.log("codex account/read failed", error);
590 }
495591
496 const normalizedModel = normalizeCodexModelSlug(input.model);
592 const normalizedModel = resolveCodexModelForAccount(
593 normalizeCodexModelSlug(input.model),
594 context.account,
595 );
497596 const sessionOverrides = {
498597 model: normalizedModel ?? null,
499 cwd: input.cwd ?? null,
500598 ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}),
599 cwd: input.cwd ?? null,
501600 ...mapCodexRuntimeMode(input.runtimeMode ?? "full-access"),
502601 };
503602
......@@ -657,8 +756,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
657756 { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string }
658757 >;
659758 model?: string;
759 serviceTier?: string | null;
660760 effort?: string;
661 serviceTier?: string;
662761 collaborationMode?: {
663762 mode: "default" | "plan";
664763 settings: {
......@@ -671,16 +770,19 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
671770 threadId: providerThreadId,
672771 input: turnInput,
673772 };
674 const normalizedModel = normalizeCodexModelSlug(input.model ?? context.session.model);
773 const normalizedModel = resolveCodexModelForAccount(
774 normalizeCodexModelSlug(input.model ?? context.session.model),
775 context.account,
776 );
675777 if (normalizedModel) {
676778 turnStartParams.model = normalizedModel;
677779 }
780 if (input.serviceTier !== undefined) {
781 turnStartParams.serviceTier = input.serviceTier;
782 }
678783 if (input.effort) {
679784 turnStartParams.effort = input.effort;
680785 }
681 if (input.serviceTier) {
682 turnStartParams.serviceTier = input.serviceTier;
683 }
684786 const collaborationMode = buildCodexCollaborationMode({
685787 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
686788 ...(normalizedModel !== undefined ? { model: normalizedModel } : {}),
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+7
......@@ -5,6 +5,7 @@ import {
55 type OrchestrationEvent,
66 type ProviderModelOptions,
77 type ProviderKind,
8 type ProviderServiceTier,
89 type OrchestrationSession,
910 ThreadId,
1011 type ProviderSession,
......@@ -201,6 +202,7 @@ const make = Effect.gen(function* () {
201202 readonly provider?: ProviderKind;
202203 readonly model?: string;
203204 readonly modelOptions?: ProviderModelOptions;
205 readonly serviceTier?: ProviderServiceTier | null;
204206 },
205207 ) {
206208 const readModel = yield* orchestrationEngine.getReadModel();
......@@ -235,6 +237,7 @@ const make = Effect.gen(function* () {
235237 : {}),
236238 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
237239 ...(desiredModel ? { model: desiredModel } : {}),
240 ...(options?.serviceTier !== undefined ? { serviceTier: options.serviceTier } : {}),
238241 ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}),
239242 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
240243 runtimeMode: desiredRuntimeMode,
......@@ -320,6 +323,7 @@ const make = Effect.gen(function* () {
320323 readonly attachments?: ReadonlyArray<ChatAttachment>;
321324 readonly provider?: ProviderKind;
322325 readonly model?: string;
326 readonly serviceTier?: ProviderServiceTier | null;
323327 readonly modelOptions?: ProviderModelOptions;
324328 readonly interactionMode?: "default" | "plan";
325329 readonly createdAt: string;
......@@ -331,6 +335,7 @@ const make = Effect.gen(function* () {
331335 yield* ensureSessionForThread(input.threadId, input.createdAt, {
332336 ...(input.provider !== undefined ? { provider: input.provider } : {}),
333337 ...(input.model !== undefined ? { model: input.model } : {}),
338 ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}),
334339 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
335340 });
336341 const normalizedInput = toNonEmptyProviderInput(input.messageText);
......@@ -350,6 +355,7 @@ const make = Effect.gen(function* () {
350355 ...(normalizedInput ? { input: normalizedInput } : {}),
351356 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
352357 ...(modelForTurn !== undefined ? { model: modelForTurn } : {}),
358 ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}),
353359 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
354360 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
355361 });
......@@ -464,6 +470,7 @@ const make = Effect.gen(function* () {
464470 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
465471 ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}),
466472 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),
473 ...(event.payload.serviceTier !== undefined ? { serviceTier: event.payload.serviceTier } : {}),
467474 ...(event.payload.modelOptions !== undefined ? { modelOptions: event.payload.modelOptions } : {}),
468475 interactionMode: event.payload.interactionMode,
469476 createdAt: event.payload.createdAt,
apps/server/src/orchestration/decider.ts+1
......@@ -301,6 +301,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
301301 messageId: command.message.messageId,
302302 ...(command.provider !== undefined ? { provider: command.provider } : {}),
303303 ...(command.model !== undefined ? { model: command.model } : {}),
304 ...(command.serviceTier !== undefined ? { serviceTier: command.serviceTier } : {}),
304305 ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}),
305306 assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE,
306307 runtimeMode:
apps/server/src/provider/Layers/CodexAdapter.ts+1
......@@ -1354,6 +1354,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
13541354 threadId: input.threadId,
13551355 ...(input.input !== undefined ? { input: input.input } : {}),
13561356 ...(input.model !== undefined ? { model: input.model } : {}),
1357 ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}),
13571358 ...(input.modelOptions?.codex?.reasoningEffort !== undefined
13581359 ? { effort: input.modelOptions.codex.reasoningEffort }
13591360 : {}),
apps/web/src/appSettings.test.ts+21
......@@ -4,6 +4,8 @@ import {
44 getAppModelOptions,
55 getSlashModelOptions,
66 normalizeCustomModelSlugs,
7 resolveAppServiceTier,
8 shouldShowFastTierIcon,
79 resolveAppModelSelection,
810} from "./appSettings";
911
......@@ -82,3 +84,22 @@ describe("getSlashModelOptions", () => {
8284 expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]);
8385 });
8486});
87
88describe("resolveAppServiceTier", () => {
89 it("maps automatic to no override", () => {
90 expect(resolveAppServiceTier("auto")).toBeNull();
91 });
92
93 it("preserves explicit service tier overrides", () => {
94 expect(resolveAppServiceTier("fast")).toBe("fast");
95 expect(resolveAppServiceTier("flex")).toBe("flex");
96 });
97});
98
99describe("shouldShowFastTierIcon", () => {
100 it("shows the fast-tier icon only for gpt-5.4 on fast tier", () => {
101 expect(shouldShowFastTierIcon("gpt-5.4", "fast")).toBe(true);
102 expect(shouldShowFastTierIcon("gpt-5.4", "auto")).toBe(false);
103 expect(shouldShowFastTierIcon("gpt-5.3-codex", "fast")).toBe(false);
104 });
105});
apps/web/src/appSettings.ts+38-1
......@@ -1,11 +1,31 @@
11import { useCallback, useSyncExternalStore } from "react";
22import { Option, Schema } from "effect";
3import { type ProviderKind } from "@t3tools/contracts";
3import { type ProviderKind, type ProviderServiceTier } from "@t3tools/contracts";
44import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
55
66const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
77const MAX_CUSTOM_MODEL_COUNT = 32;
88export const MAX_CUSTOM_MODEL_LENGTH = 256;
9export const APP_SERVICE_TIER_OPTIONS = [
10 {
11 value: "auto",
12 label: "Automatic",
13 description: "Use Codex defaults without forcing a service tier.",
14 },
15 {
16 value: "fast",
17 label: "Fast",
18 description: "Request the fast service tier when the model supports it.",
19 },
20 {
21 value: "flex",
22 label: "Flex",
23 description: "Request the flex service tier when the model supports it.",
24 },
25] as const;
26export type AppServiceTier = (typeof APP_SERVICE_TIER_OPTIONS)[number]["value"];
27const AppServiceTierSchema = Schema.Literals(["auto", "fast", "flex"]);
28const MODELS_WITH_FAST_SUPPORT = new Set(["gpt-5.4"]);
929const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {
1030 codex: new Set(getModelOptions("codex").map((option) => option.slug)),
1131};
......@@ -21,6 +41,7 @@ const AppSettingsSchema = Schema.Struct({
2141 enableAssistantStreaming: Schema.Boolean.pipe(
2242 Schema.withConstructorDefault(() => Option.some(false)),
2343 ),
44 codexServiceTier: AppServiceTierSchema.pipe(Schema.withConstructorDefault(() => Option.some("auto"))),
2445 customCodexModels: Schema.Array(Schema.String).pipe(
2546 Schema.withConstructorDefault(() => Option.some([])),
2647 ),
......@@ -32,6 +53,22 @@ export interface AppModelOption {
3253 isCustom: boolean;
3354}
3455
56export function resolveAppServiceTier(serviceTier: AppServiceTier): ProviderServiceTier | null {
57 return serviceTier === "auto" ? null : serviceTier;
58}
59
60export function shouldShowFastTierIcon(
61 model: string | null | undefined,
62 serviceTier: AppServiceTier,
63): boolean {
64 const normalizedModel = normalizeModelSlug(model);
65 return (
66 resolveAppServiceTier(serviceTier) === "fast" &&
67 normalizedModel !== null &&
68 MODELS_WITH_FAST_SUPPORT.has(normalizedModel)
69 );
70}
71
3572const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
3673
3774let listeners: Array<() => void> = [];
apps/web/src/components/ChatView.tsx+31-3
......@@ -140,6 +140,7 @@ import {
140140 XIcon,
141141 CopyIcon,
142142 CheckIcon,
143 ZapIcon,
143144} from "lucide-react";
144145import { Button } from "./ui/button";
145146import { Input } from "./ui/input";
......@@ -196,7 +197,14 @@ import { Toggle } from "./ui/toggle";
196197import { SidebarTrigger } from "./ui/sidebar";
197198import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
198199import { readNativeApi } from "~/nativeApi";
199import { getAppModelOptions, resolveAppModelSelection, useAppSettings } from "../appSettings";
200import {
201 getAppModelOptions,
202 resolveAppModelSelection,
203 resolveAppServiceTier,
204 shouldShowFastTierIcon,
205 type AppServiceTier,
206 useAppSettings,
207} from "../appSettings";
200208import {
201209 type ComposerImageAttachment,
202210 type DraftThreadEnvMode,
......@@ -388,6 +396,7 @@ type ComposerCommandItem =
388396 model: ModelSlug;
389397 label: string;
390398 description: string;
399 showFastBadge: boolean;
391400 };
392401
393402type SendPhase = "idle" | "preparing-worktree" | "sending-turn";
......@@ -497,7 +506,12 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
497506 model
498507 </Badge>
499508 ) : null}
500 <span className="truncate">{props.item.label}</span>
509 <span className="flex min-w-0 items-center gap-1.5 truncate">
510 {props.item.type === "model" && props.item.showFastBadge ? (
511 <ZapIcon className="size-3.5 shrink-0 text-amber-500" />
512 ) : null}
513 <span className="truncate">{props.item.label}</span>
514 </span>
501515 <span className="truncate text-muted-foreground/70 text-xs">{props.item.description}</span>
502516 </CommandItem>
503517 );
......@@ -754,6 +768,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
754768 activeThread.messages.length > 0 ||
755769 activeThread.session !== null),
756770 );
771 const selectedServiceTierSetting = settings.codexServiceTier;
772 const selectedServiceTier = resolveAppServiceTier(selectedServiceTierSetting);
757773 const lockedProvider: ProviderKind | null = hasThreadStarted
758774 ? (sessionProvider ?? selectedProviderByThreadId ?? null)
759775 : null;
......@@ -1205,8 +1221,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
12051221 model: slug,
12061222 label: name,
12071223 description: `${providerLabel} · ${slug}`,
1224 showFastBadge:
1225 provider === "codex" && shouldShowFastTierIcon(slug, selectedServiceTierSetting),
12081226 }));
1209 }, [composerTrigger, searchableModelOptions, workspaceEntries]);
1227 }, [composerTrigger, searchableModelOptions, selectedServiceTierSetting, workspaceEntries]);
12101228 const composerMenuOpen = Boolean(composerTrigger);
12111229 const activeComposerMenuItem = useMemo(
12121230 () =>
......@@ -2539,6 +2557,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
25392557 attachments: turnAttachments,
25402558 },
25412559 model: selectedModel || undefined,
2560 serviceTier: selectedServiceTier,
25422561 ...(selectedModelOptionsForDispatch
25432562 ? { modelOptions: selectedModelOptionsForDispatch }
25442563 : {}),
......@@ -3534,6 +3553,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
35343553 model={selectedModelForPickerWithCustomFallback}
35353554 lockedProvider={lockedProvider}
35363555 modelOptionsByProvider={modelOptionsByProvider}
3556 serviceTierSetting={selectedServiceTierSetting}
35373557 onProviderModelChange={onProviderModelSelect}
35383558 />
35393559
......@@ -5220,6 +5240,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
52205240 model: ModelSlug;
52215241 lockedProvider: ProviderKind | null;
52225242 modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;
5243 serviceTierSetting: AppServiceTier;
52235244 disabled?: boolean;
52245245 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
52255246}) {
......@@ -5252,6 +5273,9 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
52525273 >
52535274 <span className="flex min-w-0 items-center gap-2">
52545275 <ProviderIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/70" />
5276 {props.provider === "codex" && shouldShowFastTierIcon(props.model, props.serviceTierSetting) ? (
5277 <ZapIcon className="size-3.5 shrink-0 text-amber-500" />
5278 ) : null}
52555279 <span className="truncate">{selectedModelLabel}</span>
52565280 <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" />
52575281 </span>
......@@ -5294,6 +5318,10 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
52945318 value={modelOption.slug}
52955319 onClick={() => setIsMenuOpen(false)}
52965320 >
5321 {option.value === "codex" &&
5322 shouldShowFastTierIcon(modelOption.slug, props.serviceTierSetting) ? (
5323 <ZapIcon className="size-3.5 shrink-0 text-amber-500" />
5324 ) : null}
52975325 {modelOption.name}
52985326 </MenuRadioItem>
52995327 ))}
apps/web/src/routes/_chat.settings.tsx+55-5
......@@ -3,8 +3,14 @@ import { useQuery } from "@tanstack/react-query";
33import { useCallback, useState } from "react";
44import { type ProviderKind } from "@t3tools/contracts";
55import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
6
7import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings";
6import { ZapIcon } from "lucide-react";
7
8import {
9 APP_SERVICE_TIER_OPTIONS,
10 MAX_CUSTOM_MODEL_LENGTH,
11 shouldShowFastTierIcon,
12 useAppSettings,
13} from "../appSettings";
814import { isElectron } from "../env";
915import { useTheme } from "../hooks/useTheme";
1016import { serverConfigQueryOptions } from "../lib/serverReactQuery";
......@@ -12,6 +18,7 @@ import { ensureNativeApi } from "../nativeApi";
1218import { preferredTerminalEditor } from "../terminal-links";
1319import { Button } from "../components/ui/button";
1420import { Input } from "../components/ui/input";
21import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../components/ui/select";
1522import { Switch } from "../components/ui/switch";
1623import { SidebarInset } from "~/components/ui/sidebar";
1724
......@@ -96,6 +103,7 @@ function SettingsRouteView() {
96103
97104 const codexBinaryPath = settings.codexBinaryPath;
98105 const codexHomePath = settings.codexHomePath;
106 const codexServiceTier = settings.codexServiceTier;
99107 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
100108
101109 const openKeybindingsFile = useCallback(() => {
......@@ -302,6 +310,43 @@ function SettingsRouteView() {
302310 </div>
303311
304312 <div className="space-y-5">
313 <label className="block space-y-1">
314 <span className="text-xs font-medium text-foreground">Default service tier</span>
315 <Select
316 items={APP_SERVICE_TIER_OPTIONS.map((option) => ({
317 label: option.label,
318 value: option.value,
319 }))}
320 value={codexServiceTier}
321 onValueChange={(value) => {
322 if (!value) return;
323 updateSettings({ codexServiceTier: value });
324 }}
325 >
326 <SelectTrigger>
327 <SelectValue />
328 </SelectTrigger>
329 <SelectPopup alignItemWithTrigger={false}>
330 {APP_SERVICE_TIER_OPTIONS.map((option) => (
331 <SelectItem key={option.value} value={option.value}>
332 <div className="flex min-w-0 items-center gap-2">
333 {option.value === "fast" ? (
334 <ZapIcon className="size-3.5 text-amber-500" />
335 ) : (
336 <span className="size-3.5 shrink-0" aria-hidden="true" />
337 )}
338 <span className="truncate">{option.label}</span>
339 </div>
340 </SelectItem>
341 ))}
342 </SelectPopup>
343 </Select>
344 <span className="text-xs text-muted-foreground">
345 {APP_SERVICE_TIER_OPTIONS.find((option) => option.value === codexServiceTier)
346 ?.description ?? "Use Codex defaults without forcing a service tier."}
347 </span>
348 </label>
349
305350 {MODEL_PROVIDER_SETTINGS.map((providerSettings) => {
306351 const provider = providerSettings.provider;
307352 const customModels = getCustomModelsForProvider(settings, provider);
......@@ -400,9 +445,14 @@ function SettingsRouteView() {
400445 key={`${provider}:${slug}`}
401446 className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background px-3 py-2"
402447 >
403 <code className="min-w-0 flex-1 truncate text-xs text-foreground">
404 {slug}
405 </code>
448 <div className="flex min-w-0 flex-1 items-center gap-2">
449 {provider === "codex" && shouldShowFastTierIcon(slug, codexServiceTier) ? (
450 <ZapIcon className="size-3.5 shrink-0 text-amber-500" />
451 ) : null}
452 <code className="min-w-0 flex-1 truncate text-xs text-foreground">
453 {slug}
454 </code>
455 </div>
406456 <Button
407457 size="xs"
408458 variant="ghost"
packages/contracts/src/orchestration.ts+18
......@@ -29,6 +29,21 @@ export const ORCHESTRATION_WS_CHANNELS = {
2929
3030export const ProviderKind = Schema.Literal("codex");
3131export type ProviderKind = typeof ProviderKind.Type;
32export const ProviderApprovalPolicy = Schema.Literals([
33 "untrusted",
34 "on-failure",
35 "on-request",
36 "never",
37]);
38export type ProviderApprovalPolicy = typeof ProviderApprovalPolicy.Type;
39export const ProviderSandboxMode = Schema.Literals([
40 "read-only",
41 "workspace-write",
42 "danger-full-access",
43]);
44export type ProviderSandboxMode = typeof ProviderSandboxMode.Type;
45export const ProviderServiceTier = Schema.Literals(["fast", "flex"]);
46export type ProviderServiceTier = typeof ProviderServiceTier.Type;
3247export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";
3348export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]);
3449export type RuntimeMode = typeof RuntimeMode.Type;
......@@ -349,6 +364,7 @@ export const ThreadTurnStartCommand = Schema.Struct({
349364 }),
350365 provider: Schema.optional(ProviderKind),
351366 model: Schema.optional(TrimmedNonEmptyString),
367 serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)),
352368 modelOptions: Schema.optional(ProviderModelOptions),
353369 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
354370 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
......@@ -370,6 +386,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({
370386 }),
371387 provider: Schema.optional(ProviderKind),
372388 model: Schema.optional(TrimmedNonEmptyString),
389 serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)),
373390 modelOptions: Schema.optional(ProviderModelOptions),
374391 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
375392 runtimeMode: RuntimeMode,
......@@ -649,6 +666,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({
649666 messageId: MessageId,
650667 provider: Schema.optional(ProviderKind),
651668 model: Schema.optional(TrimmedNonEmptyString),
669 serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)),
652670 modelOptions: Schema.optional(ProviderModelOptions),
653671 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
654672 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
packages/contracts/src/provider.ts+7
......@@ -14,9 +14,12 @@ import {
1414 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1515 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
1616 ProviderApprovalDecision,
17 ProviderApprovalPolicy,
1718 ProviderInteractionMode,
1819 ProviderKind,
1920 ProviderRequestKind,
21 ProviderSandboxMode,
22 ProviderServiceTier,
2023 ProviderUserInputAnswers,
2124 RuntimeMode,
2225} from "./orchestration";
......@@ -61,6 +64,9 @@ export const ProviderSessionStartInput = Schema.Struct({
6164 model: Schema.optional(TrimmedNonEmptyStringSchema),
6265 modelOptions: Schema.optional(ProviderModelOptions),
6366 resumeCursor: Schema.optional(Schema.Unknown),
67 serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)),
68 approvalPolicy: Schema.optional(ProviderApprovalPolicy),
69 sandboxMode: Schema.optional(ProviderSandboxMode),
6470 providerOptions: Schema.optional(ProviderStartOptions),
6571 runtimeMode: RuntimeMode,
6672});
......@@ -75,6 +81,7 @@ export const ProviderSendTurnInput = Schema.Struct({
7581 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
7682 ),
7783 model: Schema.optional(TrimmedNonEmptyStringSchema),
84 serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)),
7885 modelOptions: Schema.optional(ProviderModelOptions),
7986 interactionMode: Schema.optional(ProviderInteractionMode),
8087});