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 {...@@ -13,6 +13,8 @@ import {
13 classifyCodexStderrLine,13 classifyCodexStderrLine,
14 isRecoverableThreadResumeError,14 isRecoverableThreadResumeError,
15 normalizeCodexModelSlug,15 normalizeCodexModelSlug,
16 readCodexAccountSnapshot,
17 resolveCodexModelForAccount,
16} from "./codexAppServerManager";18} from "./codexAppServerManager";
1719
18const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);20const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
...@@ -21,14 +23,20 @@ function createSendTurnHarness() {...@@ -21,14 +23,20 @@ function createSendTurnHarness() {
21 const manager = new CodexAppServerManager();23 const manager = new CodexAppServerManager();
22 const context = {24 const context = {
23 session: {25 session: {
24 sessionId: "sess_1",
25 provider: "codex",26 provider: "codex",
26 status: "ready",27 status: "ready",
27 threadId: "thread_1",28 threadId: "thread_1",
29 runtimeMode: "full-access",
30 model: "gpt-5.3-codex",
28 resumeCursor: { threadId: "thread_1" },31 resumeCursor: { threadId: "thread_1" },
29 createdAt: "2026-02-10T00:00:00.000Z",32 createdAt: "2026-02-10T00:00:00.000Z",
30 updatedAt: "2026-02-10T00:00:00.000Z",33 updatedAt: "2026-02-10T00:00:00.000Z",
31 },34 },
35 account: {
36 type: "unknown",
37 planType: null,
38 sparkEnabled: true,
39 },
32 };40 };
3341
34 const requireSession = vi42 const requireSession = vi
...@@ -58,10 +66,11 @@ function createThreadControlHarness() {...@@ -58,10 +66,11 @@ function createThreadControlHarness() {
58 const manager = new CodexAppServerManager();66 const manager = new CodexAppServerManager();
59 const context = {67 const context = {
60 session: {68 session: {
61 sessionId: "sess_1",
62 provider: "codex",69 provider: "codex",
63 status: "ready",70 status: "ready",
64 threadId: "thread_1",71 threadId: "thread_1",
72 runtimeMode: "full-access",
73 model: "gpt-5.3-codex",
65 resumeCursor: { threadId: "thread_1" },74 resumeCursor: { threadId: "thread_1" },
66 createdAt: "2026-02-10T00:00:00.000Z",75 createdAt: "2026-02-10T00:00:00.000Z",
67 updatedAt: "2026-02-10T00:00:00.000Z",76 updatedAt: "2026-02-10T00:00:00.000Z",
...@@ -89,10 +98,11 @@ function createPendingUserInputHarness() {...@@ -89,10 +98,11 @@ function createPendingUserInputHarness() {
89 const manager = new CodexAppServerManager();98 const manager = new CodexAppServerManager();
90 const context = {99 const context = {
91 session: {100 session: {
92 sessionId: "sess_1",
93 provider: "codex",101 provider: "codex",
94 status: "ready",102 status: "ready",
95 threadId: "thread_1",103 threadId: "thread_1",
104 runtimeMode: "full-access",
105 model: "gpt-5.3-codex",
96 resumeCursor: { threadId: "thread_1" },106 resumeCursor: { threadId: "thread_1" },
97 createdAt: "2026-02-10T00:00:00.000Z",107 createdAt: "2026-02-10T00:00:00.000Z",
98 updatedAt: "2026-02-10T00:00:00.000Z",108 updatedAt: "2026-02-10T00:00:00.000Z",
...@@ -195,6 +205,70 @@ describe("isRecoverableThreadResumeError", () => {...@@ -195,6 +205,70 @@ describe("isRecoverableThreadResumeError", () => {
195 });205 });
196});206});
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
198describe("startSession", () => {272describe("startSession", () => {
199 it("enables Codex experimental api capabilities during initialize", () => {273 it("enables Codex experimental api capabilities during initialize", () => {
200 expect(buildCodexInitializeParams()).toEqual({274 expect(buildCodexInitializeParams()).toEqual({
...@@ -259,8 +333,8 @@ describe("sendTurn", () => {...@@ -259,8 +333,8 @@ describe("sendTurn", () => {
259 },333 },
260 ],334 ],
261 model: "gpt-5.3",335 model: "gpt-5.3",
262 effort: "high",
263 serviceTier: "fast",336 serviceTier: "fast",
337 effort: "high",
264 });338 });
265339
266 expect(result).toEqual({340 expect(result).toEqual({
...@@ -283,8 +357,8 @@ describe("sendTurn", () => {...@@ -283,8 +357,8 @@ describe("sendTurn", () => {
283 },357 },
284 ],358 ],
285 model: "gpt-5.3-codex",359 model: "gpt-5.3-codex",
286 effort: "high",
287 serviceTier: "fast",360 serviceTier: "fast",
361 effort: "high",
288 });362 });
289 expect(updateSession).toHaveBeenCalledWith(context, {363 expect(updateSession).toHaveBeenCalledWith(context, {
290 status: "running",364 status: "running",
...@@ -314,6 +388,7 @@ describe("sendTurn", () => {...@@ -314,6 +388,7 @@ describe("sendTurn", () => {
314 url: "data:image/png;base64,BBBB",388 url: "data:image/png;base64,BBBB",
315 },389 },
316 ],390 ],
391 model: "gpt-5.3-codex",
317 });392 });
318 });393 });
319394
...@@ -377,6 +452,37 @@ describe("sendTurn", () => {...@@ -377,6 +452,37 @@ describe("sendTurn", () => {
377 });452 });
378 });453 });
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
380 it("rejects empty turn input", async () => {486 it("rejects empty turn input", async () => {
381 const { manager } = createSendTurnHarness();487 const { manager } = createSendTurnHarness();
382488
apps/server/src/codexAppServerManager.ts+110-8
...@@ -58,6 +58,7 @@ interface CodexUserInputAnswer {...@@ -58,6 +58,7 @@ interface CodexUserInputAnswer {
5858
59interface CodexSessionContext {59interface CodexSessionContext {
60 session: ProviderSession;60 session: ProviderSession;
61 account: CodexAccountSnapshot;
61 child: ChildProcessWithoutNullStreams;62 child: ChildProcessWithoutNullStreams;
62 output: readline.Interface;63 output: readline.Interface;
63 pending: Map<PendingRequestKey, PendingRequest>;64 pending: Map<PendingRequestKey, PendingRequest>;
...@@ -89,13 +90,30 @@ interface JsonRpcNotification {...@@ -89,13 +90,30 @@ interface JsonRpcNotification {
89 params?: unknown;90 params?: unknown;
90}91}
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
92export interface CodexAppServerSendTurnInput {110export interface CodexAppServerSendTurnInput {
93 readonly threadId: ThreadId;111 readonly threadId: ThreadId;
94 readonly input?: string;112 readonly input?: string;
95 readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>;113 readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>;
96 readonly model?: string;114 readonly model?: string;
115 readonly serviceTier?: string | null;
97 readonly effort?: string;116 readonly effort?: string;
98 readonly serviceTier?: string;
99 readonly interactionMode?: ProviderInteractionMode;117 readonly interactionMode?: ProviderInteractionMode;
100}118}
101119
...@@ -135,6 +153,50 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [...@@ -135,6 +153,50 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [
135 "unknown thread",153 "unknown thread",
136 "does not exist",154 "does not exist",
137];155];
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
138export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Plan Mode (Conversational)200export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Plan Mode (Conversational)
139201
140You 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.202You 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): {...@@ -287,6 +349,17 @@ function mapCodexRuntimeMode(runtimeMode: RuntimeMode): {
287 };349 };
288}350}
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
290/**363/**
291 * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe`364 * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe`
292 * wrapper, leaving the actual command running. Use `taskkill /T` to kill the365 * wrapper, leaving the actual command running. Use `taskkill /T` to kill the
...@@ -475,6 +548,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -475,6 +548,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
475548
476 context = {549 context = {
477 session,550 session,
551 account: {
552 type: "unknown",
553 planType: null,
554 sparkEnabled: true,
555 },
478 child,556 child,
479 output,557 output,
480 pending: new Map(),558 pending: new Map(),
...@@ -492,12 +570,33 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -492,12 +570,33 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
492 await this.sendRequest(context, "initialize", buildCodexInitializeParams());570 await this.sendRequest(context, "initialize", buildCodexInitializeParams());
493571
494 this.writeMessage(context, { method: "initialized" });572 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 );
497 const sessionOverrides = {596 const sessionOverrides = {
498 model: normalizedModel ?? null,597 model: normalizedModel ?? null,
499 cwd: input.cwd ?? null,
500 ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}),598 ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}),
599 cwd: input.cwd ?? null,
501 ...mapCodexRuntimeMode(input.runtimeMode ?? "full-access"),600 ...mapCodexRuntimeMode(input.runtimeMode ?? "full-access"),
502 };601 };
503602
...@@ -657,8 +756,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -657,8 +756,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
657 { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string }756 { type: "text"; text: string; text_elements: [] } | { type: "image"; url: string }
658 >;757 >;
659 model?: string;758 model?: string;
759 serviceTier?: string | null;
660 effort?: string;760 effort?: string;
661 serviceTier?: string;
662 collaborationMode?: {761 collaborationMode?: {
663 mode: "default" | "plan";762 mode: "default" | "plan";
664 settings: {763 settings: {
...@@ -671,16 +770,19 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -671,16 +770,19 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
671 threadId: providerThreadId,770 threadId: providerThreadId,
672 input: turnInput,771 input: turnInput,
673 };772 };
674 const normalizedModel = normalizeCodexModelSlug(input.model ?? context.session.model);773 const normalizedModel = resolveCodexModelForAccount(
774 normalizeCodexModelSlug(input.model ?? context.session.model),
775 context.account,
776 );
675 if (normalizedModel) {777 if (normalizedModel) {
676 turnStartParams.model = normalizedModel;778 turnStartParams.model = normalizedModel;
677 }779 }
780 if (input.serviceTier !== undefined) {
781 turnStartParams.serviceTier = input.serviceTier;
782 }
678 if (input.effort) {783 if (input.effort) {
679 turnStartParams.effort = input.effort;784 turnStartParams.effort = input.effort;
680 }785 }
681 if (input.serviceTier) {
682 turnStartParams.serviceTier = input.serviceTier;
683 }
684 const collaborationMode = buildCodexCollaborationMode({786 const collaborationMode = buildCodexCollaborationMode({
685 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),787 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
686 ...(normalizedModel !== undefined ? { model: normalizedModel } : {}),788 ...(normalizedModel !== undefined ? { model: normalizedModel } : {}),
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+7
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 type OrchestrationEvent,5 type OrchestrationEvent,
6 type ProviderModelOptions,6 type ProviderModelOptions,
7 type ProviderKind,7 type ProviderKind,
8 type ProviderServiceTier,
8 type OrchestrationSession,9 type OrchestrationSession,
9 ThreadId,10 ThreadId,
10 type ProviderSession,11 type ProviderSession,
...@@ -201,6 +202,7 @@ const make = Effect.gen(function* () {...@@ -201,6 +202,7 @@ const make = Effect.gen(function* () {
201 readonly provider?: ProviderKind;202 readonly provider?: ProviderKind;
202 readonly model?: string;203 readonly model?: string;
203 readonly modelOptions?: ProviderModelOptions;204 readonly modelOptions?: ProviderModelOptions;
205 readonly serviceTier?: ProviderServiceTier | null;
204 },206 },
205 ) {207 ) {
206 const readModel = yield* orchestrationEngine.getReadModel();208 const readModel = yield* orchestrationEngine.getReadModel();
...@@ -235,6 +237,7 @@ const make = Effect.gen(function* () {...@@ -235,6 +237,7 @@ const make = Effect.gen(function* () {
235 : {}),237 : {}),
236 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),238 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
237 ...(desiredModel ? { model: desiredModel } : {}),239 ...(desiredModel ? { model: desiredModel } : {}),
240 ...(options?.serviceTier !== undefined ? { serviceTier: options.serviceTier } : {}),
238 ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}),241 ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}),
239 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),242 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
240 runtimeMode: desiredRuntimeMode,243 runtimeMode: desiredRuntimeMode,
...@@ -320,6 +323,7 @@ const make = Effect.gen(function* () {...@@ -320,6 +323,7 @@ const make = Effect.gen(function* () {
320 readonly attachments?: ReadonlyArray<ChatAttachment>;323 readonly attachments?: ReadonlyArray<ChatAttachment>;
321 readonly provider?: ProviderKind;324 readonly provider?: ProviderKind;
322 readonly model?: string;325 readonly model?: string;
326 readonly serviceTier?: ProviderServiceTier | null;
323 readonly modelOptions?: ProviderModelOptions;327 readonly modelOptions?: ProviderModelOptions;
324 readonly interactionMode?: "default" | "plan";328 readonly interactionMode?: "default" | "plan";
325 readonly createdAt: string;329 readonly createdAt: string;
...@@ -331,6 +335,7 @@ const make = Effect.gen(function* () {...@@ -331,6 +335,7 @@ const make = Effect.gen(function* () {
331 yield* ensureSessionForThread(input.threadId, input.createdAt, {335 yield* ensureSessionForThread(input.threadId, input.createdAt, {
332 ...(input.provider !== undefined ? { provider: input.provider } : {}),336 ...(input.provider !== undefined ? { provider: input.provider } : {}),
333 ...(input.model !== undefined ? { model: input.model } : {}),337 ...(input.model !== undefined ? { model: input.model } : {}),
338 ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}),
334 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),339 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
335 });340 });
336 const normalizedInput = toNonEmptyProviderInput(input.messageText);341 const normalizedInput = toNonEmptyProviderInput(input.messageText);
...@@ -350,6 +355,7 @@ const make = Effect.gen(function* () {...@@ -350,6 +355,7 @@ const make = Effect.gen(function* () {
350 ...(normalizedInput ? { input: normalizedInput } : {}),355 ...(normalizedInput ? { input: normalizedInput } : {}),
351 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),356 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
352 ...(modelForTurn !== undefined ? { model: modelForTurn } : {}),357 ...(modelForTurn !== undefined ? { model: modelForTurn } : {}),
358 ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}),
353 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),359 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
354 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),360 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
355 });361 });
...@@ -464,6 +470,7 @@ const make = Effect.gen(function* () {...@@ -464,6 +470,7 @@ const make = Effect.gen(function* () {
464 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),470 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
465 ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}),471 ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}),
466 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),472 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),
473 ...(event.payload.serviceTier !== undefined ? { serviceTier: event.payload.serviceTier } : {}),
467 ...(event.payload.modelOptions !== undefined ? { modelOptions: event.payload.modelOptions } : {}),474 ...(event.payload.modelOptions !== undefined ? { modelOptions: event.payload.modelOptions } : {}),
468 interactionMode: event.payload.interactionMode,475 interactionMode: event.payload.interactionMode,
469 createdAt: event.payload.createdAt,476 createdAt: event.payload.createdAt,
apps/server/src/orchestration/decider.ts+1
...@@ -301,6 +301,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -301,6 +301,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
301 messageId: command.message.messageId,301 messageId: command.message.messageId,
302 ...(command.provider !== undefined ? { provider: command.provider } : {}),302 ...(command.provider !== undefined ? { provider: command.provider } : {}),
303 ...(command.model !== undefined ? { model: command.model } : {}),303 ...(command.model !== undefined ? { model: command.model } : {}),
304 ...(command.serviceTier !== undefined ? { serviceTier: command.serviceTier } : {}),
304 ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}),305 ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}),
305 assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE,306 assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE,
306 runtimeMode:307 runtimeMode:
apps/server/src/provider/Layers/CodexAdapter.ts+1
...@@ -1354,6 +1354,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>...@@ -1354,6 +1354,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
1354 threadId: input.threadId,1354 threadId: input.threadId,
1355 ...(input.input !== undefined ? { input: input.input } : {}),1355 ...(input.input !== undefined ? { input: input.input } : {}),
1356 ...(input.model !== undefined ? { model: input.model } : {}),1356 ...(input.model !== undefined ? { model: input.model } : {}),
1357 ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}),
1357 ...(input.modelOptions?.codex?.reasoningEffort !== undefined1358 ...(input.modelOptions?.codex?.reasoningEffort !== undefined
1358 ? { effort: input.modelOptions.codex.reasoningEffort }1359 ? { effort: input.modelOptions.codex.reasoningEffort }
1359 : {}),1360 : {}),
apps/web/src/appSettings.test.ts+21
...@@ -4,6 +4,8 @@ import {...@@ -4,6 +4,8 @@ import {
4 getAppModelOptions,4 getAppModelOptions,
5 getSlashModelOptions,5 getSlashModelOptions,
6 normalizeCustomModelSlugs,6 normalizeCustomModelSlugs,
7 resolveAppServiceTier,
8 shouldShowFastTierIcon,
7 resolveAppModelSelection,9 resolveAppModelSelection,
8} from "./appSettings";10} from "./appSettings";
911
...@@ -82,3 +84,22 @@ describe("getSlashModelOptions", () => {...@@ -82,3 +84,22 @@ describe("getSlashModelOptions", () => {
82 expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]);84 expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]);
83 });85 });
84});86});
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 @@...@@ -1,11 +1,31 @@
1import { useCallback, useSyncExternalStore } from "react";1import { useCallback, useSyncExternalStore } from "react";
2import { Option, Schema } from "effect";2import { Option, Schema } from "effect";
3import { type ProviderKind } from "@t3tools/contracts";3import { type ProviderKind, type ProviderServiceTier } from "@t3tools/contracts";
4import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";4import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
55
6const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";6const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
7const MAX_CUSTOM_MODEL_COUNT = 32;7const MAX_CUSTOM_MODEL_COUNT = 32;
8export const MAX_CUSTOM_MODEL_LENGTH = 256;8export 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"]);
9const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {29const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {
10 codex: new Set(getModelOptions("codex").map((option) => option.slug)),30 codex: new Set(getModelOptions("codex").map((option) => option.slug)),
11};31};
...@@ -21,6 +41,7 @@ const AppSettingsSchema = Schema.Struct({...@@ -21,6 +41,7 @@ const AppSettingsSchema = Schema.Struct({
21 enableAssistantStreaming: Schema.Boolean.pipe(41 enableAssistantStreaming: Schema.Boolean.pipe(
22 Schema.withConstructorDefault(() => Option.some(false)),42 Schema.withConstructorDefault(() => Option.some(false)),
23 ),43 ),
44 codexServiceTier: AppServiceTierSchema.pipe(Schema.withConstructorDefault(() => Option.some("auto"))),
24 customCodexModels: Schema.Array(Schema.String).pipe(45 customCodexModels: Schema.Array(Schema.String).pipe(
25 Schema.withConstructorDefault(() => Option.some([])),46 Schema.withConstructorDefault(() => Option.some([])),
26 ),47 ),
...@@ -32,6 +53,22 @@ export interface AppModelOption {...@@ -32,6 +53,22 @@ export interface AppModelOption {
32 isCustom: boolean;53 isCustom: boolean;
33}54}
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
35const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});72const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
3673
37let listeners: Array<() => void> = [];74let listeners: Array<() => void> = [];
apps/web/src/components/ChatView.tsx+31-3
...@@ -140,6 +140,7 @@ import {...@@ -140,6 +140,7 @@ import {
140 XIcon,140 XIcon,
141 CopyIcon,141 CopyIcon,
142 CheckIcon,142 CheckIcon,
143 ZapIcon,
143} from "lucide-react";144} from "lucide-react";
144import { Button } from "./ui/button";145import { Button } from "./ui/button";
145import { Input } from "./ui/input";146import { Input } from "./ui/input";
...@@ -196,7 +197,14 @@ import { Toggle } from "./ui/toggle";...@@ -196,7 +197,14 @@ import { Toggle } from "./ui/toggle";
196import { SidebarTrigger } from "./ui/sidebar";197import { SidebarTrigger } from "./ui/sidebar";
197import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";198import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
198import { readNativeApi } from "~/nativeApi";199import { 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";
200import {208import {
201 type ComposerImageAttachment,209 type ComposerImageAttachment,
202 type DraftThreadEnvMode,210 type DraftThreadEnvMode,
...@@ -388,6 +396,7 @@ type ComposerCommandItem =...@@ -388,6 +396,7 @@ type ComposerCommandItem =
388 model: ModelSlug;396 model: ModelSlug;
389 label: string;397 label: string;
390 description: string;398 description: string;
399 showFastBadge: boolean;
391 };400 };
392401
393type SendPhase = "idle" | "preparing-worktree" | "sending-turn";402type SendPhase = "idle" | "preparing-worktree" | "sending-turn";
...@@ -497,7 +506,12 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {...@@ -497,7 +506,12 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
497 model506 model
498 </Badge>507 </Badge>
499 ) : null}508 ) : 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>
501 <span className="truncate text-muted-foreground/70 text-xs">{props.item.description}</span>515 <span className="truncate text-muted-foreground/70 text-xs">{props.item.description}</span>
502 </CommandItem>516 </CommandItem>
503 );517 );
...@@ -754,6 +768,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -754,6 +768,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
754 activeThread.messages.length > 0 ||768 activeThread.messages.length > 0 ||
755 activeThread.session !== null),769 activeThread.session !== null),
756 );770 );
771 const selectedServiceTierSetting = settings.codexServiceTier;
772 const selectedServiceTier = resolveAppServiceTier(selectedServiceTierSetting);
757 const lockedProvider: ProviderKind | null = hasThreadStarted773 const lockedProvider: ProviderKind | null = hasThreadStarted
758 ? (sessionProvider ?? selectedProviderByThreadId ?? null)774 ? (sessionProvider ?? selectedProviderByThreadId ?? null)
759 : null;775 : null;
...@@ -1205,8 +1221,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1205,8 +1221,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
1205 model: slug,1221 model: slug,
1206 label: name,1222 label: name,
1207 description: `${providerLabel} · ${slug}`,1223 description: `${providerLabel} · ${slug}`,
1224 showFastBadge:
1225 provider === "codex" && shouldShowFastTierIcon(slug, selectedServiceTierSetting),
1208 }));1226 }));
1209 }, [composerTrigger, searchableModelOptions, workspaceEntries]);1227 }, [composerTrigger, searchableModelOptions, selectedServiceTierSetting, workspaceEntries]);
1210 const composerMenuOpen = Boolean(composerTrigger);1228 const composerMenuOpen = Boolean(composerTrigger);
1211 const activeComposerMenuItem = useMemo(1229 const activeComposerMenuItem = useMemo(
1212 () =>1230 () =>
...@@ -2539,6 +2557,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2539,6 +2557,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2539 attachments: turnAttachments,2557 attachments: turnAttachments,
2540 },2558 },
2541 model: selectedModel || undefined,2559 model: selectedModel || undefined,
2560 serviceTier: selectedServiceTier,
2542 ...(selectedModelOptionsForDispatch2561 ...(selectedModelOptionsForDispatch
2543 ? { modelOptions: selectedModelOptionsForDispatch }2562 ? { modelOptions: selectedModelOptionsForDispatch }
2544 : {}),2563 : {}),
...@@ -3534,6 +3553,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3534,6 +3553,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
3534 model={selectedModelForPickerWithCustomFallback}3553 model={selectedModelForPickerWithCustomFallback}
3535 lockedProvider={lockedProvider}3554 lockedProvider={lockedProvider}
3536 modelOptionsByProvider={modelOptionsByProvider}3555 modelOptionsByProvider={modelOptionsByProvider}
3556 serviceTierSetting={selectedServiceTierSetting}
3537 onProviderModelChange={onProviderModelSelect}3557 onProviderModelChange={onProviderModelSelect}
3538 />3558 />
35393559
...@@ -5220,6 +5240,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -5220,6 +5240,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
5220 model: ModelSlug;5240 model: ModelSlug;
5221 lockedProvider: ProviderKind | null;5241 lockedProvider: ProviderKind | null;
5222 modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;5242 modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;
5243 serviceTierSetting: AppServiceTier;
5223 disabled?: boolean;5244 disabled?: boolean;
5224 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;5245 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
5225}) {5246}) {
...@@ -5252,6 +5273,9 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -5252,6 +5273,9 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
5252 >5273 >
5253 <span className="flex min-w-0 items-center gap-2">5274 <span className="flex min-w-0 items-center gap-2">
5254 <ProviderIcon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground/70" />5275 <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}
5255 <span className="truncate">{selectedModelLabel}</span>5279 <span className="truncate">{selectedModelLabel}</span>
5256 <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" />5280 <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" />
5257 </span>5281 </span>
...@@ -5294,6 +5318,10 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -5294,6 +5318,10 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
5294 value={modelOption.slug}5318 value={modelOption.slug}
5295 onClick={() => setIsMenuOpen(false)}5319 onClick={() => setIsMenuOpen(false)}
5296 >5320 >
5321 {option.value === "codex" &&
5322 shouldShowFastTierIcon(modelOption.slug, props.serviceTierSetting) ? (
5323 <ZapIcon className="size-3.5 shrink-0 text-amber-500" />
5324 ) : null}
5297 {modelOption.name}5325 {modelOption.name}
5298 </MenuRadioItem>5326 </MenuRadioItem>
5299 ))}5327 ))}
apps/web/src/routes/_chat.settings.tsx+55-5
...@@ -3,8 +3,14 @@ import { useQuery } from "@tanstack/react-query";...@@ -3,8 +3,14 @@ import { useQuery } from "@tanstack/react-query";
3import { useCallback, useState } from "react";3import { useCallback, useState } from "react";
4import { type ProviderKind } from "@t3tools/contracts";4import { type ProviderKind } from "@t3tools/contracts";
5import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";5import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
66import { ZapIcon } from "lucide-react";
7import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings";7
8import {
9 APP_SERVICE_TIER_OPTIONS,
10 MAX_CUSTOM_MODEL_LENGTH,
11 shouldShowFastTierIcon,
12 useAppSettings,
13} from "../appSettings";
8import { isElectron } from "../env";14import { isElectron } from "../env";
9import { useTheme } from "../hooks/useTheme";15import { useTheme } from "../hooks/useTheme";
10import { serverConfigQueryOptions } from "../lib/serverReactQuery";16import { serverConfigQueryOptions } from "../lib/serverReactQuery";
...@@ -12,6 +18,7 @@ import { ensureNativeApi } from "../nativeApi";...@@ -12,6 +18,7 @@ import { ensureNativeApi } from "../nativeApi";
12import { preferredTerminalEditor } from "../terminal-links";18import { preferredTerminalEditor } from "../terminal-links";
13import { Button } from "../components/ui/button";19import { Button } from "../components/ui/button";
14import { Input } from "../components/ui/input";20import { Input } from "../components/ui/input";
21import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../components/ui/select";
15import { Switch } from "../components/ui/switch";22import { Switch } from "../components/ui/switch";
16import { SidebarInset } from "~/components/ui/sidebar";23import { SidebarInset } from "~/components/ui/sidebar";
1724
...@@ -96,6 +103,7 @@ function SettingsRouteView() {...@@ -96,6 +103,7 @@ function SettingsRouteView() {
96103
97 const codexBinaryPath = settings.codexBinaryPath;104 const codexBinaryPath = settings.codexBinaryPath;
98 const codexHomePath = settings.codexHomePath;105 const codexHomePath = settings.codexHomePath;
106 const codexServiceTier = settings.codexServiceTier;
99 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;107 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
100108
101 const openKeybindingsFile = useCallback(() => {109 const openKeybindingsFile = useCallback(() => {
...@@ -302,6 +310,43 @@ function SettingsRouteView() {...@@ -302,6 +310,43 @@ function SettingsRouteView() {
302 </div>310 </div>
303311
304 <div className="space-y-5">312 <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
305 {MODEL_PROVIDER_SETTINGS.map((providerSettings) => {350 {MODEL_PROVIDER_SETTINGS.map((providerSettings) => {
306 const provider = providerSettings.provider;351 const provider = providerSettings.provider;
307 const customModels = getCustomModelsForProvider(settings, provider);352 const customModels = getCustomModelsForProvider(settings, provider);
...@@ -400,9 +445,14 @@ function SettingsRouteView() {...@@ -400,9 +445,14 @@ function SettingsRouteView() {
400 key={`${provider}:${slug}`}445 key={`${provider}:${slug}`}
401 className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background px-3 py-2"446 className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background px-3 py-2"
402 >447 >
403 <code className="min-w-0 flex-1 truncate text-xs text-foreground">448 <div className="flex min-w-0 flex-1 items-center gap-2">
404 {slug}449 {provider === "codex" && shouldShowFastTierIcon(slug, codexServiceTier) ? (
405 </code>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>
406 <Button456 <Button
407 size="xs"457 size="xs"
408 variant="ghost"458 variant="ghost"
packages/contracts/src/orchestration.ts+18
...@@ -29,6 +29,21 @@ export const ORCHESTRATION_WS_CHANNELS = {...@@ -29,6 +29,21 @@ export const ORCHESTRATION_WS_CHANNELS = {
2929
30export const ProviderKind = Schema.Literal("codex");30export const ProviderKind = Schema.Literal("codex");
31export type ProviderKind = typeof ProviderKind.Type;31export 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;
32export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";47export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";
33export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]);48export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]);
34export type RuntimeMode = typeof RuntimeMode.Type;49export type RuntimeMode = typeof RuntimeMode.Type;
...@@ -349,6 +364,7 @@ export const ThreadTurnStartCommand = Schema.Struct({...@@ -349,6 +364,7 @@ export const ThreadTurnStartCommand = Schema.Struct({
349 }),364 }),
350 provider: Schema.optional(ProviderKind),365 provider: Schema.optional(ProviderKind),
351 model: Schema.optional(TrimmedNonEmptyString),366 model: Schema.optional(TrimmedNonEmptyString),
367 serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)),
352 modelOptions: Schema.optional(ProviderModelOptions),368 modelOptions: Schema.optional(ProviderModelOptions),
353 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),369 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
354 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),370 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
...@@ -370,6 +386,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({...@@ -370,6 +386,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({
370 }),386 }),
371 provider: Schema.optional(ProviderKind),387 provider: Schema.optional(ProviderKind),
372 model: Schema.optional(TrimmedNonEmptyString),388 model: Schema.optional(TrimmedNonEmptyString),
389 serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)),
373 modelOptions: Schema.optional(ProviderModelOptions),390 modelOptions: Schema.optional(ProviderModelOptions),
374 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),391 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
375 runtimeMode: RuntimeMode,392 runtimeMode: RuntimeMode,
...@@ -649,6 +666,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({...@@ -649,6 +666,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({
649 messageId: MessageId,666 messageId: MessageId,
650 provider: Schema.optional(ProviderKind),667 provider: Schema.optional(ProviderKind),
651 model: Schema.optional(TrimmedNonEmptyString),668 model: Schema.optional(TrimmedNonEmptyString),
669 serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)),
652 modelOptions: Schema.optional(ProviderModelOptions),670 modelOptions: Schema.optional(ProviderModelOptions),
653 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),671 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
654 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),672 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
packages/contracts/src/provider.ts+7
...@@ -14,9 +14,12 @@ import {...@@ -14,9 +14,12 @@ import {
14 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,14 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
15 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,15 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
16 ProviderApprovalDecision,16 ProviderApprovalDecision,
17 ProviderApprovalPolicy,
17 ProviderInteractionMode,18 ProviderInteractionMode,
18 ProviderKind,19 ProviderKind,
19 ProviderRequestKind,20 ProviderRequestKind,
21 ProviderSandboxMode,
22 ProviderServiceTier,
20 ProviderUserInputAnswers,23 ProviderUserInputAnswers,
21 RuntimeMode,24 RuntimeMode,
22} from "./orchestration";25} from "./orchestration";
...@@ -61,6 +64,9 @@ export const ProviderSessionStartInput = Schema.Struct({...@@ -61,6 +64,9 @@ export const ProviderSessionStartInput = Schema.Struct({
61 model: Schema.optional(TrimmedNonEmptyStringSchema),64 model: Schema.optional(TrimmedNonEmptyStringSchema),
62 modelOptions: Schema.optional(ProviderModelOptions),65 modelOptions: Schema.optional(ProviderModelOptions),
63 resumeCursor: Schema.optional(Schema.Unknown),66 resumeCursor: Schema.optional(Schema.Unknown),
67 serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)),
68 approvalPolicy: Schema.optional(ProviderApprovalPolicy),
69 sandboxMode: Schema.optional(ProviderSandboxMode),
64 providerOptions: Schema.optional(ProviderStartOptions),70 providerOptions: Schema.optional(ProviderStartOptions),
65 runtimeMode: RuntimeMode,71 runtimeMode: RuntimeMode,
66});72});
...@@ -75,6 +81,7 @@ export const ProviderSendTurnInput = Schema.Struct({...@@ -75,6 +81,7 @@ export const ProviderSendTurnInput = Schema.Struct({
75 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),81 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
76 ),82 ),
77 model: Schema.optional(TrimmedNonEmptyStringSchema),83 model: Schema.optional(TrimmedNonEmptyStringSchema),
84 serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)),
78 modelOptions: Schema.optional(ProviderModelOptions),85 modelOptions: Schema.optional(ProviderModelOptions),
79 interactionMode: Schema.optional(ProviderInteractionMode),86 interactionMode: Schema.optional(ProviderInteractionMode),
80});87});