History

Unify provider model selection across orchestration and UI

- Replace provider-specific model options with typed modelSelection - Persist and replay selected provider, model, and options end to end - Update adapter, service, and chat composer tests for the new shape

Julius Marminge committed 7 months ago

Commit 96d61601 parent 97ff0ac16 files · +287 −320Browse files

Unified · Split

16 files
  1. apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +44 −30
  2. apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +24 −32
  3. apps/server/src/provider/Layers/ClaudeAdapter.test.ts +52 −37
  4. apps/server/src/provider/Layers/ClaudeAdapter.ts +29 −15
  5. apps/server/src/provider/Layers/CodexAdapter.test.ts +8 −6
  6. apps/server/src/provider/Layers/CodexAdapter.ts +16 −6
  7. apps/server/src/provider/Layers/ProviderService.test.ts +9 −5
  8. apps/server/src/provider/Layers/ProviderService.ts +16 −13
  9. apps/web/src/components/ChatView.tsx +3 −19
  10. apps/web/src/components/chat/composerProviderRegistry.test.tsx +9 −61
  11. apps/web/src/components/chat/composerProviderRegistry.tsx +27 −13
  12. packages/contracts/src/orchestration.ts +1 −1
  13. packages/contracts/src/provider.test.ts +46 −24
  14. packages/contracts/src/provider.ts +3 −5
  15. packages/shared/src/model.test.ts +0 −40
  16. packages/shared/src/model.ts +0 −13
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+44−30
@@ -313,7 +313,10 @@ describe("ProviderCommandReactor", () => { · 312 unmodified lines
313313 expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.makeUnsafe("thread-1"));
314314 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
315315 cwd: "/tmp/provider-project",
316− model: "gpt-5-codex",
316+ modelSelection: {
317+ provider: "codex",
318+ model: "gpt-5-codex",
319+ },
317320 runtimeMode: "approval-required",
318321 });
319322
@@ -355,9 +358,10 @@ describe("ProviderCommandReactor", () => { · 35 unmodified lines
355358 await waitFor(() => harness.startSession.mock.calls.length === 1);
356359 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
357360 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
358− model: "gpt-5.3-codex",
359− modelOptions: {
360− codex: {
361+ modelSelection: {
362+ provider: "codex",
363+ model: "gpt-5.3-codex",
364+ options: {
361365 reasoningEffort: "high",
362366 fastMode: true,
363367 },
@@ -365,9 +369,10 @@ describe("ProviderCommandReactor", () => { · 1 unmodified line
365369 });
366370 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
367371 threadId: ThreadId.makeUnsafe("thread-1"),
368− model: "gpt-5.3-codex",
369− modelOptions: {
370− codex: {
372+ modelSelection: {
373+ provider: "codex",
374+ model: "gpt-5.3-codex",
375+ options: {
371376 reasoningEffort: "high",
372377 fastMode: true,
373378 },
@@ -406,19 +411,20 @@ describe("ProviderCommandReactor", () => { · 32 unmodified lines
406411 await waitFor(() => harness.startSession.mock.calls.length === 1);
407412 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
408413 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
409− provider: "claudeAgent",
410− model: "claude-sonnet-4-6",
411− modelOptions: {
412− claudeAgent: {
414+ modelSelection: {
415+ provider: "claudeAgent",
416+ model: "claude-sonnet-4-6",
417+ options: {
413418 effort: "max",
414419 },
415420 },
416421 });
417422 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
418423 threadId: ThreadId.makeUnsafe("thread-1"),
419− model: "claude-sonnet-4-6",
420− modelOptions: {
421− claudeAgent: {
424+ modelSelection: {
425+ provider: "claudeAgent",
426+ model: "claude-sonnet-4-6",
427+ options: {
422428 effort: "max",
423429 },
424430 },
@@ -456,19 +462,20 @@ describe("ProviderCommandReactor", () => { · 31 unmodified lines
456462 await waitFor(() => harness.startSession.mock.calls.length === 1);
457463 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
458464 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
459− provider: "claudeAgent",
460− model: "claude-opus-4-6",
461− modelOptions: {
462− claudeAgent: {
465+ modelSelection: {
466+ provider: "claudeAgent",
467+ model: "claude-opus-4-6",
468+ options: {
463469 fastMode: true,
464470 },
465471 },
466472 });
467473 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
468474 threadId: ThreadId.makeUnsafe("thread-1"),
469− model: "claude-opus-4-6",
470− modelOptions: {
471− claudeAgent: {
475+ modelSelection: {
476+ provider: "claudeAgent",
477+ model: "claude-opus-4-6",
478+ options: {
472479 fastMode: true,
473480 },
474481 },
@@ -594,12 +601,17 @@ describe("ProviderCommandReactor", () => { · 119 unmodified lines
594601 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
595602
596603 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
597− provider: "codex",
598− model: "claude-sonnet-4-6",
604+ modelSelection: {
605+ provider: "codex",
606+ model: "claude-sonnet-4-6",
607+ },
599608 });
600609 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
601610 threadId: ThreadId.makeUnsafe("thread-1"),
602− model: "claude-sonnet-4-6",
611+ modelSelection: {
612+ provider: "codex",
613+ model: "claude-sonnet-4-6",
614+ },
603615 });
604616 });
605617
@@ -707,10 +719,11 @@ describe("ProviderCommandReactor", () => { · 101 unmodified lines
707719 await waitFor(() => harness.startSession.mock.calls.length === 2);
708720 await waitFor(() => harness.sendTurn.mock.calls.length === 2);
709721 expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
710− provider: "claudeAgent",
711722 resumeCursor: { opaque: "resume-1" },
712− modelOptions: {
713− claudeAgent: {
723+ modelSelection: {
724+ provider: "claudeAgent",
725+ model: "claude-sonnet-4-6",
726+ options: {
714727 effort: "max",
715728 },
716729 },
@@ -841,11 +854,12 @@ describe("ProviderCommandReactor", () => { · 124 unmodified lines
841854 await waitFor(() => harness.startSession.mock.calls.length === 1);
842855
843856 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
844− provider: "claudeAgent",
845− model: "claude-opus-4-6",
857+ modelSelection: {
858+ provider: "claudeAgent",
859+ model: "claude-opus-4-6",
860+ },
846861 runtimeMode: "approval-required",
847862 });
848− expect(harness.startSession.mock.calls[0]?.[1]).not.toHaveProperty("modelOptions");
849863 });
850864
851865 it("rejects provider changes after a thread is already bound to a session provider", async () => {
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+24−32
@@ -5,7 +5,6 @@ import { · 4 unmodified lines
55 EventId,
66 type ModelSelection,
77 type OrchestrationEvent,
8− type ProviderModelOptions,
98 ProviderKind,
109 type ProviderStartOptions,
1110 type OrchestrationSession,
@@ -16,7 +15,6 @@ import { · 4 unmodified lines
1615 } from "@t3tools/contracts";
1716 import { Cache, Cause, Duration, Effect, Layer, Option, Schema, Stream } from "effect";
1817 import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
19−import { toProviderModelOptions } from "@t3tools/shared/model";
2018
2119 import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
2220 import { GitCore } from "../../git/Services/GitCore.ts";
@@ -77,9 +75,9 @@ const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; · 54 unmodified lines
7775 const WORKTREE_BRANCH_PREFIX = "t3code";
7876 const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`);
7977
80−const sameModelOptions = (
81− left: ProviderModelOptions | undefined,
82− right: ProviderModelOptions | undefined,
78+const sameModelSelectionOptions = (
79+ left: ModelSelection | undefined,
80+ right: ModelSelection | undefined,
8381 ): boolean => JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
8482
8583 function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {
@@ -159,7 +157,7 @@ const make = Effect.gen(function* () { · 73 unmodified lines
159157 );
160158
161159 const threadProviderOptions = new Map<string, ProviderStartOptions>();
162− const threadModelOptions = new Map<string, ProviderModelOptions>();
160+ const threadModelSelections = new Map<string, ModelSelection>();
163161
164162 const appendProviderFailureActivity = (input: {
165163 readonly threadId: ThreadId;
@@ -217,7 +215,6 @@ const make = Effect.gen(function* () { · 51 unmodified lines
217215 createdAt: string,
218216 options?: {
219217 readonly modelSelection?: ModelSelection;
220− readonly modelOptions?: ProviderModelOptions;
221218 readonly providerOptions?: ProviderStartOptions;
222219 },
223220 ) {
@@ -247,9 +244,6 @@ const make = Effect.gen(function* () { · 23 unmodified lines
247244 }
248245 const preferredProvider: ProviderKind = currentProvider ?? threadProvider;
249246 const desiredModelSelection = requestedModelSelection ?? thread.modelSelection;
250− const desiredModel = desiredModelSelection.model;
251− const desiredModelOptions =
252− options?.modelOptions ?? toProviderModelOptions(desiredModelSelection);
253247 const effectiveCwd = resolveThreadWorkspaceCwd({
254248 thread,
255249 projects: readModel.projects,
@@ -268,8 +262,7 @@ const make = Effect.gen(function* () { · 12 unmodified lines
268262 threadId,
269263 ...(preferredProvider ? { provider: preferredProvider } : {}),
270264 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
271− ...(desiredModel ? { model: desiredModel } : {}),
272− ...(desiredModelOptions !== undefined ? { modelOptions: desiredModelOptions } : {}),
265+ modelSelection: desiredModelSelection,
273266 ...(options?.providerOptions !== undefined
274267 ? { providerOptions: options.providerOptions }
275268 : {}),
@@ -309,17 +302,17 @@ const make = Effect.gen(function* () { · 33 unmodified lines
309302 requestedModelSelection !== undefined &&
310303 requestedModelSelection.model !== activeSession?.model;
311304 const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session";
312− const previousModelOptions = threadModelOptions.get(threadId);
313− const shouldRestartForModelOptionsChange =
305+ const previousModelSelection = threadModelSelections.get(threadId);
306+ const shouldRestartForModelSelectionChange =
314307 currentProvider === "claudeAgent" &&
315− options?.modelOptions !== undefined &&
316− !sameModelOptions(previousModelOptions, options.modelOptions);
308+ requestedModelSelection !== undefined &&
309+ !sameModelSelectionOptions(previousModelSelection, requestedModelSelection);
317310
318311 if (
319312 !runtimeModeChanged &&
320313 !providerChanged &&
321314 !shouldRestartForModelChange &&
322− !shouldRestartForModelOptionsChange
315+ !shouldRestartForModelSelectionChange
323316 ) {
324317 return existingSessionThreadId;
325318 }
@@ -339,7 +332,7 @@ const make = Effect.gen(function* () { · 13 unmodified lines
339332 providerChanged,
340333 modelChanged,
341334 shouldRestartForModelChange,
342− shouldRestartForModelOptionsChange,
335+ shouldRestartForModelSelectionChange,
343336 hasResumeCursor: resumeCursor !== undefined,
344337 });
345338 const restartedSession = yield* startProviderSession(
@@ -366,7 +359,6 @@ const make = Effect.gen(function* () { · 20 unmodified lines
366359 readonly messageText: string;
367360 readonly attachments?: ReadonlyArray<ChatAttachment>;
368361 readonly modelSelection?: ModelSelection;
369− readonly modelOptions?: ProviderModelOptions;
370362 readonly providerOptions?: ProviderStartOptions;
371363 readonly interactionMode?: "default" | "plan";
372364 readonly createdAt: string;
@@ -377,14 +369,13 @@ const make = Effect.gen(function* () { · 4 unmodified lines
377369 }
378370 yield* ensureSessionForThread(input.threadId, input.createdAt, {
379371 ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}),
380− ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
381372 ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),
382373 });
383374 if (input.providerOptions !== undefined) {
384375 threadProviderOptions.set(input.threadId, input.providerOptions);
385376 }
386− if (input.modelOptions !== undefined) {
387− threadModelOptions.set(input.threadId, input.modelOptions);
377+ if (input.modelSelection !== undefined) {
378+ threadModelSelections.set(input.threadId, input.modelSelection);
388379 }
389380 const normalizedInput = toNonEmptyProviderInput(input.messageText);
390381 const normalizedAttachments = input.attachments ?? [];
@@ -398,14 +389,20 @@ const make = Effect.gen(function* () { · 7 unmodified lines
398389 ? "in-session"
399390 : (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch;
400391 const modelForTurn =
401− sessionModelSwitch === "unsupported" ? activeSession?.model : input.modelSelection?.model;
392+ sessionModelSwitch === "unsupported"
393+ ? input.modelSelection
394+ ? {
395+ ...input.modelSelection,
396+ model: activeSession?.model ?? input.modelSelection.model,
397+ }
398+ : undefined
399+ : input.modelSelection;
402400
403401 yield* providerService.sendTurn({
404402 threadId: input.threadId,
405403 ...(normalizedInput ? { input: normalizedInput } : {}),
406404 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
407− ...(modelForTurn !== undefined ? { model: modelForTurn } : {}),
408− ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
405+ ...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}),
409406 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
410407 });
411408 });
@@ -514,10 +511,6 @@ const make = Effect.gen(function* () { · 102 unmodified lines
514511 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
515512 }).pipe(Effect.forkScoped);
516513
517− const requestedModelOptions = event.payload.modelSelection
518− ? toProviderModelOptions(event.payload.modelSelection)
519− : undefined;
520−
521514 yield* sendTurnForThread({
522515 threadId: event.payload.threadId,
523516 messageText: message.text,
@@ -525,7 +518,6 @@ const make = Effect.gen(function* () { · 1 unmodified line
525518 ...(event.payload.modelSelection !== undefined
526519 ? { modelSelection: event.payload.modelSelection }
527520 : {}),
528− ...(requestedModelOptions !== undefined ? { modelOptions: requestedModelOptions } : {}),
529521 ...(event.payload.providerOptions !== undefined
530522 ? { providerOptions: event.payload.providerOptions }
531523 : {}),
@@ -695,12 +687,12 @@ const make = Effect.gen(function* () { · 163 unmodified lines
695687 return;
696688 }
697689 const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId);
698− const cachedModelOptions = threadModelOptions.get(event.payload.threadId);
690+ const cachedModelSelection = threadModelSelections.get(event.payload.threadId);
699691 yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, {
700692 ...(cachedProviderOptions !== undefined
701693 ? { providerOptions: cachedProviderOptions }
702694 : {}),
703− ...(cachedModelOptions !== undefined ? { modelOptions: cachedModelOptions } : {}),
695+ ...(cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {}),
704696 });
705697 return;
706698 }
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+52−37
@@ -332,13 +332,14 @@ describe("ClaudeAdapterLive", () => { · 331 unmodified lines
332332 yield* adapter.startSession({
333333 threadId: THREAD_ID,
334334 provider: "claudeAgent",
335− model: "claude-opus-4-6",
336− runtimeMode: "full-access",
337− modelOptions: {
338− claudeAgent: {
335+ modelSelection: {
336+ provider: "claudeAgent",
337+ model: "claude-opus-4-6",
338+ options: {
339339 effort: "max",
340340 },
341341 },
342+ runtimeMode: "full-access",
342343 });
343344
344345 const createInput = harness.getLastCreateQueryInput();
@@ -356,13 +357,14 @@ describe("ClaudeAdapterLive", () => { · 11 unmodified lines
356357 yield* adapter.startSession({
357358 threadId: THREAD_ID,
358359 provider: "claudeAgent",
359− model: "claude-sonnet-4-6",
360− runtimeMode: "full-access",
361− modelOptions: {
362− claudeAgent: {
360+ modelSelection: {
361+ provider: "claudeAgent",
362+ model: "claude-sonnet-4-6",
363+ options: {
363364 effort: "max",
364365 },
365366 },
367+ runtimeMode: "full-access",
366368 });
367369
368370 const createInput = harness.getLastCreateQueryInput();
@@ -380,13 +382,14 @@ describe("ClaudeAdapterLive", () => { · 11 unmodified lines
380382 yield* adapter.startSession({
381383 threadId: THREAD_ID,
382384 provider: "claudeAgent",
383− model: "claude-haiku-4-5",
384− runtimeMode: "full-access",
385− modelOptions: {
386− claudeAgent: {
385+ modelSelection: {
386+ provider: "claudeAgent",
387+ model: "claude-haiku-4-5",
388+ options: {
387389 effort: "high",
388390 },
389391 },
392+ runtimeMode: "full-access",
390393 });
391394
392395 const createInput = harness.getLastCreateQueryInput();
@@ -404,13 +407,14 @@ describe("ClaudeAdapterLive", () => { · 11 unmodified lines
404407 yield* adapter.startSession({
405408 threadId: THREAD_ID,
406409 provider: "claudeAgent",
407− model: "claude-haiku-4-5",
408− runtimeMode: "full-access",
409− modelOptions: {
410− claudeAgent: {
410+ modelSelection: {
411+ provider: "claudeAgent",
412+ model: "claude-haiku-4-5",
413+ options: {
411414 thinking: false,
412415 },
413416 },
417+ runtimeMode: "full-access",
414418 });
415419
416420 const createInput = harness.getLastCreateQueryInput();
@@ -430,13 +434,14 @@ describe("ClaudeAdapterLive", () => { · 13 unmodified lines
430434 yield* adapter.startSession({
431435 threadId: THREAD_ID,
432436 provider: "claudeAgent",
433− model: "claude-sonnet-4-6",
434− runtimeMode: "full-access",
435− modelOptions: {
436− claudeAgent: {
437+ modelSelection: {
438+ provider: "claudeAgent",
439+ model: "claude-sonnet-4-6",
440+ options: {
437441 thinking: false,
438442 },
439443 },
444+ runtimeMode: "full-access",
440445 });
441446
442447 const createInput = harness.getLastCreateQueryInput();
@@ -454,13 +459,14 @@ describe("ClaudeAdapterLive", () => { · 11 unmodified lines
454459 yield* adapter.startSession({
455460 threadId: THREAD_ID,
456461 provider: "claudeAgent",
457− model: "claude-opus-4-6",
458− runtimeMode: "full-access",
459− modelOptions: {
460− claudeAgent: {
462+ modelSelection: {
463+ provider: "claudeAgent",
464+ model: "claude-opus-4-6",
465+ options: {
461466 fastMode: true,
462467 },
463468 },
469+ runtimeMode: "full-access",
464470 });
465471
466472 const createInput = harness.getLastCreateQueryInput();
@@ -480,13 +486,14 @@ describe("ClaudeAdapterLive", () => { · 13 unmodified lines
480486 yield* adapter.startSession({
481487 threadId: THREAD_ID,
482488 provider: "claudeAgent",
483− model: "claude-sonnet-4-6",
484− runtimeMode: "full-access",
485− modelOptions: {
486− claudeAgent: {
489+ modelSelection: {
490+ provider: "claudeAgent",
491+ model: "claude-sonnet-4-6",
492+ options: {
487493 fastMode: true,
488494 },
489495 },
496+ runtimeMode: "full-access",
490497 });
491498
492499 const createInput = harness.getLastCreateQueryInput();
@@ -504,22 +511,24 @@ describe("ClaudeAdapterLive", () => { · 11 unmodified lines
504511 const session = yield* adapter.startSession({
505512 threadId: THREAD_ID,
506513 provider: "claudeAgent",
507− model: "claude-sonnet-4-6",
508− runtimeMode: "full-access",
509− modelOptions: {
510− claudeAgent: {
514+ modelSelection: {
515+ provider: "claudeAgent",
516+ model: "claude-sonnet-4-6",
517+ options: {
511518 effort: "ultrathink",
512519 },
513520 },
521+ runtimeMode: "full-access",
514522 });
515523
516524 yield* adapter.sendTurn({
517525 threadId: session.threadId,
518526 input: "Investigate the edge cases",
519527 attachments: [],
520− model: "claude-sonnet-4-6",
521− modelOptions: {
522− claudeAgent: {
528+ modelSelection: {
529+ provider: "claudeAgent",
530+ model: "claude-sonnet-4-6",
531+ options: {
523532 effort: "ultrathink",
524533 },
525534 },
@@ -613,7 +622,10 @@ describe("ClaudeAdapterLive", () => { · 87 unmodified lines
613622 const session = yield* adapter.startSession({
614623 threadId: THREAD_ID,
615624 provider: "claudeAgent",
616− model: "claude-sonnet-4-5",
625+ modelSelection: {
626+ provider: "claudeAgent",
627+ model: "claude-sonnet-4-5",
628+ },
617629 runtimeMode: "full-access",
618630 });
619631
@@ -2239,7 +2251,10 @@ describe("ClaudeAdapterLive", () => { · 1619 unmodified lines
22392251 yield* adapter.sendTurn({
22402252 threadId: session.threadId,
22412253 input: "hello",
2242− model: "claude-opus-4-6",
2254+ modelSelection: {
2255+ provider: "claudeAgent",
2256+ model: "claude-opus-4-6",
2257+ },
22432258 attachments: [],
22442259 });
22452260
apps/server/src/provider/Layers/ClaudeAdapter.ts+29−15
@@ -436,11 +436,17 @@ const CLAUDE_SETTING_SOURCES = [ · 435 unmodified lines
436436 function buildPromptText(input: ProviderSendTurnInput): string {
437437 const requestedEffort = resolveReasoningEffortForProvider(
438438 "claudeAgent",
439− input.modelOptions?.claudeAgent?.effort ?? null,
439+ input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null,
440+ );
441+ const supportedEffortOptions = getReasoningEffortOptions(
442+ "claudeAgent",
443+ input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined,
440444 );
441− const supportedEffortOptions = getReasoningEffortOptions("claudeAgent", input.model);
442445 const promptEffort =
443− requestedEffort === "ultrathink" && supportsClaudeUltrathinkKeyword(input.model)
446+ requestedEffort === "ultrathink" &&
447+ supportsClaudeUltrathinkKeyword(
448+ input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined,
449+ )
444450 ? "ultrathink"
445451 : requestedEffort && supportedEffortOptions.includes(requestedEffort)
446452 ? requestedEffort
@@ -2542,21 +2548,27 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 2095 unmodified lines
25422548 );
25432549
25442550 const providerOptions = input.providerOptions?.claudeAgent;
2551+ const modelSelection =
2552+ input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined;
25452553 const requestedEffort = resolveReasoningEffortForProvider(
25462554 "claudeAgent",
2547− input.modelOptions?.claudeAgent?.effort ?? null,
2555+ modelSelection?.options?.effort ?? null,
2556+ );
2557+ const supportedEffortOptions = getReasoningEffortOptions(
2558+ "claudeAgent",
2559+ modelSelection?.model,
25482560 );
2549− const supportedEffortOptions = getReasoningEffortOptions("claudeAgent", input.model);
25502561 const effort =
25512562 requestedEffort && supportedEffortOptions.includes(requestedEffort)
25522563 ? requestedEffort
25532564 : null;
25542565 const fastMode =
2555− input.modelOptions?.claudeAgent?.fastMode === true && supportsClaudeFastMode(input.model);
2566+ modelSelection?.options?.fastMode === true &&
2567+ supportsClaudeFastMode(modelSelection?.model);
25562568 const thinking =
2557− typeof input.modelOptions?.claudeAgent?.thinking === "boolean" &&
2558− supportsClaudeThinkingToggle(input.model)
2559− ? input.modelOptions.claudeAgent.thinking
2569+ typeof modelSelection?.options?.thinking === "boolean" &&
2570+ supportsClaudeThinkingToggle(modelSelection?.model)
2571+ ? modelSelection.options.thinking
25602572 : undefined;
25612573 const effectiveEffort = getEffectiveClaudeCodeEffort(effort);
25622574 const permissionMode =
@@ -2569,7 +2581,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 6 unmodified lines
25692581
25702582 const queryOptions: ClaudeQueryOptions = {
25712583 ...(input.cwd ? { cwd: input.cwd } : {}),
2572− ...(input.model ? { model: input.model } : {}),
2584+ ...(modelSelection?.model ? { model: modelSelection.model } : {}),
25732585 pathToClaudeCodeExecutable: providerOptions?.binaryPath ?? "claude",
25742586 settingSources: [...CLAUDE_SETTING_SOURCES],
25752587 ...(effectiveEffort ? { effort: effectiveEffort } : {}),
@@ -2610,7 +2622,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 34 unmodified lines
26102622 status: "ready",
26112623 runtimeMode: input.runtimeMode,
26122624 ...(input.cwd ? { cwd: input.cwd } : {}),
2613− ...(input.model ? { model: input.model } : {}),
2625+ ...(modelSelection?.model ? { model: modelSelection.model } : {}),
26142626 ...(threadId ? { threadId } : {}),
26152627 resumeCursor: {
26162628 ...(threadId ? { threadId } : {}),
@@ -2664,7 +2676,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 47 unmodified lines
26642676 threadId,
26652677 payload: {
26662678 config: {
2667− ...(input.model ? { model: input.model } : {}),
2679+ ...(modelSelection?.model ? { model: modelSelection.model } : {}),
26682680 ...(input.cwd ? { cwd: input.cwd } : {}),
26692681 ...(effectiveEffort ? { effort: effectiveEffort } : {}),
26702682 ...(permissionMode ? { permissionMode } : {}),
@@ -2710,6 +2722,8 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 39 unmodified lines
27102722 const sendTurn: ClaudeAdapterShape["sendTurn"] = (input) =>
27112723 Effect.gen(function* () {
27122724 const context = yield* requireSession(input.threadId);
2725+ const modelSelection =
2726+ input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined;
27132727
27142728 if (context.turnState) {
27152729 // Auto-close a stale synthetic turn (from background agent responses
@@ -2717,9 +2731,9 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 1 unmodified line
27172731 yield* completeTurn(context, "completed");
27182732 }
27192733
2720− if (input.model) {
2734+ if (modelSelection?.model) {
27212735 yield* Effect.tryPromise({
2722− try: () => context.query.setModel(input.model),
2736+ try: () => context.query.setModel(modelSelection.model),
27232737 catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause),
27242738 });
27252739 }
@@ -2769,7 +2783,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 43 unmodified lines
27692783 createdAt: turnStartedStamp.createdAt,
27702784 threadId: context.session.threadId,
27712785 turnId,
2772− payload: input.model ? { model: input.model } : {},
2786+ payload: modelSelection?.model ? { model: modelSelection.model } : {},
27732787 providerRefs: {},
27742788 });
27752789
apps/server/src/provider/Layers/CodexAdapter.test.ts+8−6
@@ -188,9 +188,10 @@ validationLayer("CodexAdapterLive validation", (it) => { · 187 unmodified lines
188188 yield* adapter.startSession({
189189 provider: "codex",
190190 threadId: asThreadId("thread-1"),
191− model: "gpt-5.3-codex",
192− modelOptions: {
193− codex: {
191+ modelSelection: {
192+ provider: "codex",
193+ model: "gpt-5.3-codex",
194+ options: {
194195 fastMode: true,
195196 },
196197 },
@@ -256,9 +257,10 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { · 59 unmodified lines
256257 adapter.sendTurn({
257258 threadId: asThreadId("sess-missing"),
258259 input: "hello",
259− model: "gpt-5.3-codex",
260− modelOptions: {
261− codex: {
260+ modelSelection: {
261+ provider: "codex",
262+ model: "gpt-5.3-codex",
263+ options: {
262264 reasoningEffort: "high",
263265 fastMode: true,
264266 },
apps/server/src/provider/Layers/CodexAdapter.ts+16−6
@@ -1313,8 +1313,12 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => · 1312 unmodified lines
13131313 ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
13141314 ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),
13151315 runtimeMode: input.runtimeMode,
1316− ...(input.model !== undefined ? { model: input.model } : {}),
1317− ...(input.modelOptions?.codex?.fastMode ? { serviceTier: "fast" } : {}),
1316+ ...(input.modelSelection?.provider === "codex"
1317+ ? { model: input.modelSelection.model }
1318+ : {}),
1319+ ...(input.modelSelection?.provider === "codex" && input.modelSelection.options?.fastMode
1320+ ? { serviceTier: "fast" }
1321+ : {}),
13181322 };
13191323
13201324 return Effect.tryPromise({
@@ -1370,11 +1374,17 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => · 49 unmodified lines
13701374 const managerInput = {
13711375 threadId: input.threadId,
13721376 ...(input.input !== undefined ? { input: input.input } : {}),
1373− ...(input.model !== undefined ? { model: input.model } : {}),
1374− ...(input.modelOptions?.codex?.reasoningEffort !== undefined
1375− ? { effort: input.modelOptions.codex.reasoningEffort }
1377+ ...(input.modelSelection?.provider === "codex"
1378+ ? { model: input.modelSelection.model }
1379+ : {}),
1380+ ...(input.modelSelection?.provider === "codex" &&
1381+ input.modelSelection.options?.reasoningEffort !== undefined
1382+ ? { effort: input.modelSelection.options.reasoningEffort }
1383+ : {}),
1384+ ...(input.modelSelection?.provider === "codex" &&
1385+ input.modelSelection.options?.fastMode
1386+ ? { serviceTier: "fast" }
13761387 : {}),
1377− ...(input.modelOptions?.codex?.fastMode ? { serviceTier: "fast" } : {}),
13781388 ...(input.interactionMode !== undefined
13791389 ? { interactionMode: input.interactionMode }
13801390 : {}),
apps/server/src/provider/Layers/ProviderService.test.ts+9−5
@@ -633,8 +633,10 @@ routing.layer("ProviderServiceLive routing", (it) => { · 632 unmodified lines
633633 provider: "claudeAgent",
634634 threadId: asThreadId("thread-claude-send-turn"),
635635 cwd: "/tmp/project-claude-send-turn",
636− modelOptions: {
637− claudeAgent: {
636+ modelSelection: {
637+ provider: "claudeAgent",
638+ model: "claude-opus-4-6",
639+ options: {
638640 effort: "max",
639641 },
640642 },
@@ -658,14 +660,16 @@ routing.layer("ProviderServiceLive routing", (it) => { · 17 unmodified lines
658660 const startPayload = resumedStartInput as {
659661 provider?: string;
660662 cwd?: string;
661− modelOptions?: unknown;
663+ modelSelection?: unknown;
662664 resumeCursor?: unknown;
663665 threadId?: string;
664666 };
665667 assert.equal(startPayload.provider, "claudeAgent");
666668 assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn");
667− assert.deepEqual(startPayload.modelOptions, {
668− claudeAgent: {
669+ assert.deepEqual(startPayload.modelSelection, {
670+ provider: "claudeAgent",
671+ model: "claude-opus-4-6",
672+ options: {
669673 effort: "max",
670674 },
671675 });
apps/server/src/provider/Layers/ProviderService.ts+16−13
@@ -10,6 +10,7 @@ · 9 unmodified lines
1010 * @module ProviderServiceLive
1111 */
1212 import {
13+ ModelSelection,
1314 NonNegativeInt,
1415 ThreadId,
1516 ProviderInterruptTurnInput,
@@ -89,7 +90,7 @@ function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "st · 73 unmodified lines
8990 function toRuntimePayloadFromSession(
9091 session: ProviderSession,
9192 extra?: {
92− readonly modelOptions?: unknown;
93+ readonly modelSelection?: unknown;
9394 readonly providerOptions?: unknown;
9495 readonly lastRuntimeEvent?: string;
9596 readonly lastRuntimeEventAt?: string;
@@ -100,7 +101,7 @@ function toRuntimePayloadFromSession( · 4 unmodified lines
100101 model: session.model ?? null,
101102 activeTurnId: session.activeTurnId ?? null,
102103 lastError: session.lastError ?? null,
103− ...(extra?.modelOptions !== undefined ? { modelOptions: extra.modelOptions } : {}),
104+ ...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}),
104105 ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}),
105106 ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}),
106107 ...(extra?.lastRuntimeEventAt !== undefined
@@ -109,15 +110,14 @@ function toRuntimePayloadFromSession( · 2 unmodified lines
109110 };
110111 }
111112
112−function readPersistedModelOptions(
113+function readPersistedModelSelection(
113114 runtimePayload: ProviderRuntimeBinding["runtimePayload"],
114−): Record<string, unknown> | undefined {
115+): ModelSelection | undefined {
115116 if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) {
116117 return undefined;
117118 }
118− const raw = "modelOptions" in runtimePayload ? runtimePayload.modelOptions : undefined;
119− if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
120− return raw as Record<string, unknown>;
119+ const raw = "modelSelection" in runtimePayload ? runtimePayload.modelSelection : undefined;
120+ return Schema.is(ModelSelection)(raw) ? raw : undefined;
121121 }
122122
123123 function readPersistedProviderOptions(
@@ -172,7 +172,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 48 unmodified lines
172172 session: ProviderSession,
173173 threadId: ThreadId,
174174 extra?: {
175− readonly modelOptions?: unknown;
175+ readonly modelSelection?: unknown;
176176 readonly providerOptions?: unknown;
177177 readonly lastRuntimeEvent?: string;
178178 readonly lastRuntimeEventAt?: string;
@@ -238,14 +238,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 59 unmodified lines
238238 }
239239
240240 const persistedCwd = readPersistedCwd(input.binding.runtimePayload);
241− const persistedModelOptions = readPersistedModelOptions(input.binding.runtimePayload);
241+ const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload);
242242 const persistedProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload);
243243
244244 const resumed = yield* adapter.startSession({
245245 threadId: input.binding.threadId,
246246 provider: input.binding.provider,
247247 ...(persistedCwd ? { cwd: persistedCwd } : {}),
248− ...(persistedModelOptions ? { modelOptions: persistedModelOptions } : {}),
248+ ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}),
249249 ...(persistedProviderOptions ? { providerOptions: persistedProviderOptions } : {}),
250250 ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}),
251251 runtimeMode: input.binding.runtimeMode ?? "full-access",
@@ -328,7 +328,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 76 unmodified lines
328328 }
329329
330330 yield* upsertSessionBinding(session, threadId, {
331− modelOptions: input.modelOptions,
331+ modelSelection: input.modelSelection,
332332 providerOptions: input.providerOptions,
333333 });
334334 yield* analytics.record("provider.session.started", {
@@ -336,7 +336,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 1 unmodified line
336336 runtimeMode: input.runtimeMode,
337337 hasResumeCursor: session.resumeCursor !== undefined,
338338 hasCwd: typeof input.cwd === "string" && input.cwd.trim().length > 0,
339− hasModel: typeof input.model === "string" && input.model.trim().length > 0,
339+ hasModel:
340+ typeof input.modelSelection?.model === "string" &&
341+ input.modelSelection.model.trim().length > 0,
340342 });
341343
342344 return session;
@@ -372,6 +374,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 29 unmodified lines
372374 status: "running",
373375 ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}),
374376 runtimePayload: {
377+ ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}),
375378 activeTurnId: turn.turnId,
376379 lastRuntimeEvent: "provider.sendTurn",
377380 lastRuntimeEventAt: new Date().toISOString(),
@@ -379,7 +382,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 1 unmodified line
379382 });
380383 yield* analytics.record("provider.turn.sent", {
381384 provider: routed.adapter.provider,
382− model: input.model,
385+ model: input.modelSelection?.model,
383386 interactionMode: input.interactionMode,
384387 attachmentCount: input.attachments.length,
385388 hasInput: typeof input.input === "string" && input.input.trim().length > 0,
apps/web/src/components/ChatView.tsx+3−19
@@ -7,7 +7,6 @@ import { · 6 unmodified lines
77 type ProjectScript,
88 type ModelSlug,
99 type ProviderKind,
10− type ProviderModelOptions,
1110 type ProjectEntry,
1211 type ProjectId,
1312 type ProviderApprovalDecision,
@@ -28,7 +27,6 @@ import { · 14 unmodified lines
2827 getDefaultModel,
2928 normalizeModelSlug,
3029 resolveModelSlugForProvider,
31− toProviderModelOptions,
3230 } from "@t3tools/shared/model";
3331 import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3432 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -205,12 +203,6 @@ function formatOutgoingPrompt(params: { · 170 unmodified lines
205203 }
206204 return params.text;
207205 }
208−function extractModelSelectionOptions(
209− provider: ProviderKind,
210− modelOptions: ProviderModelOptions | undefined,
211−): ModelSelection["options"] | undefined {
212− return provider === "codex" ? modelOptions?.codex : modelOptions?.claudeAgent;
213−}
214206 const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
215207 const SCRIPT_TERMINAL_COLS = 120;
216208 const SCRIPT_TERMINAL_ROWS = 30;
@@ -630,16 +622,15 @@ export default function ChatView({ threadId }: ChatViewProps) { · 413 unmodified lines
630622 customModelsByProvider,
631623 selectedProvider,
632624 ]);
633− const draftModelOptions = toProviderModelOptions(composerDraft.modelSelection);
634625 const composerProviderState = useMemo(
635626 () =>
636627 getComposerProviderState({
637628 provider: selectedProvider,
638629 model: selectedModel,
639630 prompt,
640− modelOptions: draftModelOptions,
631+ modelOptions: composerDraft.modelSelection?.options,
641632 }),
642− [draftModelOptions, prompt, selectedModel, selectedProvider],
633+ [composerDraft.modelSelection?.options, prompt, selectedModel, selectedProvider],
643634 );
644635 const selectedPromptEffort = composerProviderState.promptEffort;
645636 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;
@@ -647,14 +638,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1 unmodified line
647638 () => ({
648639 provider: selectedProvider,
649640 model: selectedModel,
650− ...(extractModelSelectionOptions(selectedProvider, selectedModelOptionsForDispatch)
651− ? {
652− options: extractModelSelectionOptions(
653− selectedProvider,
654− selectedModelOptionsForDispatch,
655− ),
656− }
657− : {}),
641+ ...(selectedModelOptionsForDispatch ? { options: selectedModelOptionsForDispatch } : {}),
658642 }),
659643 [selectedModel, selectedModelOptionsForDispatch, selectedProvider],
660644 );
apps/web/src/components/chat/composerProviderRegistry.test.tsx+9−61
@@ -23,10 +23,8 @@ describe("getComposerProviderState", () => { · 22 unmodified lines
2323 model: "gpt-5.4",
2424 prompt: "",
2525 modelOptions: {
26− codex: {
27− reasoningEffort: "low",
28− fastMode: true,
29− },
26+ reasoningEffort: "low",
27+ fastMode: true,
3028 },
3129 });
3230
@@ -34,10 +32,8 @@ describe("getComposerProviderState", () => { · 1 unmodified line
3432 provider: "codex",
3533 promptEffort: "low",
3634 modelOptionsForDispatch: {
37− codex: {
38− reasoningEffort: "low",
39− fastMode: true,
40− },
35+ reasoningEffort: "low",
36+ fastMode: true,
4137 },
4238 });
4339 });
@@ -63,9 +59,7 @@ describe("getComposerProviderState", () => { · 19 unmodified lines
6359 model: "claude-sonnet-4-6",
6460 prompt: "Ultrathink:\nInvestigate this failure",
6561 modelOptions: {
66− claudeAgent: {
67− effort: "medium",
68− },
62+ effort: "medium",
6963 },
7064 });
7165
@@ -73,9 +67,7 @@ describe("getComposerProviderState", () => { · 1 unmodified line
7367 provider: "claudeAgent",
7468 promptEffort: "medium",
7569 modelOptionsForDispatch: {
76− claudeAgent: {
77− effort: "medium",
78− },
70+ effort: "medium",
7971 },
8072 composerFrameClassName: "ultrathink-frame",
8173 composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]",
@@ -89,10 +81,8 @@ describe("getComposerProviderState", () => { · 7 unmodified lines
8981 model: "claude-haiku-4-5",
9082 prompt: "",
9183 modelOptions: {
92− claudeAgent: {
93− effort: "max",
94− thinking: false,
95− },
84+ effort: "max",
85+ thinking: false,
9686 },
9787 });
9888
@@ -100,50 +90,8 @@ describe("getComposerProviderState", () => { · 1 unmodified line
10090 provider: "claudeAgent",
10191 promptEffort: null,
10292 modelOptionsForDispatch: {
103− claudeAgent: {
104− thinking: false,
105− },
93+ thinking: false,
10694 },
10795 });
10896 });
109−
110− it("ignores codex options while resolving Claude state", () => {
111− const state = getComposerProviderState({
112− provider: "claudeAgent",
113− model: "claude-opus-4-6",
114− prompt: "",
115− modelOptions: {
116− codex: {
117− reasoningEffort: "low",
118− fastMode: true,
119− },
120− },
121− });
122−
123− expect(state).toEqual({
124− provider: "claudeAgent",
125− promptEffort: "high",
126− modelOptionsForDispatch: undefined,
127− });
128− });
129−
130− it("ignores Claude options while resolving codex state", () => {
131− const state = getComposerProviderState({
132− provider: "codex",
133− model: "gpt-5.4",
134− prompt: "Ultrathink:\nThis should not matter",
135− modelOptions: {
136− claudeAgent: {
137− effort: "max",
138− fastMode: true,
139− },
140− },
141− });
142−
143− expect(state).toEqual({
144− provider: "codex",
145− promptEffort: "high",
146− modelOptionsForDispatch: undefined,
147− });
148− });
14997 });
apps/web/src/components/chat/composerProviderRegistry.tsx+27−13
@@ -1,7 +1,9 @@
11 import {
2+ type ClaudeModelOptions,
3+ type CodexModelOptions,
24 type ModelSlug,
5+ type ModelSelection,
36 type ProviderKind,
4− type ProviderModelOptions,
57 type ThreadId,
68 } from "@t3tools/contracts";
79 import {
@@ -21,13 +23,13 @@ export type ComposerProviderStateInput = { · 13 unmodified lines
2123 provider: ProviderKind;
2224 model: ModelSlug;
2325 prompt: string;
24− modelOptions: ProviderModelOptions | null | undefined;
26+ modelOptions: ModelSelection["options"] | null | undefined;
2527 };
2628
2729 export type ComposerProviderState = {
2830 provider: ProviderKind;
2931 promptEffort: string | null;
30− modelOptionsForDispatch: ProviderModelOptions | undefined;
32+ modelOptionsForDispatch: ModelSelection["options"] | undefined;
3133 composerFrameClassName?: string;
3234 composerSurfaceClassName?: string;
3335 modelPickerIconClassName?: string;
@@ -47,20 +49,33 @@ type ProviderRegistryEntry = { · 13 unmodified lines
4749 }) => ReactNode;
4850 };
4951
52+function asCodexModelOptions(
53+ modelOptions: ModelSelection["options"] | null | undefined,
54+): CodexModelOptions | undefined {
55+ return modelOptions && "reasoningEffort" in modelOptions ? modelOptions : undefined;
56+}
57+
58+function asClaudeModelOptions(
59+ modelOptions: ModelSelection["options"] | null | undefined,
60+): ClaudeModelOptions | undefined {
61+ return modelOptions && ("effort" in modelOptions || "thinking" in modelOptions)
62+ ? modelOptions
63+ : undefined;
64+}
65+
5066 const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {
5167 codex: {
5268 getState: ({ modelOptions }) => {
69+ const codexModelOptions = asCodexModelOptions(modelOptions);
5370 const promptEffort =
54− resolveReasoningEffortForProvider("codex", modelOptions?.codex?.reasoningEffort) ??
71+ resolveReasoningEffortForProvider("codex", codexModelOptions?.reasoningEffort) ??
5572 getDefaultReasoningEffort("codex");
56− const normalizedCodexOptions = normalizeCodexModelOptions(modelOptions?.codex);
73+ const normalizedCodexOptions = normalizeCodexModelOptions(codexModelOptions);
5774
5875 return {
5976 provider: "codex",
6077 promptEffort,
61− modelOptionsForDispatch: normalizedCodexOptions
62− ? { codex: normalizedCodexOptions }
63− : undefined,
78+ modelOptionsForDispatch: normalizedCodexOptions,
6479 };
6580 },
6681 renderTraitsMenuContent: ({ threadId }) => <CodexTraitsMenuContent threadId={threadId} />,
@@ -68,10 +83,11 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { · 1 unmodified line
6883 },
6984 claudeAgent: {
7085 getState: ({ model, prompt, modelOptions }) => {
86+ const claudeModelOptions = asClaudeModelOptions(modelOptions);
7187 const reasoningOptions = getReasoningEffortOptions("claudeAgent", model);
7288 const draftEffort = resolveReasoningEffortForProvider(
7389 "claudeAgent",
74− modelOptions?.claudeAgent?.effort,
90+ claudeModelOptions?.effort,
7591 );
7692 const defaultEffort = getDefaultReasoningEffort("claudeAgent");
7793 const promptEffort =
@@ -80,16 +96,14 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { · 2 unmodified lines
8096 : reasoningOptions.includes(defaultEffort)
8197 ? defaultEffort
8298 : null;
83− const normalizedClaudeOptions = normalizeClaudeModelOptions(model, modelOptions?.claudeAgent);
99+ const normalizedClaudeOptions = normalizeClaudeModelOptions(model, claudeModelOptions);
84100 const ultrathinkActive =
85101 supportsClaudeUltrathinkKeyword(model) && isClaudeUltrathinkPrompt(prompt);
86102
87103 return {
88104 provider: "claudeAgent",
89105 promptEffort,
90− modelOptionsForDispatch: normalizedClaudeOptions
91− ? { claudeAgent: normalizedClaudeOptions }
92− : undefined,
106+ modelOptionsForDispatch: normalizedClaudeOptions,
93107 ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame" } : {}),
94108 ...(ultrathinkActive
95109 ? { composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]" }
packages/contracts/src/orchestration.ts+1−1
@@ -133,7 +133,7 @@ function normalizeLegacyThreadModelSelection( · 132 unmodified lines
133133 return Effect.succeed(input.modelSelection);
134134 }
135135 if (input.model === undefined) {
136− return Effect.succeed(undefined);
136+ return Effect.succeed<ModelSelection | undefined>(undefined);
137137 }
138138 return Effect.try({
139139 try: () =>
packages/contracts/src/provider.test.ts+46−24
@@ -12,9 +12,10 @@ describe("ProviderSessionStartInput", () => { · 11 unmodified lines
1212 threadId: "thread-1",
1313 provider: "codex",
1414 cwd: "/tmp/workspace",
15− model: "gpt-5.3-codex",
16− modelOptions: {
17− codex: {
15+ modelSelection: {
16+ provider: "codex",
17+ model: "gpt-5.3-codex",
18+ options: {
1819 reasoningEffort: "high",
1920 fastMode: true,
2021 },
@@ -28,8 +29,13 @@ describe("ProviderSessionStartInput", () => { · 7 unmodified lines
2829 },
2930 });
3031 expect(parsed.runtimeMode).toBe("full-access");
31− expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("high");
32− expect(parsed.modelOptions?.codex?.fastMode).toBe(true);
32+ expect(parsed.modelSelection?.provider).toBe("codex");
33+ expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex");
34+ if (parsed.modelSelection?.provider !== "codex") {
35+ throw new Error("Expected codex modelSelection");
36+ }
37+ expect(parsed.modelSelection.options?.reasoningEffort).toBe("high");
38+ expect(parsed.modelSelection.options?.fastMode).toBe(true);
3339 expect(parsed.providerOptions?.codex?.binaryPath).toBe("/usr/local/bin/codex");
3440 expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex");
3541 });
@@ -48,9 +54,10 @@ describe("ProviderSessionStartInput", () => { · 12 unmodified lines
4854 threadId: "thread-1",
4955 provider: "claudeAgent",
5056 cwd: "/tmp/workspace",
51− model: "claude-sonnet-4-6",
52− modelOptions: {
53− claudeAgent: {
57+ modelSelection: {
58+ provider: "claudeAgent",
59+ model: "claude-sonnet-4-6",
60+ options: {
5461 thinking: true,
5562 effort: "max",
5663 fastMode: true,
@@ -66,9 +73,14 @@ describe("ProviderSessionStartInput", () => { · 9 unmodified lines
6673 runtimeMode: "full-access",
6774 });
6875 expect(parsed.provider).toBe("claudeAgent");
69− expect(parsed.modelOptions?.claudeAgent?.thinking).toBe(true);
70− expect(parsed.modelOptions?.claudeAgent?.effort).toBe("max");
71− expect(parsed.modelOptions?.claudeAgent?.fastMode).toBe(true);
76+ expect(parsed.modelSelection?.provider).toBe("claudeAgent");
77+ expect(parsed.modelSelection?.model).toBe("claude-sonnet-4-6");
78+ if (parsed.modelSelection?.provider !== "claudeAgent") {
79+ throw new Error("Expected claude modelSelection");
80+ }
81+ expect(parsed.modelSelection.options?.thinking).toBe(true);
82+ expect(parsed.modelSelection.options?.effort).toBe("max");
83+ expect(parsed.modelSelection.options?.fastMode).toBe(true);
7284 expect(parsed.providerOptions?.claudeAgent?.binaryPath).toBe("/usr/local/bin/claude");
7385 expect(parsed.providerOptions?.claudeAgent?.permissionMode).toBe("plan");
7486 expect(parsed.providerOptions?.claudeAgent?.maxThinkingTokens).toBe(12_000);
@@ -77,36 +89,46 @@ describe("ProviderSessionStartInput", () => { · 2 unmodified lines
7789 });
7890
7991 describe("ProviderSendTurnInput", () => {
80− it("accepts provider-scoped model options", () => {
92+ it("accepts codex modelSelection", () => {
8193 const parsed = decodeProviderSendTurnInput({
8294 threadId: "thread-1",
83− model: "gpt-5.3-codex",
84− modelOptions: {
85− codex: {
95+ modelSelection: {
96+ provider: "codex",
97+ model: "gpt-5.3-codex",
98+ options: {
8699 reasoningEffort: "xhigh",
87100 fastMode: true,
88101 },
89102 },
90103 });
91104
92− expect(parsed.model).toBe("gpt-5.3-codex");
93− expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("xhigh");
94− expect(parsed.modelOptions?.codex?.fastMode).toBe(true);
105+ expect(parsed.modelSelection?.provider).toBe("codex");
106+ expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex");
107+ if (parsed.modelSelection?.provider !== "codex") {
108+ throw new Error("Expected codex modelSelection");
109+ }
110+ expect(parsed.modelSelection.options?.reasoningEffort).toBe("xhigh");
111+ expect(parsed.modelSelection.options?.fastMode).toBe(true);
95112 });
96113
97− it("accepts claude provider effort options including ultrathink", () => {
114+ it("accepts claude modelSelection including ultrathink", () => {
98115 const parsed = decodeProviderSendTurnInput({
99116 threadId: "thread-1",
100− model: "claude-sonnet-4-6",
101− modelOptions: {
102− claudeAgent: {
117+ modelSelection: {
118+ provider: "claudeAgent",
119+ model: "claude-sonnet-4-6",
120+ options: {
103121 effort: "ultrathink",
104122 fastMode: true,
105123 },
106124 },
107125 });
108126
109− expect(parsed.modelOptions?.claudeAgent?.effort).toBe("ultrathink");
110− expect(parsed.modelOptions?.claudeAgent?.fastMode).toBe(true);
127+ expect(parsed.modelSelection?.provider).toBe("claudeAgent");
128+ if (parsed.modelSelection?.provider !== "claudeAgent") {
129+ throw new Error("Expected claude modelSelection");
130+ }
131+ expect(parsed.modelSelection.options?.effort).toBe("ultrathink");
132+ expect(parsed.modelSelection.options?.fastMode).toBe(true);
111133 });
112134 });
packages/contracts/src/provider.ts+3−5
@@ -1,6 +1,5 @@
11 import { Schema } from "effect";
22 import { TrimmedNonEmptyString } from "./baseSchemas";
3−import { ProviderModelOptions } from "./model";
43 import {
54 ApprovalRequestId,
65 EventId,
@@ -11,6 +10,7 @@ import { · 4 unmodified lines
1110 } from "./baseSchemas";
1211 import {
1312 ChatAttachment,
13+ ModelSelection,
1414 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1515 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
1616 ProviderApprovalDecision,
@@ -51,8 +51,7 @@ export const ProviderSessionStartInput = Schema.Struct({ · 34 unmodified lines
5151 threadId: ThreadId,
5252 provider: Schema.optional(ProviderKind),
5353 cwd: Schema.optional(TrimmedNonEmptyString),
54− model: Schema.optional(TrimmedNonEmptyString),
55− modelOptions: Schema.optional(ProviderModelOptions),
54+ modelSelection: Schema.optional(ModelSelection),
5655 resumeCursor: Schema.optional(Schema.Unknown),
5756 approvalPolicy: Schema.optional(ProviderApprovalPolicy),
5857 sandboxMode: Schema.optional(ProviderSandboxMode),
@@ -69,8 +68,7 @@ export const ProviderSendTurnInput = Schema.Struct({ · 10 unmodified lines
6968 attachments: Schema.optional(
7069 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
7170 ),
72− model: Schema.optional(TrimmedNonEmptyString),
73− modelOptions: Schema.optional(ProviderModelOptions),
71+ modelSelection: Schema.optional(ModelSelection),
7472 interactionMode: Schema.optional(ProviderInteractionMode),
7573 });
7674 export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type;
packages/shared/src/model.test.ts+0−40
@@ -27,7 +27,6 @@ import { · 26 unmodified lines
2727 supportsClaudeFastMode,
2828 supportsClaudeMaxEffort,
2929 supportsClaudeThinkingToggle,
30− toProviderModelOptions,
3130 supportsClaudeUltrathinkKeyword,
3231 } from "./model";
3332
@@ -273,45 +272,6 @@ describe("normalizeClaudeModelOptions", () => { · 239 unmodified lines
273272 });
274273 });
275274
276−describe("toProviderModelOptions", () => {
277− it("returns undefined when no model options exist", () => {
278− expect(toProviderModelOptions({ provider: "codex", model: "gpt-5.4" })).toBeUndefined();
279− expect(toProviderModelOptions(null)).toBeUndefined();
280− });
281−
282− it("scopes codex options under the codex provider key", () => {
283− expect(
284− toProviderModelOptions({
285− provider: "codex",
286− model: "gpt-5.4",
287− options: {
288− fastMode: true,
289− },
290− }),
291− ).toEqual({
292− codex: {
293− fastMode: true,
294− },
295− });
296− });
297−
298− it("scopes claude options under the claudeAgent provider key", () => {
299− expect(
300− toProviderModelOptions({
301− provider: "claudeAgent",
302− model: "claude-opus-4-6",
303− options: {
304− effort: "max",
305− },
306− }),
307− ).toEqual({
308− claudeAgent: {
309− effort: "max",
310− },
311− });
312− });
313−});
314−
315275 describe("supportsClaudeAdaptiveReasoning", () => {
316276 it("only enables adaptive reasoning for Opus 4.6 and Sonnet 4.6", () => {
317277 expect(supportsClaudeAdaptiveReasoning("claude-opus-4-6")).toBe(true);
packages/shared/src/model.ts+0−13
@@ -10,9 +10,7 @@ import { · 9 unmodified lines
1010 type ClaudeCodeEffort,
1111 type CodexModelOptions,
1212 type CodexReasoningEffort,
13− type ModelSelection,
1413 type ModelSlug,
15− type ProviderModelOptions,
1614 type ProviderReasoningEffort,
1715 type ProviderKind,
1816 } from "@t3tools/contracts";
@@ -268,15 +266,4 @@ export function applyClaudePromptEffortPrefix( · 249 unmodified lines
268266 return `Ultrathink:\n${trimmed}`;
269267 }
270268
271−export function toProviderModelOptions(
272− modelSelection: ModelSelection | null | undefined,
273−): ProviderModelOptions | undefined {
274− if (!modelSelection?.options) {
275− return undefined;
276− }
277− return modelSelection.provider === "codex"
278− ? { codex: modelSelection.options }
279− : { claudeAgent: modelSelection.options };
280−}
281−
282269 export { CLAUDE_CODE_EFFORT_OPTIONS, CODEX_REASONING_EFFORT_OPTIONS };