authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-03 22:39:59-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log7425a3c4b3cb3bd664c0dd586bc0df3195966d39
tree4b7c87a55a72a2ef4d80171f01e98e80ae3ace96
parent344e04b62351d005fbf973c1f6384fc162878db1

Support provider custom models and fix runtime event mapping

- add per-provider custom model settings/options in web settings and chat picker - preserve Codex request type on `serverRequest/resolved` events - validate empty Cursor prompts before turn start and keep tool-call item types consistent - add tests for new adapter and model-settings behavior - add PR event review checklist document

12 files changed, 582 insertions(+), 153 deletions(-)

PR_EVENT_REVIEW_CHECKLIST.md created+37
...@@ -0,0 +1,37 @@
1# PR Event Review Checklist
2
3## P0
4
5- `apps/server/src/provider/Layers/CursorAdapter.ts:1365` validate `input.input` before emitting `turn.started` or mutating `context.turnState`; current order can leave a phantom running turn on invalid input.
6- `apps/server/src/provider/Layers/CursorAdapter.ts:783` preserve the original tool-call classification across `tool_call_update`; do not hard-code `itemType: "command_execution"` for every update/completion.
7- `apps/server/src/provider/Layers/CodexAdapter.ts:720` correlate `serverRequest/resolved` back to the original request type instead of emitting `requestType: "unknown"`; otherwise approval resolution loses semantic meaning.
8- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:320` add first-class handling for `session.state.changed`; orchestration currently derives session state indirectly and can miss provider-reported waiting/error/stopped transitions.
9
10## P1
11
12- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:320` consume `thread.metadata.updated` and dispatch thread title/metadata updates where applicable.
13- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:320` consume `turn.plan.updated` and project plan state or append a structured thread activity so plan updates are not dropped.
14- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:320` consume `turn.diff.updated` and route it into the existing diff/checkpoint flow if this event is now the provider-side source of truth.
15- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:320` consume `item.updated` for tool lifecycle summaries/progress so mid-tool updates are visible instead of ignored.
16- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:320` consume `runtime.warning` and append non-fatal warning activities; these are currently emitted by providers but disappear downstream.
17
18## P2
19
20- `apps/server/src/provider/Layers/CodexAdapter.ts:904` map `windowsSandbox/setupCompleted` to at least one `session.state.changed` transition plus optional warning detail, as called for by `EVENTS.md`.
21- `apps/server/src/provider/Layers/CodexAdapter.ts:468` consider emitting `session.configured` when session startup config is known, not only `session.started` / `session.state.changed`.
22- `apps/server/src/provider/Layers/CodexAdapter.ts:652` widen `item.updated` coverage beyond reasoning-summary and terminal-interaction boundaries if Codex methods provide meaningful in-progress item mutations.
23- `apps/server/src/provider/Layers/ClaudeCodeAdapter.ts:1222` verify whether Claude `tool_progress` should carry a real human summary instead of synthesizing `task:<id>` into `summary`; keep raw correlation data separately if needed.
24- `apps/server/src/provider/Layers/ClaudeCodeAdapter.ts:1062` decide whether any Claude SDK events should map to `user-input.requested` / `user-input.resolved`; if unsupported, document that explicitly in tests or comments.
25
26## P3
27
28- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:1` reduce reliance on legacy event shims once all adapters and tests are canonical; they currently hide incomplete V2 migration.
29- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:320` decide whether `auth.status`, `files.persisted`, `tool.summary`, `task.*`, `hook.*`, realtime thread events, and token-usage updates should become activities, session fields, or remain intentionally ignored.
30- `apps/server/src/orchestration/projector.ts:360` extend read-model fields only after ingestion semantics are finalized, so projector changes reflect intentional product surface rather than raw event exhaust.
31
32## Tests To Add
33
34- `apps/server/src/provider/Layers/CursorAdapter.test.ts` assert that invalid empty prompt input emits no `turn.started` and leaves session idle.
35- `apps/server/src/provider/Layers/CursorAdapter.test.ts` assert that `tool_call` and `tool_call_update` keep the same canonical `itemType`.
36- `apps/server/src/provider/Layers/CodexAdapter.test.ts` add a case proving `serverRequest/resolved` preserves the original request type.
37- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts` add coverage for `session.state.changed`, `turn.plan.updated`, `turn.diff.updated`, `thread.metadata.updated`, and `runtime.warning`.
apps/server/src/provider/Layers/CodexAdapter.test.ts+36
...@@ -284,6 +284,42 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {...@@ -284,6 +284,42 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
284 assert.equal(firstEvent.value.payload.reason, "Session stopped");284 assert.equal(firstEvent.value.payload.reason, "Session stopped");
285 }),285 }),
286 );286 );
287
288 it.effect("preserves request type when mapping serverRequest/resolved", () =>
289 Effect.gen(function* () {
290 const adapter = yield* CodexAdapter;
291 const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);
292
293 const event: ProviderEvent = {
294 id: asEventId("evt-request-resolved"),
295 kind: "notification",
296 provider: "codex",
297 sessionId: asSessionId("sess-1"),
298 createdAt: new Date().toISOString(),
299 method: "serverRequest/resolved",
300 requestId: ApprovalRequestId.makeUnsafe("req-1"),
301 payload: {
302 request: {
303 method: "item/commandExecution/requestApproval",
304 },
305 decision: "accept",
306 },
307 };
308
309 lifecycleManager.emit("event", event);
310 const firstEvent = yield* Fiber.join(firstEventFiber);
311
312 assert.equal(firstEvent._tag, "Some");
313 if (firstEvent._tag !== "Some") {
314 return;
315 }
316 assert.equal(firstEvent.value.type, "request.resolved");
317 if (firstEvent.value.type !== "request.resolved") {
318 return;
319 }
320 assert.equal(firstEvent.value.payload.requestType, "command_execution_approval");
321 }),
322 );
287});323});
288324
289afterAll(() => {325afterAll(() => {
apps/server/src/provider/Layers/CodexAdapter.ts+20-1
...@@ -239,6 +239,19 @@ function toRequestTypeFromKind(kind: unknown): CanonicalRequestType {...@@ -239,6 +239,19 @@ function toRequestTypeFromKind(kind: unknown): CanonicalRequestType {
239 }239 }
240}240}
241241
242function toRequestTypeFromResolvedPayload(payload: Record<string, unknown> | undefined): CanonicalRequestType {
243 const request = asObject(payload?.request);
244 const method = asString(request?.method) ?? asString(payload?.method);
245 if (method) {
246 return toRequestTypeFromMethod(method);
247 }
248 const requestKind = asString(request?.kind) ?? asString(payload?.requestKind);
249 if (requestKind) {
250 return toRequestTypeFromKind(requestKind);
251 }
252 return "unknown";
253}
254
242function toThreadState(value: unknown):255function toThreadState(value: unknown):
243 | "active"256 | "active"
244 | "idle"257 | "idle"
...@@ -714,12 +727,18 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -714,12 +727,18 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
714 }727 }
715728
716 if (event.method === "serverRequest/resolved") {729 if (event.method === "serverRequest/resolved") {
730 const requestType =
731 toRequestTypeFromResolvedPayload(payload) !== "unknown"
732 ? toRequestTypeFromResolvedPayload(payload)
733 : event.requestId && event.requestKind !== undefined
734 ? toRequestTypeFromKind(event.requestKind)
735 : "unknown";
717 return [736 return [
718 {737 {
719 ...runtimeEventBase(event),738 ...runtimeEventBase(event),
720 type: "request.resolved",739 type: "request.resolved",
721 payload: {740 payload: {
722 requestType: "unknown",741 requestType,
723 ...(event.payload !== undefined ? { resolution: event.payload } : {}),742 ...(event.payload !== undefined ? { resolution: event.payload } : {}),
724 },743 },
725 },744 },
apps/server/src/provider/Layers/CursorAdapter.test.ts+82
...@@ -543,4 +543,86 @@ describe("CursorAdapterLive", () => {...@@ -543,4 +543,86 @@ describe("CursorAdapterLive", () => {
543 assert.equal(fake.lastPermissionSelection, "allow-always");543 assert.equal(fake.lastPermissionSelection, "allow-always");
544 }).pipe(Effect.provide(layer));544 }).pipe(Effect.provide(layer));
545 });545 });
546
547 it.effect("rejects empty prompt input before starting a turn", () => {
548 const fake = new FakeCursorAcpProcess();
549 const layer = makeCursorAdapterLive({
550 createProcess: () => fake as never,
551 });
552
553 return Effect.gen(function* () {
554 const adapter = yield* CursorAdapter;
555 const session = yield* adapter.startSession({
556 provider: "cursor",
557 });
558
559 yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain);
560
561 const result = yield* adapter
562 .sendTurn({
563 sessionId: session.sessionId,
564 input: " ",
565 attachments: [],
566 })
567 .pipe(Effect.result);
568
569 assert.equal(result._tag, "Failure");
570 if (result._tag !== "Failure") {
571 return;
572 }
573 assert.deepEqual(
574 result.failure,
575 new ProviderAdapterValidationError({
576 provider: "cursor",
577 operation: "sendTurn",
578 issue: "Turn input must be non-empty.",
579 }),
580 );
581
582 assert.equal(fake.requests.some((request) => request.method === "session/prompt"), false);
583 }).pipe(Effect.provide(layer));
584 });
585
586 it.effect("keeps tool_call item types consistent through tool_call_update", () => {
587 const fake = new FakeCursorAcpProcess();
588 const layer = makeCursorAdapterLive({
589 createProcess: () => fake as never,
590 });
591
592 return Effect.gen(function* () {
593 const adapter = yield* CursorAdapter;
594
595 const eventsFiber = yield* Stream.take(adapter.streamEvents, 13).pipe(
596 Stream.runCollect,
597 Effect.forkChild,
598 );
599
600 const session = yield* adapter.startSession({
601 provider: "cursor",
602 });
603
604 yield* adapter.sendTurn({
605 sessionId: session.sessionId,
606 input: "hello",
607 attachments: [],
608 });
609
610 const events = Array.from(yield* Fiber.join(eventsFiber));
611 const started = events.find(
612 (event) => event.type === "item.started" && String(event.itemId) === "tool-1",
613 );
614 const completed = events.find(
615 (event) => event.type === "item.completed" && String(event.itemId) === "tool-1",
616 );
617
618 assert.equal(started?.type, "item.started");
619 assert.equal(completed?.type, "item.completed");
620 if (started?.type !== "item.started" || completed?.type !== "item.completed") {
621 return;
622 }
623
624 assert.equal(started.payload.itemType, "command_execution");
625 assert.equal(completed.payload.itemType, "command_execution");
626 }).pipe(Effect.provide(layer));
627 });
546});628});
apps/server/src/provider/Layers/CursorAdapter.ts+20-11
...@@ -75,6 +75,7 @@ interface CursorTurnState {...@@ -75,6 +75,7 @@ interface CursorTurnState {
75 readonly turnId: ReturnType<typeof ProviderTurnId.makeUnsafe>;75 readonly turnId: ReturnType<typeof ProviderTurnId.makeUnsafe>;
76 readonly assistantItemId: ReturnType<typeof ProviderItemId.makeUnsafe>;76 readonly assistantItemId: ReturnType<typeof ProviderItemId.makeUnsafe>;
77 readonly startedToolCalls: Set<string>;77 readonly startedToolCalls: Set<string>;
78 readonly toolCalls: Map<string, { itemType: CanonicalItemType; title: string }>;
78 readonly items: Array<unknown>;79 readonly items: Array<unknown>;
79}80}
8081
...@@ -734,6 +735,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -734,6 +735,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
734 const seen = context.turnState.startedToolCalls.has(update.toolCallId);735 const seen = context.turnState.startedToolCalls.has(update.toolCallId);
735 const itemType = normalizeToolItemType(update.kind, update.title);736 const itemType = normalizeToolItemType(update.kind, update.title);
736 const title = update.title ?? titleForItemType(itemType);737 const title = update.title ?? titleForItemType(itemType);
738 context.turnState.toolCalls.set(update.toolCallId, { itemType, title });
737 const detail = asString(asObject(update.rawInput)?.command);739 const detail = asString(asObject(update.rawInput)?.command);
738740
739 if (!seen) {741 if (!seen) {
...@@ -779,6 +781,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -779,6 +781,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
779 case "tool_call_update": {781 case "tool_call_update": {
780 if (!context.turnState) return;782 if (!context.turnState) return;
781 const status = update.status === "completed" ? "completed" : "inProgress";783 const status = update.status === "completed" ? "completed" : "inProgress";
784 const trackedTool = context.turnState.toolCalls.get(update.toolCallId);
785 const itemType = trackedTool?.itemType ?? "dynamic_tool_call";
786 const title = trackedTool?.title ?? titleForItemType(itemType);
782 const stamp = yield* makeEventStamp();787 const stamp = yield* makeEventStamp();
783 const eventType = update.status === "completed" ? "item.completed" : "item.updated";788 const eventType = update.status === "completed" ? "item.completed" : "item.updated";
784 yield* offerRuntimeEvent({789 yield* offerRuntimeEvent({
...@@ -789,15 +794,18 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -789,15 +794,18 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
789 turnId: asRuntimeTurnId(context.turnState.turnId),794 turnId: asRuntimeTurnId(context.turnState.turnId),
790 itemId: asRuntimeItemId(update.toolCallId),795 itemId: asRuntimeItemId(update.toolCallId),
791 payload: {796 payload: {
792 itemType: "command_execution",797 itemType,
793 status,798 status,
794 title: "Tool call",799 title,
795 ...(summarizeToolOutput(update.rawOutput)800 ...(summarizeToolOutput(update.rawOutput)
796 ? { detail: summarizeToolOutput(update.rawOutput) }801 ? { detail: summarizeToolOutput(update.rawOutput) }
797 : {}),802 : {}),
798 ...(update.rawOutput !== undefined ? { data: update.rawOutput } : {}),803 ...(update.rawOutput !== undefined ? { data: update.rawOutput } : {}),
799 },804 },
800 });805 });
806 if (update.status === "completed") {
807 context.turnState.toolCalls.delete(update.toolCallId);
808 }
801 return;809 return;
802 }810 }
803 }811 }
...@@ -1345,11 +1353,21 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1345,11 +1353,21 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1345 });1353 });
1346 }1354 }
13471355
1356 const promptText = input.input?.trim();
1357 if (!promptText || promptText.length === 0) {
1358 return yield* new ProviderAdapterValidationError({
1359 provider: PROVIDER,
1360 operation: "sendTurn",
1361 issue: "Turn input must be non-empty.",
1362 });
1363 }
1364
1348 const turnId = ProviderTurnId.makeUnsafe(yield* Random.nextUUIDv4);1365 const turnId = ProviderTurnId.makeUnsafe(yield* Random.nextUUIDv4);
1349 const turnState: CursorTurnState = {1366 const turnState: CursorTurnState = {
1350 turnId,1367 turnId,
1351 assistantItemId: asProviderItemId(yield* Random.nextUUIDv4),1368 assistantItemId: asProviderItemId(yield* Random.nextUUIDv4),
1352 startedToolCalls: new Set(),1369 startedToolCalls: new Set(),
1370 toolCalls: new Map(),
1353 items: [],1371 items: [],
1354 };1372 };
13551373
...@@ -1381,15 +1399,6 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1381,15 +1399,6 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1381 },1399 },
1382 });1400 });
13831401
1384 const promptText = input.input?.trim();
1385 if (!promptText || promptText.length === 0) {
1386 return yield* new ProviderAdapterValidationError({
1387 provider: PROVIDER,
1388 operation: "sendTurn",
1389 issue: "Turn input must be non-empty.",
1390 });
1391 }
1392
1393 const promptResultRaw = yield* Effect.tryPromise({1402 const promptResultRaw = yield* Effect.tryPromise({
1394 try: async () =>1403 try: async () =>
1395 sendRequest(context, "session/prompt", {1404 sendRequest(context, "session/prompt", {
apps/web/src/appSettings.test.ts+41-4
...@@ -15,11 +15,22 @@ describe("normalizeCustomModelSlugs", () => {...@@ -15,11 +15,22 @@ describe("normalizeCustomModelSlugs", () => {
15 ]),15 ]),
16 ).toEqual(["custom/internal-model"]);16 ).toEqual(["custom/internal-model"]);
17 });17 });
18
19 it("normalizes provider-specific aliases for claude and cursor", () => {
20 expect(normalizeCustomModelSlugs(["sonnet"], "claudeCode")).toEqual([]);
21 expect(normalizeCustomModelSlugs(["claude/custom-sonnet"], "claudeCode")).toEqual([
22 "claude/custom-sonnet",
23 ]);
24 expect(normalizeCustomModelSlugs(["composer"], "cursor")).toEqual([]);
25 expect(normalizeCustomModelSlugs(["cursor/custom-model"], "cursor")).toEqual([
26 "cursor/custom-model",
27 ]);
28 });
18});29});
1930
20describe("getAppModelOptions", () => {31describe("getAppModelOptions", () => {
21 it("appends saved custom models after the built-in options", () => {32 it("appends saved custom models after the built-in options", () => {
22 const options = getAppModelOptions(["custom/internal-model"]);33 const options = getAppModelOptions("codex", ["custom/internal-model"]);
2334
24 expect(options.map((option) => option.slug)).toEqual([35 expect(options.map((option) => option.slug)).toEqual([
25 "gpt-5.3-codex",36 "gpt-5.3-codex",
...@@ -31,7 +42,7 @@ describe("getAppModelOptions", () => {...@@ -31,7 +42,7 @@ describe("getAppModelOptions", () => {
31 });42 });
3243
33 it("keeps the currently selected custom model available even if it is no longer saved", () => {44 it("keeps the currently selected custom model available even if it is no longer saved", () => {
34 const options = getAppModelOptions([], "custom/selected-model");45 const options = getAppModelOptions("codex", [], "custom/selected-model");
3546
36 expect(options.at(-1)).toEqual({47 expect(options.at(-1)).toEqual({
37 slug: "custom/selected-model",48 slug: "custom/selected-model",
...@@ -39,18 +50,44 @@ describe("getAppModelOptions", () => {...@@ -39,18 +50,44 @@ describe("getAppModelOptions", () => {
39 isCustom: true,50 isCustom: true,
40 });51 });
41 });52 });
53
54 it("keeps a saved custom provider model available as an exact slug option", () => {
55 const options = getAppModelOptions("claudeCode", ["claude/custom-opus"], "claude/custom-opus");
56
57 expect(options.some((option) => option.slug === "claude/custom-opus" && option.isCustom)).toBe(
58 true,
59 );
60 });
42});61});
4362
44describe("getSlashModelOptions", () => {63describe("getSlashModelOptions", () => {
45 it("includes saved custom model slugs for /model command suggestions", () => {64 it("includes saved custom model slugs for /model command suggestions", () => {
46 const options = getSlashModelOptions(["custom/internal-model"], "", "gpt-5.3-codex");65 const options = getSlashModelOptions(
66 "codex",
67 ["custom/internal-model"],
68 "",
69 "gpt-5.3-codex",
70 );
4771
48 expect(options.some((option) => option.slug === "custom/internal-model")).toBe(true);72 expect(options.some((option) => option.slug === "custom/internal-model")).toBe(true);
49 });73 });
5074
51 it("filters slash-model suggestions across built-in and custom model names", () => {75 it("filters slash-model suggestions across built-in and custom model names", () => {
52 const options = getSlashModelOptions(["openai/gpt-oss-120b"], "oss", "gpt-5.3-codex");76 const options = getSlashModelOptions(
77 "codex",
78 ["openai/gpt-oss-120b"],
79 "oss",
80 "gpt-5.3-codex",
81 );
5382
54 expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]);83 expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]);
55 });84 });
85
86 it("includes provider-specific custom slugs in non-codex model lists", () => {
87 const claudeOptions = getAppModelOptions("claudeCode", ["claude/custom-opus"]);
88 const cursorOptions = getAppModelOptions("cursor", ["cursor/custom-model"]);
89
90 expect(claudeOptions.some((option) => option.slug === "claude/custom-opus")).toBe(true);
91 expect(cursorOptions.some((option) => option.slug === "cursor/custom-model")).toBe(true);
92 });
56});93});
apps/web/src/appSettings.ts+26-10
...@@ -1,11 +1,15 @@...@@ -1,11 +1,15 @@
1import { useCallback, useSyncExternalStore } from "react";1import { useCallback, useSyncExternalStore } from "react";
2import { Option, Schema } from "effect";2import { Option, Schema } from "effect";
3import { MODEL_OPTIONS, normalizeModelSlug } from "@t3tools/contracts";3import { getModelOptions, normalizeModelSlug, type ProviderKind } from "@t3tools/contracts";
44
5const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";5const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
6const MAX_CUSTOM_MODEL_COUNT = 32;6const MAX_CUSTOM_MODEL_COUNT = 32;
7export const MAX_CUSTOM_MODEL_LENGTH = 256;7export const MAX_CUSTOM_MODEL_LENGTH = 256;
8const BUILT_IN_MODEL_SLUGS = new Set<string>(MODEL_OPTIONS.map((option) => option.slug));8const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {
9 codex: new Set(getModelOptions("codex").map((option) => option.slug)),
10 claudeCode: new Set(getModelOptions("claudeCode").map((option) => option.slug)),
11 cursor: new Set(getModelOptions("cursor").map((option) => option.slug)),
12};
913
10const AppSettingsSchema = Schema.Struct({14const AppSettingsSchema = Schema.Struct({
11 codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(15 codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(
...@@ -21,6 +25,12 @@ const AppSettingsSchema = Schema.Struct({...@@ -21,6 +25,12 @@ const AppSettingsSchema = Schema.Struct({
21 customCodexModels: Schema.Array(Schema.String).pipe(25 customCodexModels: Schema.Array(Schema.String).pipe(
22 Schema.withConstructorDefault(() => Option.some([])),26 Schema.withConstructorDefault(() => Option.some([])),
23 ),27 ),
28 customClaudeModels: Schema.Array(Schema.String).pipe(
29 Schema.withConstructorDefault(() => Option.some([])),
30 ),
31 customCursorModels: Schema.Array(Schema.String).pipe(
32 Schema.withConstructorDefault(() => Option.some([])),
33 ),
24});34});
25export type AppSettings = typeof AppSettingsSchema.Type;35export type AppSettings = typeof AppSettingsSchema.Type;
26export interface AppModelOption {36export interface AppModelOption {
...@@ -37,16 +47,18 @@ let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS;...@@ -37,16 +47,18 @@ let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS;
3747
38export function normalizeCustomModelSlugs(48export function normalizeCustomModelSlugs(
39 models: Iterable<string | null | undefined>,49 models: Iterable<string | null | undefined>,
40): AppSettings["customCodexModels"] {50 provider: ProviderKind = "codex",
51): string[] {
41 const normalizedModels: string[] = [];52 const normalizedModels: string[] = [];
42 const seen = new Set<string>();53 const seen = new Set<string>();
54 const builtInModelSlugs = BUILT_IN_MODEL_SLUGS_BY_PROVIDER[provider];
4355
44 for (const candidate of models) {56 for (const candidate of models) {
45 const normalized = normalizeModelSlug(candidate);57 const normalized = normalizeModelSlug(candidate, provider);
46 if (58 if (
47 !normalized ||59 !normalized ||
48 normalized.length > MAX_CUSTOM_MODEL_LENGTH ||60 normalized.length > MAX_CUSTOM_MODEL_LENGTH ||
49 BUILT_IN_MODEL_SLUGS.has(normalized) ||61 builtInModelSlugs.has(normalized) ||
50 seen.has(normalized)62 seen.has(normalized)
51 ) {63 ) {
52 continue;64 continue;
...@@ -65,22 +77,25 @@ export function normalizeCustomModelSlugs(...@@ -65,22 +77,25 @@ export function normalizeCustomModelSlugs(
65function normalizeAppSettings(settings: AppSettings): AppSettings {77function normalizeAppSettings(settings: AppSettings): AppSettings {
66 return {78 return {
67 ...settings,79 ...settings,
68 customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels),80 customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"),
81 customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeCode"),
82 customCursorModels: normalizeCustomModelSlugs(settings.customCursorModels, "cursor"),
69 };83 };
70}84}
7185
72export function getAppModelOptions(86export function getAppModelOptions(
87 provider: ProviderKind,
73 customModels: readonly string[],88 customModels: readonly string[],
74 selectedModel?: string | null,89 selectedModel?: string | null,
75): AppModelOption[] {90): AppModelOption[] {
76 const options: AppModelOption[] = MODEL_OPTIONS.map(({ slug, name }) => ({91 const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({
77 slug,92 slug,
78 name,93 name,
79 isCustom: false,94 isCustom: false,
80 }));95 }));
81 const seen = new Set(options.map((option) => option.slug));96 const seen = new Set(options.map((option) => option.slug));
8297
83 for (const slug of normalizeCustomModelSlugs(customModels)) {98 for (const slug of normalizeCustomModelSlugs(customModels, provider)) {
84 if (seen.has(slug)) {99 if (seen.has(slug)) {
85 continue;100 continue;
86 }101 }
...@@ -93,7 +108,7 @@ export function getAppModelOptions(...@@ -93,7 +108,7 @@ export function getAppModelOptions(
93 });108 });
94 }109 }
95110
96 const normalizedSelectedModel = normalizeModelSlug(selectedModel);111 const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider);
97 if (normalizedSelectedModel && !seen.has(normalizedSelectedModel)) {112 if (normalizedSelectedModel && !seen.has(normalizedSelectedModel)) {
98 options.push({113 options.push({
99 slug: normalizedSelectedModel,114 slug: normalizedSelectedModel,
...@@ -106,12 +121,13 @@ export function getAppModelOptions(...@@ -106,12 +121,13 @@ export function getAppModelOptions(
106}121}
107122
108export function getSlashModelOptions(123export function getSlashModelOptions(
124 provider: ProviderKind,
109 customModels: readonly string[],125 customModels: readonly string[],
110 query: string,126 query: string,
111 selectedModel?: string | null,127 selectedModel?: string | null,
112): AppModelOption[] {128): AppModelOption[] {
113 const normalizedQuery = query.trim().toLowerCase();129 const normalizedQuery = query.trim().toLowerCase();
114 const options = getAppModelOptions(customModels, selectedModel);130 const options = getAppModelOptions(provider, customModels, selectedModel);
115 if (!normalizedQuery) {131 if (!normalizedQuery) {
116 return options;132 return options;
117 }133 }
apps/web/src/components/ChatView.tsx+68-16
...@@ -12,7 +12,6 @@ import {...@@ -12,7 +12,6 @@ import {
12 getDefaultReasoningEffort,12 getDefaultReasoningEffort,
13 getCursorModelCapabilities,13 getCursorModelCapabilities,
14 getCursorModelFamilyOptions,14 getCursorModelFamilyOptions,
15 getModelOptions,
16 getReasoningEffortOptions,15 getReasoningEffortOptions,
17 type ProjectId,16 type ProjectId,
18 type ProjectEntry,17 type ProjectEntry,
...@@ -164,7 +163,7 @@ import { Toggle } from "./ui/toggle";...@@ -164,7 +163,7 @@ import { Toggle } from "./ui/toggle";
164import { SidebarTrigger } from "./ui/sidebar";163import { SidebarTrigger } from "./ui/sidebar";
165import { newCommandId, newMessageId } from "~/lib/utils";164import { newCommandId, newMessageId } from "~/lib/utils";
166import { readNativeApi } from "~/nativeApi";165import { readNativeApi } from "~/nativeApi";
167import { getAppModelOptions, getSlashModelOptions, useAppSettings } from "../appSettings";166import { getAppModelOptions, useAppSettings } from "../appSettings";
168import {167import {
169 type ComposerImageAttachment,168 type ComposerImageAttachment,
170 type DraftThreadEnvMode,169 type DraftThreadEnvMode,
...@@ -691,7 +690,28 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -691,7 +690,28 @@ export default function ChatView({ threadId }: ChatViewProps) {
691 selectedProvider,690 selectedProvider,
692 activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider) ?? DEFAULT_MODEL,691 activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider) ?? DEFAULT_MODEL,
693 );692 );
694 const selectedModel = resolveModelSlugForProvider(selectedProvider, composerDraft.model ?? baseThreadModel);693 const selectedModel = useMemo(() => {
694 const draftModel = composerDraft.model;
695 if (!draftModel) {
696 return baseThreadModel;
697 }
698
699 const providerOptions = getCustomModelOptionsByProvider(settings)[selectedProvider];
700 const directMatch = providerOptions.find((option) => option.slug === draftModel);
701 if (directMatch) {
702 return directMatch.slug as ModelSlug;
703 }
704
705 const normalizedDraftModel = normalizeModelSlug(draftModel, selectedProvider);
706 if (normalizedDraftModel) {
707 const normalizedMatch = providerOptions.find((option) => option.slug === normalizedDraftModel);
708 if (normalizedMatch) {
709 return normalizedMatch.slug as ModelSlug;
710 }
711 }
712
713 return resolveModelSlugForProvider(selectedProvider, draftModel);
714 }, [baseThreadModel, composerDraft.model, selectedProvider, settings]);
695 const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider);715 const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider);
696 const reasoningOptions = getReasoningEffortOptions(selectedProvider);716 const reasoningOptions = getReasoningEffortOptions(selectedProvider);
697 const supportsReasoningEffort = reasoningOptions.length > 0;717 const supportsReasoningEffort = reasoningOptions.length > 0;
...@@ -712,13 +732,27 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -712,13 +732,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
712 );732 );
713 const selectedModelForPicker =733 const selectedModelForPicker =
714 selectedProvider === "cursor" && selectedCursorModel ? selectedCursorModel.family : selectedModel;734 selectedProvider === "cursor" && selectedCursorModel ? selectedCursorModel.family : selectedModel;
735 const modelOptionsByProvider = useMemo(() => getCustomModelOptionsByProvider(settings), [settings]);
736 const selectedModelForPickerWithCustomFallback = useMemo(() => {
737 if (selectedProvider !== "cursor") {
738 const currentOptions = modelOptionsByProvider[selectedProvider];
739 return currentOptions.some((option) => option.slug === selectedModelForPicker)
740 ? selectedModelForPicker
741 : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker);
742 }
743
744 const currentOptions = modelOptionsByProvider.cursor;
745 return currentOptions.some((option) => option.slug === selectedModelForPicker)
746 ? selectedModelForPicker
747 : selectedModelForPicker;
748 }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]);
715 const searchableModelOptions = useMemo(749 const searchableModelOptions = useMemo(
716 () =>750 () =>
717 PROVIDER_OPTIONS.filter(751 PROVIDER_OPTIONS.filter(
718 (option) =>752 (option) =>
719 option.available && (lockedProvider === null || option.value === lockedProvider),753 option.available && (lockedProvider === null || option.value === lockedProvider),
720 ).flatMap((option) =>754 ).flatMap((option) =>
721 getProviderModelPickerOptions(option.value).map(({ slug, name }) => ({755 modelOptionsByProvider[option.value].map(({ slug, name }) => ({
722 provider: option.value,756 provider: option.value,
723 providerLabel: option.label,757 providerLabel: option.label,
724 slug,758 slug,
...@@ -728,7 +762,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -728,7 +762,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
728 searchProvider: option.label.toLowerCase(),762 searchProvider: option.label.toLowerCase(),
729 })),763 })),
730 ),764 ),
731 [lockedProvider],765 [lockedProvider, modelOptionsByProvider],
732 );766 );
733 const phase = derivePhase(activeThread?.session ?? null);767 const phase = derivePhase(activeThread?.session ?? null);
734 const isSendBusy = sendPhase !== "idle";768 const isSendBusy = sendPhase !== "idle";
...@@ -2822,8 +2856,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2822,8 +2856,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
2822 <span className="inline-flex">2856 <span className="inline-flex">
2823 <ProviderModelPicker2857 <ProviderModelPicker
2824 provider={selectedProvider}2858 provider={selectedProvider}
2825 model={selectedModelForPicker}2859 model={selectedModelForPickerWithCustomFallback}
2826 lockedProvider={lockedProvider}2860 lockedProvider={lockedProvider}
2861 modelOptionsByProvider={modelOptionsByProvider}
2827 disabled2862 disabled
2828 onProviderModelChange={onProviderModelSelect}2863 onProviderModelChange={onProviderModelSelect}
2829 />2864 />
...@@ -2837,8 +2872,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2837,8 +2872,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
2837 ) : (2872 ) : (
2838 <ProviderModelPicker2873 <ProviderModelPicker
2839 provider={selectedProvider}2874 provider={selectedProvider}
2840 model={selectedModelForPicker}2875 model={selectedModelForPickerWithCustomFallback}
2841 lockedProvider={lockedProvider}2876 lockedProvider={lockedProvider}
2877 modelOptionsByProvider={modelOptionsByProvider}
2842 onProviderModelChange={onProviderModelSelect}2878 onProviderModelChange={onProviderModelSelect}
2843 />2879 />
2844 )}2880 )}
...@@ -4040,11 +4076,22 @@ const COMING_SOON_PROVIDER_OPTIONS = [...@@ -4040,11 +4076,22 @@ const COMING_SOON_PROVIDER_OPTIONS = [
4040 { id: "gemini", label: "Gemini", icon: Gemini },4076 { id: "gemini", label: "Gemini", icon: Gemini },
4041] as const;4077] as const;
40424078
4043function getProviderModelPickerOptions(provider: ProviderKind) {4079function getCustomModelOptionsByProvider(settings: {
4044 if (provider === "cursor") {4080 customCodexModels: readonly string[];
4045 return getCursorModelFamilyOptions();4081 customClaudeModels: readonly string[];
4046 }4082 customCursorModels: readonly string[];
4047 return getModelOptions(provider);4083}): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> {
4084 const cursorFamilyOptions = getCursorModelFamilyOptions();
4085 return {
4086 codex: getAppModelOptions("codex", settings.customCodexModels),
4087 claudeCode: getAppModelOptions("claudeCode", settings.customClaudeModels),
4088 cursor: [
4089 ...cursorFamilyOptions,
4090 ...getAppModelOptions("cursor", settings.customCursorModels).filter(
4091 (option) => !cursorFamilyOptions.some((family) => family.slug === option.slug),
4092 ),
4093 ],
4094 };
4048}4095}
40494096
4050const PROVIDER_ICON_BY_PROVIDER: Record<ProviderKind, Icon> = {4097const PROVIDER_ICON_BY_PROVIDER: Record<ProviderKind, Icon> = {
...@@ -4056,13 +4103,13 @@ const PROVIDER_ICON_BY_PROVIDER: Record<ProviderKind, Icon> = {...@@ -4056,13 +4103,13 @@ const PROVIDER_ICON_BY_PROVIDER: Record<ProviderKind, Icon> = {
4056function resolveModelForProviderPicker(4103function resolveModelForProviderPicker(
4057 provider: ProviderKind,4104 provider: ProviderKind,
4058 value: string,4105 value: string,
4106 options: ReadonlyArray<{ slug: string; name: string }>,
4059): ModelSlug | null {4107): ModelSlug | null {
4060 const trimmedValue = value.trim();4108 const trimmedValue = value.trim();
4061 if (!trimmedValue) {4109 if (!trimmedValue) {
4062 return null;4110 return null;
4063 }4111 }
40644112
4065 const options = getProviderModelPickerOptions(provider);
4066 const direct = options.find((option) => option.slug === trimmedValue);4113 const direct = options.find((option) => option.slug === trimmedValue);
4067 if (direct) {4114 if (direct) {
4068 return direct.slug;4115 return direct.slug;
...@@ -4094,11 +4141,12 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -4094,11 +4141,12 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
4094 provider: ProviderKind;4141 provider: ProviderKind;
4095 model: ModelSlug;4142 model: ModelSlug;
4096 lockedProvider: ProviderKind | null;4143 lockedProvider: ProviderKind | null;
4144 modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;
4097 disabled?: boolean;4145 disabled?: boolean;
4098 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;4146 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
4099}) {4147}) {
4100 const [isMenuOpen, setIsMenuOpen] = useState(false);4148 const [isMenuOpen, setIsMenuOpen] = useState(false);
4101 const selectedProviderOptions = getProviderModelPickerOptions(props.provider);4149 const selectedProviderOptions = props.modelOptionsByProvider[props.provider];
4102 const selectedModelLabel =4150 const selectedModelLabel =
4103 selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model;4151 selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model;
4104 const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider];4152 const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider];
...@@ -4155,13 +4203,17 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -4155,13 +4203,17 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
4155 if (props.disabled) return;4203 if (props.disabled) return;
4156 if (isDisabledByProviderLock) return;4204 if (isDisabledByProviderLock) return;
4157 if (!value) return;4205 if (!value) return;
4158 const resolvedModel = resolveModelForProviderPicker(option.value, value);4206 const resolvedModel = resolveModelForProviderPicker(
4207 option.value,
4208 value,
4209 props.modelOptionsByProvider[option.value],
4210 );
4159 if (!resolvedModel) return;4211 if (!resolvedModel) return;
4160 props.onProviderModelChange(option.value, resolvedModel);4212 props.onProviderModelChange(option.value, resolvedModel);
4161 setIsMenuOpen(false);4213 setIsMenuOpen(false);
4162 }}4214 }}
4163 >4215 >
4164 {getProviderModelPickerOptions(option.value).map((modelOption) => (4216 {props.modelOptionsByProvider[option.value].map((modelOption) => (
4165 <MenuRadioItem4217 <MenuRadioItem
4166 key={`${option.value}:${modelOption.slug}`}4218 key={`${option.value}:${modelOption.slug}`}
4167 value={modelOption.slug}4219 value={modelOption.slug}
apps/web/src/routes/_chat.settings.tsx+241-98
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { createFileRoute } from "@tanstack/react-router";1import { createFileRoute } from "@tanstack/react-router";
2import { useQuery } from "@tanstack/react-query";2import { useQuery } from "@tanstack/react-query";
3import { useCallback, useState } from "react";3import { useCallback, useState } from "react";
4import { MODEL_OPTIONS, normalizeModelSlug } from "@t3tools/contracts";4import { getModelOptions, normalizeModelSlug, type ProviderKind } from "@t3tools/contracts";
55
6import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings";6import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings";
7import { isElectron } from "../env";7import { isElectron } from "../env";
...@@ -46,6 +46,78 @@ const RUNTIME_MODE_OPTIONS = [...@@ -46,6 +46,78 @@ const RUNTIME_MODE_OPTIONS = [
46 },46 },
47] as const;47] as const;
4848
49const MODEL_PROVIDER_SETTINGS: Array<{
50 provider: ProviderKind;
51 title: string;
52 description: string;
53 placeholder: string;
54 example: string;
55}> = [
56 {
57 provider: "codex",
58 title: "Codex",
59 description: "Save additional Codex model slugs for the picker and `/model` command.",
60 placeholder: "your-codex-model-slug",
61 example: "gpt-6.7-codex-ultra-preview",
62 },
63 {
64 provider: "claudeCode",
65 title: "Claude Code",
66 description: "Save additional Claude model slugs for the picker and `/model` command.",
67 placeholder: "your-claude-model-slug",
68 example: "claude-sonnet-5-0",
69 },
70 {
71 provider: "cursor",
72 title: "Cursor",
73 description: "Save additional Cursor model slugs for the picker and `/model` command.",
74 placeholder: "your-cursor-model-slug",
75 example: "openai/gpt-oss-120b",
76 },
77] as const;
78
79function getCustomModelsForProvider(
80 settings: ReturnType<typeof useAppSettings>["settings"],
81 provider: ProviderKind,
82) {
83 switch (provider) {
84 case "claudeCode":
85 return settings.customClaudeModels;
86 case "cursor":
87 return settings.customCursorModels;
88 case "codex":
89 default:
90 return settings.customCodexModels;
91 }
92}
93
94function getDefaultCustomModelsForProvider(
95 defaults: ReturnType<typeof useAppSettings>["defaults"],
96 provider: ProviderKind,
97) {
98 switch (provider) {
99 case "claudeCode":
100 return defaults.customClaudeModels;
101 case "cursor":
102 return defaults.customCursorModels;
103 case "codex":
104 default:
105 return defaults.customCodexModels;
106 }
107}
108
109function patchCustomModels(provider: ProviderKind, models: string[]) {
110 switch (provider) {
111 case "claudeCode":
112 return { customClaudeModels: models };
113 case "cursor":
114 return { customCursorModels: models };
115 case "codex":
116 default:
117 return { customCodexModels: models };
118 }
119}
120
49function SettingsRouteView() {121function SettingsRouteView() {
50 const { theme, setTheme, resolvedTheme } = useTheme();122 const { theme, setTheme, resolvedTheme } = useTheme();
51 const runtimeMode = useStore((store) => store.runtimeMode);123 const runtimeMode = useStore((store) => store.runtimeMode);
...@@ -54,12 +126,19 @@ function SettingsRouteView() {...@@ -54,12 +126,19 @@ function SettingsRouteView() {
54 const serverConfigQuery = useQuery(serverConfigQueryOptions());126 const serverConfigQuery = useQuery(serverConfigQueryOptions());
55 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);127 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);
56 const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null);128 const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null);
57 const [customModelInput, setCustomModelInput] = useState("");129 const [customModelInputByProvider, setCustomModelInputByProvider] = useState<
58 const [customModelError, setCustomModelError] = useState<string | null>(null);130 Record<ProviderKind, string>
131 >({
132 codex: "",
133 claudeCode: "",
134 cursor: "",
135 });
136 const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<
137 Partial<Record<ProviderKind, string | null>>
138 >({});
59139
60 const codexBinaryPath = settings.codexBinaryPath;140 const codexBinaryPath = settings.codexBinaryPath;
61 const codexHomePath = settings.codexHomePath;141 const codexHomePath = settings.codexHomePath;
62 const customCodexModels = settings.customCodexModels;
63 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;142 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
64143
65 const openKeybindingsFile = useCallback(() => {144 const openKeybindingsFile = useCallback(() => {
...@@ -79,40 +158,60 @@ function SettingsRouteView() {...@@ -79,40 +158,60 @@ function SettingsRouteView() {
79 });158 });
80 }, [keybindingsConfigPath]);159 }, [keybindingsConfigPath]);
81160
82 const addCustomModel = useCallback(() => {161 const addCustomModel = useCallback((provider: ProviderKind) => {
83 const normalized = normalizeModelSlug(customModelInput);162 const customModelInput = customModelInputByProvider[provider];
163 const customModels = getCustomModelsForProvider(settings, provider);
164 const normalized = normalizeModelSlug(customModelInput, provider);
84 if (!normalized) {165 if (!normalized) {
85 setCustomModelError("Enter a model slug.");166 setCustomModelErrorByProvider((existing) => ({
167 ...existing,
168 [provider]: "Enter a model slug.",
169 }));
86 return;170 return;
87 }171 }
88 if (MODEL_OPTIONS.some((option) => option.slug === normalized)) {172 if (getModelOptions(provider).some((option) => option.slug === normalized)) {
89 setCustomModelError("That model is already built in.");173 setCustomModelErrorByProvider((existing) => ({
174 ...existing,
175 [provider]: "That model is already built in.",
176 }));
90 return;177 return;
91 }178 }
92 if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) {179 if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) {
93 setCustomModelError(`Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`);180 setCustomModelErrorByProvider((existing) => ({
181 ...existing,
182 [provider]: `Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`,
183 }));
94 return;184 return;
95 }185 }
96 if (customCodexModels.includes(normalized)) {186 if (customModels.includes(normalized)) {
97 setCustomModelError("That custom model is already saved.");187 setCustomModelErrorByProvider((existing) => ({
188 ...existing,
189 [provider]: "That custom model is already saved.",
190 }));
98 return;191 return;
99 }192 }
100193
101 updateSettings({194 updateSettings(patchCustomModels(provider, [...customModels, normalized]));
102 customCodexModels: [...customCodexModels, normalized],195 setCustomModelInputByProvider((existing) => ({
103 });196 ...existing,
104 setCustomModelInput("");197 [provider]: "",
105 setCustomModelError(null);198 }));
106 }, [customCodexModels, customModelInput, updateSettings]);199 setCustomModelErrorByProvider((existing) => ({
200 ...existing,
201 [provider]: null,
202 }));
203 }, [customModelInputByProvider, settings, updateSettings]);
107204
108 const removeCustomModel = useCallback(205 const removeCustomModel = useCallback(
109 (slug: string) => {206 (provider: ProviderKind, slug: string) => {
110 updateSettings({207 const customModels = getCustomModelsForProvider(settings, provider);
111 customCodexModels: customCodexModels.filter((model) => model !== slug),208 updateSettings(patchCustomModels(provider, customModels.filter((model) => model !== slug)));
112 });209 setCustomModelErrorByProvider((existing) => ({
113 setCustomModelError(null);210 ...existing,
211 [provider]: null,
212 }));
114 },213 },
115 [customCodexModels, updateSettings],214 [settings, updateSettings],
116 );215 );
117216
118 return (217 return (
...@@ -240,89 +339,133 @@ function SettingsRouteView() {...@@ -240,89 +339,133 @@ function SettingsRouteView() {
240 <div className="mb-4">339 <div className="mb-4">
241 <h2 className="text-sm font-medium text-foreground">Models</h2>340 <h2 className="text-sm font-medium text-foreground">Models</h2>
242 <p className="mt-1 text-xs text-muted-foreground">341 <p className="mt-1 text-xs text-muted-foreground">
243 Save additional Codex model slugs so they appear in the chat model picker.342 Save additional provider model slugs so they appear in the chat model picker and
343 `/model` command suggestions.
244 </p>344 </p>
245 </div>345 </div>
246346
247 <div className="space-y-4">347 <div className="space-y-5">
248 <div className="flex flex-col gap-3 sm:flex-row sm:items-start">348 {MODEL_PROVIDER_SETTINGS.map((providerSettings) => {
249 <label htmlFor="custom-model-slug" className="block flex-1 space-y-1">349 const provider = providerSettings.provider;
250 <span className="text-xs font-medium text-foreground">Custom model slug</span>350 const customModels = getCustomModelsForProvider(settings, provider);
251 <Input351 const customModelInput = customModelInputByProvider[provider];
252 id="custom-model-slug"352 const customModelError = customModelErrorByProvider[provider] ?? null;
253 value={customModelInput}353 return (
254 onChange={(event) => {354 <div
255 setCustomModelInput(event.target.value);355 key={provider}
256 if (customModelError) {356 className="rounded-xl border border-border bg-background/50 p-4"
257 setCustomModelError(null);357 >
258 }358 <div className="mb-4">
259 }}359 <h3 className="text-sm font-medium text-foreground">
260 onKeyDown={(event) => {360 {providerSettings.title}
261 if (event.key !== "Enter") return;361 </h3>
262 event.preventDefault();362 <p className="mt-1 text-xs text-muted-foreground">
263 addCustomModel();363 {providerSettings.description}
264 }}364 </p>
265 placeholder="your-model-slug"365 </div>
266 spellCheck={false}366
267 />367 <div className="space-y-4">
268 <span className="text-xs text-muted-foreground">368 <div className="flex flex-col gap-3 sm:flex-row sm:items-start">
269 Example: <code>gpt-6.7-codex-ultra-preview</code>369 <label
270 </span>370 htmlFor={`custom-model-slug-${provider}`}
271 </label>371 className="block flex-1 space-y-1"
272372 >
273 <Button className="sm:mt-6" type="button" onClick={addCustomModel}>373 <span className="text-xs font-medium text-foreground">
274 Add model374 Custom model slug
275 </Button>375 </span>
276 </div>376 <Input
277377 id={`custom-model-slug-${provider}`}
278 {customModelError ? (378 value={customModelInput}
279 <p className="text-xs text-destructive">{customModelError}</p>379 onChange={(event) => {
280 ) : null}380 const value = event.target.value;
281381 setCustomModelInputByProvider((existing) => ({
282 <div className="space-y-2">382 ...existing,
283 <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">383 [provider]: value,
284 <p>Saved custom models: {customCodexModels.length}</p>384 }));
285 {customCodexModels.length > 0 ? (385 if (customModelError) {
286 <Button386 setCustomModelErrorByProvider((existing) => ({
287 size="xs"387 ...existing,
288 variant="outline"388 [provider]: null,
289 onClick={() =>389 }));
290 updateSettings({390 }
291 customCodexModels: defaults.customCodexModels,391 }}
292 })392 onKeyDown={(event) => {
293 }393 if (event.key !== "Enter") return;
294 >394 event.preventDefault();
295 Reset custom models395 addCustomModel(provider);
296 </Button>396 }}
297 ) : null}397 placeholder={providerSettings.placeholder}
298 </div>398 spellCheck={false}
399 />
400 <span className="text-xs text-muted-foreground">
401 Example: <code>{providerSettings.example}</code>
402 </span>
403 </label>
299404
300 {customCodexModels.length > 0 ? (
301 <div className="space-y-2">
302 {customCodexModels.map((slug) => (
303 <div
304 key={slug}
305 className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background px-3 py-2"
306 >
307 <code className="min-w-0 flex-1 truncate text-xs text-foreground">
308 {slug}
309 </code>
310 <Button405 <Button
311 size="xs"406 className="sm:mt-6"
312 variant="ghost"407 type="button"
313 onClick={() => removeCustomModel(slug)}408 onClick={() => addCustomModel(provider)}
314 >409 >
315 Remove410 Add model
316 </Button>411 </Button>
317 </div>412 </div>
318 ))}413
319 </div>414 {customModelError ? (
320 ) : (415 <p className="text-xs text-destructive">{customModelError}</p>
321 <div className="rounded-lg border border-dashed border-border bg-background px-3 py-4 text-xs text-muted-foreground">416 ) : null}
322 No custom models saved yet.417
418 <div className="space-y-2">
419 <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
420 <p>Saved custom models: {customModels.length}</p>
421 {customModels.length > 0 ? (
422 <Button
423 size="xs"
424 variant="outline"
425 onClick={() =>
426 updateSettings(
427 patchCustomModels(
428 provider,
429 [...getDefaultCustomModelsForProvider(defaults, provider)],
430 ),
431 )
432 }
433 >
434 Reset custom models
435 </Button>
436 ) : null}
437 </div>
438
439 {customModels.length > 0 ? (
440 <div className="space-y-2">
441 {customModels.map((slug) => (
442 <div
443 key={`${provider}:${slug}`}
444 className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background px-3 py-2"
445 >
446 <code className="min-w-0 flex-1 truncate text-xs text-foreground">
447 {slug}
448 </code>
449 <Button
450 size="xs"
451 variant="ghost"
452 onClick={() => removeCustomModel(provider, slug)}
453 >
454 Remove
455 </Button>
456 </div>
457 ))}
458 </div>
459 ) : (
460 <div className="rounded-lg border border-dashed border-border bg-background px-3 py-4 text-xs text-muted-foreground">
461 No custom models saved yet.
462 </div>
463 )}
464 </div>
465 </div>
323 </div>466 </div>
324 )}467 );
325 </div>468 })}
326 </div>469 </div>
327 </section>470 </section>
328471
apps/web/src/store.ts+3-3
...@@ -164,9 +164,9 @@ function toLegacyProvider(providerName: string | null): ProviderKind {...@@ -164,9 +164,9 @@ function toLegacyProvider(providerName: string | null): ProviderKind {
164 return "codex";164 return "codex";
165}165}
166166
167const CODEX_MODEL_SLUGS = new Set(getModelOptions("codex").map((option) => option.slug));167const CODEX_MODEL_SLUGS = new Set<string>(getModelOptions("codex").map((option) => option.slug));
168const CLAUDE_MODEL_SLUGS = new Set(getModelOptions("claudeCode").map((option) => option.slug));168const CLAUDE_MODEL_SLUGS = new Set<string>(getModelOptions("claudeCode").map((option) => option.slug));
169const CURSOR_MODEL_SLUGS = new Set(getModelOptions("cursor").map((option) => option.slug));169const CURSOR_MODEL_SLUGS = new Set<string>(getModelOptions("cursor").map((option) => option.slug));
170const CURSOR_DISTINCT_MODEL_SLUGS = new Set(170const CURSOR_DISTINCT_MODEL_SLUGS = new Set(
171 [...CURSOR_MODEL_SLUGS].filter(171 [...CURSOR_MODEL_SLUGS].filter(
172 (slug) => !CODEX_MODEL_SLUGS.has(slug) && !CLAUDE_MODEL_SLUGS.has(slug),172 (slug) => !CODEX_MODEL_SLUGS.has(slug) && !CLAUDE_MODEL_SLUGS.has(slug),
packages/contracts/src/model.test.ts+6-9
...@@ -49,7 +49,7 @@ describe("normalizeModelSlug", () => {...@@ -49,7 +49,7 @@ describe("normalizeModelSlug", () => {
49 expect(normalizeModelSlug("gpt-5.3-codex-spark", "cursor")).toBe(49 expect(normalizeModelSlug("gpt-5.3-codex-spark", "cursor")).toBe(
50 "gpt-5.3-codex-spark-preview",50 "gpt-5.3-codex-spark-preview",
51 );51 );
52 expect(normalizeModelSlug("gemini-3.1", "cursor")).toBe("gemini-3.1-pro");52 expect(normalizeModelSlug("gemini-3.1", "cursor")).toBe("gemini-3.1");
53 expect(normalizeModelSlug("claude-4.6-sonnet-thinking", "cursor")).toBe(53 expect(normalizeModelSlug("claude-4.6-sonnet-thinking", "cursor")).toBe(
54 "sonnet-4.6-thinking",54 "sonnet-4.6-thinking",
55 );55 );
...@@ -63,8 +63,8 @@ describe("resolveModelSlug", () => {...@@ -63,8 +63,8 @@ describe("resolveModelSlug", () => {
63 });63 });
6464
65 it("preserves unknown custom models", () => {65 it("preserves unknown custom models", () => {
66 expect(resolveModelSlug("gpt-4.1")).toBe("gpt-4.1");66 expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL);
67 expect(resolveModelSlug("custom/internal-model")).toBe("custom/internal-model");67 expect(resolveModelSlug("custom/internal-model")).toBe(DEFAULT_MODEL);
68 });68 });
6969
70 it("resolves only supported model options", () => {70 it("resolves only supported model options", () => {
...@@ -116,12 +116,9 @@ describe("cursor model selection", () => {...@@ -116,12 +116,9 @@ describe("cursor model selection", () => {
116 fast: true,116 fast: true,
117 thinking: false,117 thinking: false,
118 });118 });
119 expect(parseCursorModelSelection("gpt-5.2-codex")).toEqual({119 expect(parseCursorModelSelection("gpt-5.2-codex")).toEqual(
120 family: "gpt-5.3-codex",120 parseCursorModelSelection(DEFAULT_MODEL_BY_PROVIDER.cursor),
121 reasoning: "normal",121 );
122 fast: false,
123 thinking: false,
124 });
125 });122 });
126123
127 it("parses and resolves thinking variants", () => {124 it("parses and resolves thinking variants", () => {
packages/contracts/src/model.ts+2-1
...@@ -62,7 +62,8 @@ export const MODEL_OPTIONS_BY_PROVIDER = {...@@ -62,7 +62,8 @@ export const MODEL_OPTIONS_BY_PROVIDER = {
62 ],62 ],
63} as const satisfies Record<ProviderKind, readonly ModelOption[]>;63} as const satisfies Record<ProviderKind, readonly ModelOption[]>;
6464
65export type ModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"];65type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"];
66export type ModelSlug = BuiltInModelSlug | (string & {});
66export type CursorModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)["cursor"][number]["slug"];67export type CursorModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)["cursor"][number]["slug"];
6768
68type CursorModelCapability = {69type CursorModelCapability = {