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) => {
284284 assert.equal(firstEvent.value.payload.reason, "Session stopped");
285285 }),
286286 );
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 );
287323});
288324
289325afterAll(() => {
apps/server/src/provider/Layers/CodexAdapter.ts+20-1
......@@ -239,6 +239,19 @@ function toRequestTypeFromKind(kind: unknown): CanonicalRequestType {
239239 }
240240}
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
242255function toThreadState(value: unknown):
243256 | "active"
244257 | "idle"
......@@ -714,12 +727,18 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
714727 }
715728
716729 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";
717736 return [
718737 {
719738 ...runtimeEventBase(event),
720739 type: "request.resolved",
721740 payload: {
722 requestType: "unknown",
741 requestType,
723742 ...(event.payload !== undefined ? { resolution: event.payload } : {}),
724743 },
725744 },
apps/server/src/provider/Layers/CursorAdapter.test.ts+82
......@@ -543,4 +543,86 @@ describe("CursorAdapterLive", () => {
543543 assert.equal(fake.lastPermissionSelection, "allow-always");
544544 }).pipe(Effect.provide(layer));
545545 });
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 });
546628});
apps/server/src/provider/Layers/CursorAdapter.ts+20-11
......@@ -75,6 +75,7 @@ interface CursorTurnState {
7575 readonly turnId: ReturnType<typeof ProviderTurnId.makeUnsafe>;
7676 readonly assistantItemId: ReturnType<typeof ProviderItemId.makeUnsafe>;
7777 readonly startedToolCalls: Set<string>;
78 readonly toolCalls: Map<string, { itemType: CanonicalItemType; title: string }>;
7879 readonly items: Array<unknown>;
7980}
8081
......@@ -734,6 +735,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
734735 const seen = context.turnState.startedToolCalls.has(update.toolCallId);
735736 const itemType = normalizeToolItemType(update.kind, update.title);
736737 const title = update.title ?? titleForItemType(itemType);
738 context.turnState.toolCalls.set(update.toolCallId, { itemType, title });
737739 const detail = asString(asObject(update.rawInput)?.command);
738740
739741 if (!seen) {
......@@ -779,6 +781,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
779781 case "tool_call_update": {
780782 if (!context.turnState) return;
781783 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);
782787 const stamp = yield* makeEventStamp();
783788 const eventType = update.status === "completed" ? "item.completed" : "item.updated";
784789 yield* offerRuntimeEvent({
......@@ -789,15 +794,18 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
789794 turnId: asRuntimeTurnId(context.turnState.turnId),
790795 itemId: asRuntimeItemId(update.toolCallId),
791796 payload: {
792 itemType: "command_execution",
797 itemType,
793798 status,
794 title: "Tool call",
799 title,
795800 ...(summarizeToolOutput(update.rawOutput)
796801 ? { detail: summarizeToolOutput(update.rawOutput) }
797802 : {}),
798803 ...(update.rawOutput !== undefined ? { data: update.rawOutput } : {}),
799804 },
800805 });
806 if (update.status === "completed") {
807 context.turnState.toolCalls.delete(update.toolCallId);
808 }
801809 return;
802810 }
803811 }
......@@ -1345,11 +1353,21 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
13451353 });
13461354 }
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
13481365 const turnId = ProviderTurnId.makeUnsafe(yield* Random.nextUUIDv4);
13491366 const turnState: CursorTurnState = {
13501367 turnId,
13511368 assistantItemId: asProviderItemId(yield* Random.nextUUIDv4),
13521369 startedToolCalls: new Set(),
1370 toolCalls: new Map(),
13531371 items: [],
13541372 };
13551373
......@@ -1381,15 +1399,6 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
13811399 },
13821400 });
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
13931402 const promptResultRaw = yield* Effect.tryPromise({
13941403 try: async () =>
13951404 sendRequest(context, "session/prompt", {
apps/web/src/appSettings.test.ts+41-4
......@@ -15,11 +15,22 @@ describe("normalizeCustomModelSlugs", () => {
1515 ]),
1616 ).toEqual(["custom/internal-model"]);
1717 });
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 });
1829});
1930
2031describe("getAppModelOptions", () => {
2132 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
2435 expect(options.map((option) => option.slug)).toEqual([
2536 "gpt-5.3-codex",
......@@ -31,7 +42,7 @@ describe("getAppModelOptions", () => {
3142 });
3243
3344 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
3647 expect(options.at(-1)).toEqual({
3748 slug: "custom/selected-model",
......@@ -39,18 +50,44 @@ describe("getAppModelOptions", () => {
3950 isCustom: true,
4051 });
4152 });
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 });
4261});
4362
4463describe("getSlashModelOptions", () => {
4564 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
4872 expect(options.some((option) => option.slug === "custom/internal-model")).toBe(true);
4973 });
5074
5175 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
5483 expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]);
5584 });
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 });
5693});
apps/web/src/appSettings.ts+26-10
......@@ -1,11 +1,15 @@
11import { useCallback, useSyncExternalStore } from "react";
22import { Option, Schema } from "effect";
3import { MODEL_OPTIONS, normalizeModelSlug } from "@t3tools/contracts";
3import { getModelOptions, normalizeModelSlug, type ProviderKind } from "@t3tools/contracts";
44
55const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
66const MAX_CUSTOM_MODEL_COUNT = 32;
77export 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
1014const AppSettingsSchema = Schema.Struct({
1115 codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(
......@@ -21,6 +25,12 @@ const AppSettingsSchema = Schema.Struct({
2125 customCodexModels: Schema.Array(Schema.String).pipe(
2226 Schema.withConstructorDefault(() => Option.some([])),
2327 ),
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 ),
2434});
2535export type AppSettings = typeof AppSettingsSchema.Type;
2636export interface AppModelOption {
......@@ -37,16 +47,18 @@ let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS;
3747
3848export function normalizeCustomModelSlugs(
3949 models: Iterable<string | null | undefined>,
40): AppSettings["customCodexModels"] {
50 provider: ProviderKind = "codex",
51): string[] {
4152 const normalizedModels: string[] = [];
4253 const seen = new Set<string>();
54 const builtInModelSlugs = BUILT_IN_MODEL_SLUGS_BY_PROVIDER[provider];
4355
4456 for (const candidate of models) {
45 const normalized = normalizeModelSlug(candidate);
57 const normalized = normalizeModelSlug(candidate, provider);
4658 if (
4759 !normalized ||
4860 normalized.length > MAX_CUSTOM_MODEL_LENGTH ||
49 BUILT_IN_MODEL_SLUGS.has(normalized) ||
61 builtInModelSlugs.has(normalized) ||
5062 seen.has(normalized)
5163 ) {
5264 continue;
......@@ -65,22 +77,25 @@ export function normalizeCustomModelSlugs(
6577function normalizeAppSettings(settings: AppSettings): AppSettings {
6678 return {
6779 ...settings,
68 customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels),
80 customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"),
81 customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeCode"),
82 customCursorModels: normalizeCustomModelSlugs(settings.customCursorModels, "cursor"),
6983 };
7084}
7185
7286export function getAppModelOptions(
87 provider: ProviderKind,
7388 customModels: readonly string[],
7489 selectedModel?: string | null,
7590): AppModelOption[] {
76 const options: AppModelOption[] = MODEL_OPTIONS.map(({ slug, name }) => ({
91 const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({
7792 slug,
7893 name,
7994 isCustom: false,
8095 }));
8196 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)) {
8499 if (seen.has(slug)) {
85100 continue;
86101 }
......@@ -93,7 +108,7 @@ export function getAppModelOptions(
93108 });
94109 }
95110
96 const normalizedSelectedModel = normalizeModelSlug(selectedModel);
111 const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider);
97112 if (normalizedSelectedModel && !seen.has(normalizedSelectedModel)) {
98113 options.push({
99114 slug: normalizedSelectedModel,
......@@ -106,12 +121,13 @@ export function getAppModelOptions(
106121}
107122
108123export function getSlashModelOptions(
124 provider: ProviderKind,
109125 customModels: readonly string[],
110126 query: string,
111127 selectedModel?: string | null,
112128): AppModelOption[] {
113129 const normalizedQuery = query.trim().toLowerCase();
114 const options = getAppModelOptions(customModels, selectedModel);
130 const options = getAppModelOptions(provider, customModels, selectedModel);
115131 if (!normalizedQuery) {
116132 return options;
117133 }
apps/web/src/components/ChatView.tsx+68-16
......@@ -12,7 +12,6 @@ import {
1212 getDefaultReasoningEffort,
1313 getCursorModelCapabilities,
1414 getCursorModelFamilyOptions,
15 getModelOptions,
1615 getReasoningEffortOptions,
1716 type ProjectId,
1817 type ProjectEntry,
......@@ -164,7 +163,7 @@ import { Toggle } from "./ui/toggle";
164163import { SidebarTrigger } from "./ui/sidebar";
165164import { newCommandId, newMessageId } from "~/lib/utils";
166165import { readNativeApi } from "~/nativeApi";
167import { getAppModelOptions, getSlashModelOptions, useAppSettings } from "../appSettings";
166import { getAppModelOptions, useAppSettings } from "../appSettings";
168167import {
169168 type ComposerImageAttachment,
170169 type DraftThreadEnvMode,
......@@ -691,7 +690,28 @@ export default function ChatView({ threadId }: ChatViewProps) {
691690 selectedProvider,
692691 activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider) ?? DEFAULT_MODEL,
693692 );
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]);
695715 const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider);
696716 const reasoningOptions = getReasoningEffortOptions(selectedProvider);
697717 const supportsReasoningEffort = reasoningOptions.length > 0;
......@@ -712,13 +732,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
712732 );
713733 const selectedModelForPicker =
714734 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]);
715749 const searchableModelOptions = useMemo(
716750 () =>
717751 PROVIDER_OPTIONS.filter(
718752 (option) =>
719753 option.available && (lockedProvider === null || option.value === lockedProvider),
720754 ).flatMap((option) =>
721 getProviderModelPickerOptions(option.value).map(({ slug, name }) => ({
755 modelOptionsByProvider[option.value].map(({ slug, name }) => ({
722756 provider: option.value,
723757 providerLabel: option.label,
724758 slug,
......@@ -728,7 +762,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
728762 searchProvider: option.label.toLowerCase(),
729763 })),
730764 ),
731 [lockedProvider],
765 [lockedProvider, modelOptionsByProvider],
732766 );
733767 const phase = derivePhase(activeThread?.session ?? null);
734768 const isSendBusy = sendPhase !== "idle";
......@@ -2822,8 +2856,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
28222856 <span className="inline-flex">
28232857 <ProviderModelPicker
28242858 provider={selectedProvider}
2825 model={selectedModelForPicker}
2859 model={selectedModelForPickerWithCustomFallback}
28262860 lockedProvider={lockedProvider}
2861 modelOptionsByProvider={modelOptionsByProvider}
28272862 disabled
28282863 onProviderModelChange={onProviderModelSelect}
28292864 />
......@@ -2837,8 +2872,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
28372872 ) : (
28382873 <ProviderModelPicker
28392874 provider={selectedProvider}
2840 model={selectedModelForPicker}
2875 model={selectedModelForPickerWithCustomFallback}
28412876 lockedProvider={lockedProvider}
2877 modelOptionsByProvider={modelOptionsByProvider}
28422878 onProviderModelChange={onProviderModelSelect}
28432879 />
28442880 )}
......@@ -4040,11 +4076,22 @@ const COMING_SOON_PROVIDER_OPTIONS = [
40404076 { id: "gemini", label: "Gemini", icon: Gemini },
40414077] as const;
40424078
4043function getProviderModelPickerOptions(provider: ProviderKind) {
4044 if (provider === "cursor") {
4045 return getCursorModelFamilyOptions();
4046 }
4047 return getModelOptions(provider);
4079function getCustomModelOptionsByProvider(settings: {
4080 customCodexModels: readonly string[];
4081 customClaudeModels: readonly string[];
4082 customCursorModels: readonly string[];
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 };
40484095}
40494096
40504097const PROVIDER_ICON_BY_PROVIDER: Record<ProviderKind, Icon> = {
......@@ -4056,13 +4103,13 @@ const PROVIDER_ICON_BY_PROVIDER: Record<ProviderKind, Icon> = {
40564103function resolveModelForProviderPicker(
40574104 provider: ProviderKind,
40584105 value: string,
4106 options: ReadonlyArray<{ slug: string; name: string }>,
40594107): ModelSlug | null {
40604108 const trimmedValue = value.trim();
40614109 if (!trimmedValue) {
40624110 return null;
40634111 }
40644112
4065 const options = getProviderModelPickerOptions(provider);
40664113 const direct = options.find((option) => option.slug === trimmedValue);
40674114 if (direct) {
40684115 return direct.slug;
......@@ -4094,11 +4141,12 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
40944141 provider: ProviderKind;
40954142 model: ModelSlug;
40964143 lockedProvider: ProviderKind | null;
4144 modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;
40974145 disabled?: boolean;
40984146 onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void;
40994147}) {
41004148 const [isMenuOpen, setIsMenuOpen] = useState(false);
4101 const selectedProviderOptions = getProviderModelPickerOptions(props.provider);
4149 const selectedProviderOptions = props.modelOptionsByProvider[props.provider];
41024150 const selectedModelLabel =
41034151 selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model;
41044152 const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider];
......@@ -4155,13 +4203,17 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
41554203 if (props.disabled) return;
41564204 if (isDisabledByProviderLock) return;
41574205 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 );
41594211 if (!resolvedModel) return;
41604212 props.onProviderModelChange(option.value, resolvedModel);
41614213 setIsMenuOpen(false);
41624214 }}
41634215 >
4164 {getProviderModelPickerOptions(option.value).map((modelOption) => (
4216 {props.modelOptionsByProvider[option.value].map((modelOption) => (
41654217 <MenuRadioItem
41664218 key={`${option.value}:${modelOption.slug}`}
41674219 value={modelOption.slug}
apps/web/src/routes/_chat.settings.tsx+241-98
......@@ -1,7 +1,7 @@
11import { createFileRoute } from "@tanstack/react-router";
22import { useQuery } from "@tanstack/react-query";
33import { useCallback, useState } from "react";
4import { MODEL_OPTIONS, normalizeModelSlug } from "@t3tools/contracts";
4import { getModelOptions, normalizeModelSlug, type ProviderKind } from "@t3tools/contracts";
55
66import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings";
77import { isElectron } from "../env";
......@@ -46,6 +46,78 @@ const RUNTIME_MODE_OPTIONS = [
4646 },
4747] 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
49121function SettingsRouteView() {
50122 const { theme, setTheme, resolvedTheme } = useTheme();
51123 const runtimeMode = useStore((store) => store.runtimeMode);
......@@ -54,12 +126,19 @@ function SettingsRouteView() {
54126 const serverConfigQuery = useQuery(serverConfigQueryOptions());
55127 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);
56128 const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null);
57 const [customModelInput, setCustomModelInput] = useState("");
58 const [customModelError, setCustomModelError] = useState<string | null>(null);
129 const [customModelInputByProvider, setCustomModelInputByProvider] = useState<
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
60140 const codexBinaryPath = settings.codexBinaryPath;
61141 const codexHomePath = settings.codexHomePath;
62 const customCodexModels = settings.customCodexModels;
63142 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
64143
65144 const openKeybindingsFile = useCallback(() => {
......@@ -79,40 +158,60 @@ function SettingsRouteView() {
79158 });
80159 }, [keybindingsConfigPath]);
81160
82 const addCustomModel = useCallback(() => {
83 const normalized = normalizeModelSlug(customModelInput);
161 const addCustomModel = useCallback((provider: ProviderKind) => {
162 const customModelInput = customModelInputByProvider[provider];
163 const customModels = getCustomModelsForProvider(settings, provider);
164 const normalized = normalizeModelSlug(customModelInput, provider);
84165 if (!normalized) {
85 setCustomModelError("Enter a model slug.");
166 setCustomModelErrorByProvider((existing) => ({
167 ...existing,
168 [provider]: "Enter a model slug.",
169 }));
86170 return;
87171 }
88 if (MODEL_OPTIONS.some((option) => option.slug === normalized)) {
89 setCustomModelError("That model is already built in.");
172 if (getModelOptions(provider).some((option) => option.slug === normalized)) {
173 setCustomModelErrorByProvider((existing) => ({
174 ...existing,
175 [provider]: "That model is already built in.",
176 }));
90177 return;
91178 }
92179 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 }));
94184 return;
95185 }
96 if (customCodexModels.includes(normalized)) {
97 setCustomModelError("That custom model is already saved.");
186 if (customModels.includes(normalized)) {
187 setCustomModelErrorByProvider((existing) => ({
188 ...existing,
189 [provider]: "That custom model is already saved.",
190 }));
98191 return;
99192 }
100193
101 updateSettings({
102 customCodexModels: [...customCodexModels, normalized],
103 });
104 setCustomModelInput("");
105 setCustomModelError(null);
106 }, [customCodexModels, customModelInput, updateSettings]);
194 updateSettings(patchCustomModels(provider, [...customModels, normalized]));
195 setCustomModelInputByProvider((existing) => ({
196 ...existing,
197 [provider]: "",
198 }));
199 setCustomModelErrorByProvider((existing) => ({
200 ...existing,
201 [provider]: null,
202 }));
203 }, [customModelInputByProvider, settings, updateSettings]);
107204
108205 const removeCustomModel = useCallback(
109 (slug: string) => {
110 updateSettings({
111 customCodexModels: customCodexModels.filter((model) => model !== slug),
112 });
113 setCustomModelError(null);
206 (provider: ProviderKind, slug: string) => {
207 const customModels = getCustomModelsForProvider(settings, provider);
208 updateSettings(patchCustomModels(provider, customModels.filter((model) => model !== slug)));
209 setCustomModelErrorByProvider((existing) => ({
210 ...existing,
211 [provider]: null,
212 }));
114213 },
115 [customCodexModels, updateSettings],
214 [settings, updateSettings],
116215 );
117216
118217 return (
......@@ -240,89 +339,133 @@ function SettingsRouteView() {
240339 <div className="mb-4">
241340 <h2 className="text-sm font-medium text-foreground">Models</h2>
242341 <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.
244344 </p>
245345 </div>
246346
247 <div className="space-y-4">
248 <div className="flex flex-col gap-3 sm:flex-row sm:items-start">
249 <label htmlFor="custom-model-slug" className="block flex-1 space-y-1">
250 <span className="text-xs font-medium text-foreground">Custom model slug</span>
251 <Input
252 id="custom-model-slug"
253 value={customModelInput}
254 onChange={(event) => {
255 setCustomModelInput(event.target.value);
256 if (customModelError) {
257 setCustomModelError(null);
258 }
259 }}
260 onKeyDown={(event) => {
261 if (event.key !== "Enter") return;
262 event.preventDefault();
263 addCustomModel();
264 }}
265 placeholder="your-model-slug"
266 spellCheck={false}
267 />
268 <span className="text-xs text-muted-foreground">
269 Example: <code>gpt-6.7-codex-ultra-preview</code>
270 </span>
271 </label>
272
273 <Button className="sm:mt-6" type="button" onClick={addCustomModel}>
274 Add model
275 </Button>
276 </div>
277
278 {customModelError ? (
279 <p className="text-xs text-destructive">{customModelError}</p>
280 ) : null}
281
282 <div className="space-y-2">
283 <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
284 <p>Saved custom models: {customCodexModels.length}</p>
285 {customCodexModels.length > 0 ? (
286 <Button
287 size="xs"
288 variant="outline"
289 onClick={() =>
290 updateSettings({
291 customCodexModels: defaults.customCodexModels,
292 })
293 }
294 >
295 Reset custom models
296 </Button>
297 ) : null}
298 </div>
347 <div className="space-y-5">
348 {MODEL_PROVIDER_SETTINGS.map((providerSettings) => {
349 const provider = providerSettings.provider;
350 const customModels = getCustomModelsForProvider(settings, provider);
351 const customModelInput = customModelInputByProvider[provider];
352 const customModelError = customModelErrorByProvider[provider] ?? null;
353 return (
354 <div
355 key={provider}
356 className="rounded-xl border border-border bg-background/50 p-4"
357 >
358 <div className="mb-4">
359 <h3 className="text-sm font-medium text-foreground">
360 {providerSettings.title}
361 </h3>
362 <p className="mt-1 text-xs text-muted-foreground">
363 {providerSettings.description}
364 </p>
365 </div>
366
367 <div className="space-y-4">
368 <div className="flex flex-col gap-3 sm:flex-row sm:items-start">
369 <label
370 htmlFor={`custom-model-slug-${provider}`}
371 className="block flex-1 space-y-1"
372 >
373 <span className="text-xs font-medium text-foreground">
374 Custom model slug
375 </span>
376 <Input
377 id={`custom-model-slug-${provider}`}
378 value={customModelInput}
379 onChange={(event) => {
380 const value = event.target.value;
381 setCustomModelInputByProvider((existing) => ({
382 ...existing,
383 [provider]: value,
384 }));
385 if (customModelError) {
386 setCustomModelErrorByProvider((existing) => ({
387 ...existing,
388 [provider]: null,
389 }));
390 }
391 }}
392 onKeyDown={(event) => {
393 if (event.key !== "Enter") return;
394 event.preventDefault();
395 addCustomModel(provider);
396 }}
397 placeholder={providerSettings.placeholder}
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>
310405 <Button
311 size="xs"
312 variant="ghost"
313 onClick={() => removeCustomModel(slug)}
406 className="sm:mt-6"
407 type="button"
408 onClick={() => addCustomModel(provider)}
314409 >
315 Remove
410 Add model
316411 </Button>
317412 </div>
318 ))}
319 </div>
320 ) : (
321 <div className="rounded-lg border border-dashed border-border bg-background px-3 py-4 text-xs text-muted-foreground">
322 No custom models saved yet.
413
414 {customModelError ? (
415 <p className="text-xs text-destructive">{customModelError}</p>
416 ) : null}
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>
323466 </div>
324 )}
325 </div>
467 );
468 })}
326469 </div>
327470 </section>
328471
apps/web/src/store.ts+3-3
......@@ -164,9 +164,9 @@ function toLegacyProvider(providerName: string | null): ProviderKind {
164164 return "codex";
165165}
166166
167const CODEX_MODEL_SLUGS = new Set(getModelOptions("codex").map((option) => option.slug));
168const CLAUDE_MODEL_SLUGS = new Set(getModelOptions("claudeCode").map((option) => option.slug));
169const CURSOR_MODEL_SLUGS = new Set(getModelOptions("cursor").map((option) => option.slug));
167const CODEX_MODEL_SLUGS = new Set<string>(getModelOptions("codex").map((option) => option.slug));
168const CLAUDE_MODEL_SLUGS = new Set<string>(getModelOptions("claudeCode").map((option) => option.slug));
169const CURSOR_MODEL_SLUGS = new Set<string>(getModelOptions("cursor").map((option) => option.slug));
170170const CURSOR_DISTINCT_MODEL_SLUGS = new Set(
171171 [...CURSOR_MODEL_SLUGS].filter(
172172 (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", () => {
4949 expect(normalizeModelSlug("gpt-5.3-codex-spark", "cursor")).toBe(
5050 "gpt-5.3-codex-spark-preview",
5151 );
52 expect(normalizeModelSlug("gemini-3.1", "cursor")).toBe("gemini-3.1-pro");
52 expect(normalizeModelSlug("gemini-3.1", "cursor")).toBe("gemini-3.1");
5353 expect(normalizeModelSlug("claude-4.6-sonnet-thinking", "cursor")).toBe(
5454 "sonnet-4.6-thinking",
5555 );
......@@ -63,8 +63,8 @@ describe("resolveModelSlug", () => {
6363 });
6464
6565 it("preserves unknown custom models", () => {
66 expect(resolveModelSlug("gpt-4.1")).toBe("gpt-4.1");
67 expect(resolveModelSlug("custom/internal-model")).toBe("custom/internal-model");
66 expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL);
67 expect(resolveModelSlug("custom/internal-model")).toBe(DEFAULT_MODEL);
6868 });
6969
7070 it("resolves only supported model options", () => {
......@@ -116,12 +116,9 @@ describe("cursor model selection", () => {
116116 fast: true,
117117 thinking: false,
118118 });
119 expect(parseCursorModelSelection("gpt-5.2-codex")).toEqual({
120 family: "gpt-5.3-codex",
121 reasoning: "normal",
122 fast: false,
123 thinking: false,
124 });
119 expect(parseCursorModelSelection("gpt-5.2-codex")).toEqual(
120 parseCursorModelSelection(DEFAULT_MODEL_BY_PROVIDER.cursor),
121 );
125122 });
126123
127124 it("parses and resolves thinking variants", () => {
packages/contracts/src/model.ts+2-1
......@@ -62,7 +62,8 @@ export const MODEL_OPTIONS_BY_PROVIDER = {
6262 ],
6363} 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 & {});
6667export type CursorModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)["cursor"][number]["slug"];
6768
6869type CursorModelCapability = {