| author | |
| committer | |
| log | 40fcb6bdf8d726f35c6d3aec82d0cce6345c9dcd |
| tree | 7a92e1d9f889c6beb5b0710074fb991995deaafa |
| parent | d58ff5de26b9753c3dbd13cb55e532ccd44ba0d2 |
| signature | Signed by PGP key B5690EEEBB952194 |
* 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"; |
| 17 | 19 | ||
| 18 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | 20 | const 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 | }; |
| 33 | 41 | ||
| 34 | const requireSession = vi | 42 | 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 | }); |
| 197 | 207 | ||
| 208 | describe("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 | |||
| 250 | describe("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 | |||
| 198 | describe("startSession", () => { | 272 | describe("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 | }); |
| 265 | 339 | ||
| 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 | }); |
| 319 | 394 | ||
| ... | @@ -377,6 +452,37 @@ describe("sendTurn", () => { | ... | @@ -377,6 +452,37 @@ describe("sendTurn", () => { |
| 377 | }); | 452 | }); |
| 378 | }); | 453 | }); |
| 379 | 454 | ||
| 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(); |
| 382 | 488 |
apps/server/src/codexAppServerManager.ts+110-8| ... | @@ -58,6 +58,7 @@ interface CodexUserInputAnswer { | ... | @@ -58,6 +58,7 @@ interface CodexUserInputAnswer { |
| 58 | 58 | ||
| 59 | interface CodexSessionContext { | 59 | interface 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 | } |
| 91 | 92 | ||
| 93 | type CodexPlanType = | ||
| 94 | | "free" | ||
| 95 | | "go" | ||
| 96 | | "plus" | ||
| 97 | | "pro" | ||
| 98 | | "team" | ||
| 99 | | "business" | ||
| 100 | | "enterprise" | ||
| 101 | | "edu" | ||
| 102 | | "unknown"; | ||
| 103 | |||
| 104 | interface CodexAccountSnapshot { | ||
| 105 | readonly type: "apiKey" | "chatgpt" | "unknown"; | ||
| 106 | readonly planType: CodexPlanType | null; | ||
| 107 | readonly sparkEnabled: boolean; | ||
| 108 | } | ||
| 109 | |||
| 92 | export interface CodexAppServerSendTurnInput { | 110 | export 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 | } |
| 101 | 119 | ||
| ... | @@ -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 | ]; |
| 156 | const CODEX_DEFAULT_MODEL = "gpt-5.3-codex"; | ||
| 157 | const CODEX_SPARK_MODEL = "gpt-5.3-codex-spark"; | ||
| 158 | const CODEX_SPARK_DISABLED_PLAN_TYPES = new Set<CodexPlanType>(["free", "go", "plus"]); | ||
| 159 | |||
| 160 | function 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 | |||
| 167 | function asString(value: unknown): string | undefined { | ||
| 168 | return typeof value === "string" ? value : undefined; | ||
| 169 | } | ||
| 170 | |||
| 171 | export 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 | |||
| 138 | export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Plan Mode (Conversational) | 200 | export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Plan Mode (Conversational) |
| 139 | 201 | ||
| 140 | 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. | 202 | 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): { | ... | @@ -287,6 +349,17 @@ function mapCodexRuntimeMode(runtimeMode: RuntimeMode): { |
| 287 | }; | 349 | }; |
| 288 | } | 350 | } |
| 289 | 351 | ||
| 352 | export 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 the | 365 | * 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 |
| 475 | 548 | ||
| 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()); |
| 493 | 571 | ||
| 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 | } | ||
| 495 | 591 | ||
| 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 | }; |
| 503 | 602 | ||
| ... | @@ -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 !== undefined | 1358 | ...(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"; |
| 9 | 11 | ||
| ... | @@ -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 | |||
| 88 | describe("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 | |||
| 99 | describe("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 @@ |
| 1 | import { useCallback, useSyncExternalStore } from "react"; | 1 | import { useCallback, useSyncExternalStore } from "react"; |
| 2 | import { Option, Schema } from "effect"; | 2 | import { Option, Schema } from "effect"; |
| 3 | import { type ProviderKind } from "@t3tools/contracts"; | 3 | import { type ProviderKind, type ProviderServiceTier } from "@t3tools/contracts"; |
| 4 | import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; | 4 | import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; |
| 5 | 5 | ||
| 6 | const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; | 6 | const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; |
| 7 | const MAX_CUSTOM_MODEL_COUNT = 32; | 7 | const MAX_CUSTOM_MODEL_COUNT = 32; |
| 8 | export const MAX_CUSTOM_MODEL_LENGTH = 256; | 8 | export const MAX_CUSTOM_MODEL_LENGTH = 256; |
| 9 | export 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; | ||
| 26 | export type AppServiceTier = (typeof APP_SERVICE_TIER_OPTIONS)[number]["value"]; | ||
| 27 | const AppServiceTierSchema = Schema.Literals(["auto", "fast", "flex"]); | ||
| 28 | const MODELS_WITH_FAST_SUPPORT = new Set(["gpt-5.4"]); | ||
| 9 | const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = { | 29 | const 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 | } |
| 34 | 55 | ||
| 56 | export function resolveAppServiceTier(serviceTier: AppServiceTier): ProviderServiceTier | null { | ||
| 57 | return serviceTier === "auto" ? null : serviceTier; | ||
| 58 | } | ||
| 59 | |||
| 60 | export 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 | |||
| 35 | const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); | 72 | const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); |
| 36 | 73 | ||
| 37 | let listeners: Array<() => void> = []; | 74 | let 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"; |
| 144 | import { Button } from "./ui/button"; | 145 | import { Button } from "./ui/button"; |
| 145 | import { Input } from "./ui/input"; | 146 | import { Input } from "./ui/input"; |
| ... | @@ -196,7 +197,14 @@ import { Toggle } from "./ui/toggle"; | ... | @@ -196,7 +197,14 @@ import { Toggle } from "./ui/toggle"; |
| 196 | import { SidebarTrigger } from "./ui/sidebar"; | 197 | import { SidebarTrigger } from "./ui/sidebar"; |
| 197 | import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; | 198 | import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; |
| 198 | import { readNativeApi } from "~/nativeApi"; | 199 | import { readNativeApi } from "~/nativeApi"; |
| 199 | import { getAppModelOptions, resolveAppModelSelection, useAppSettings } from "../appSettings"; | 200 | import { |
| 201 | getAppModelOptions, | ||
| 202 | resolveAppModelSelection, | ||
| 203 | resolveAppServiceTier, | ||
| 204 | shouldShowFastTierIcon, | ||
| 205 | type AppServiceTier, | ||
| 206 | useAppSettings, | ||
| 207 | } from "../appSettings"; | ||
| 200 | import { | 208 | import { |
| 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 | }; |
| 392 | 401 | ||
| 393 | type SendPhase = "idle" | "preparing-worktree" | "sending-turn"; | 402 | type 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 | model | 506 | 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 = hasThreadStarted | 773 | 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 | ...(selectedModelOptionsForDispatch | 2561 | ...(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 | /> |
| 3539 | 3559 | ||
| ... | @@ -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"; |
| 3 | import { useCallback, useState } from "react"; | 3 | import { useCallback, useState } from "react"; |
| 4 | import { type ProviderKind } from "@t3tools/contracts"; | 4 | import { type ProviderKind } from "@t3tools/contracts"; |
| 5 | import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; | 5 | import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; |
| 6 | 6 | import { ZapIcon } from "lucide-react"; | |
| 7 | import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; | 7 | |
| 8 | import { | ||
| 9 | APP_SERVICE_TIER_OPTIONS, | ||
| 10 | MAX_CUSTOM_MODEL_LENGTH, | ||
| 11 | shouldShowFastTierIcon, | ||
| 12 | useAppSettings, | ||
| 13 | } from "../appSettings"; | ||
| 8 | import { isElectron } from "../env"; | 14 | import { isElectron } from "../env"; |
| 9 | import { useTheme } from "../hooks/useTheme"; | 15 | import { useTheme } from "../hooks/useTheme"; |
| 10 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | 16 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| ... | @@ -12,6 +18,7 @@ import { ensureNativeApi } from "../nativeApi"; | ... | @@ -12,6 +18,7 @@ import { ensureNativeApi } from "../nativeApi"; |
| 12 | import { preferredTerminalEditor } from "../terminal-links"; | 18 | import { preferredTerminalEditor } from "../terminal-links"; |
| 13 | import { Button } from "../components/ui/button"; | 19 | import { Button } from "../components/ui/button"; |
| 14 | import { Input } from "../components/ui/input"; | 20 | import { Input } from "../components/ui/input"; |
| 21 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../components/ui/select"; | ||
| 15 | import { Switch } from "../components/ui/switch"; | 22 | import { Switch } from "../components/ui/switch"; |
| 16 | import { SidebarInset } from "~/components/ui/sidebar"; | 23 | import { SidebarInset } from "~/components/ui/sidebar"; |
| 17 | 24 | ||
| ... | @@ -96,6 +103,7 @@ function SettingsRouteView() { | ... | @@ -96,6 +103,7 @@ function SettingsRouteView() { |
| 96 | 103 | ||
| 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; |
| 100 | 108 | ||
| 101 | const openKeybindingsFile = useCallback(() => { | 109 | const openKeybindingsFile = useCallback(() => { |
| ... | @@ -302,6 +310,43 @@ function SettingsRouteView() { | ... | @@ -302,6 +310,43 @@ function SettingsRouteView() { |
| 302 | </div> | 310 | </div> |
| 303 | 311 | ||
| 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 | <Button | 456 | <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 = { |
| 29 | 29 | ||
| 30 | export const ProviderKind = Schema.Literal("codex"); | 30 | export const ProviderKind = Schema.Literal("codex"); |
| 31 | export type ProviderKind = typeof ProviderKind.Type; | 31 | export type ProviderKind = typeof ProviderKind.Type; |
| 32 | export const ProviderApprovalPolicy = Schema.Literals([ | ||
| 33 | "untrusted", | ||
| 34 | "on-failure", | ||
| 35 | "on-request", | ||
| 36 | "never", | ||
| 37 | ]); | ||
| 38 | export type ProviderApprovalPolicy = typeof ProviderApprovalPolicy.Type; | ||
| 39 | export const ProviderSandboxMode = Schema.Literals([ | ||
| 40 | "read-only", | ||
| 41 | "workspace-write", | ||
| 42 | "danger-full-access", | ||
| 43 | ]); | ||
| 44 | export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; | ||
| 45 | export const ProviderServiceTier = Schema.Literals(["fast", "flex"]); | ||
| 46 | export type ProviderServiceTier = typeof ProviderServiceTier.Type; | ||
| 32 | export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; | 47 | export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; |
| 33 | export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); | 48 | export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); |
| 34 | export type RuntimeMode = typeof RuntimeMode.Type; | 49 | export 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 | }); |