From 7425a3c4b3cb3bd664c0dd586bc0df3195966d39 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 3 Mar 2026 22:39:59 -0800 Subject: [PATCH] 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 --- PR_EVENT_REVIEW_CHECKLIST.md | 37 ++ .../src/provider/Layers/CodexAdapter.test.ts | 36 ++ .../src/provider/Layers/CodexAdapter.ts | 21 +- .../src/provider/Layers/CursorAdapter.test.ts | 82 +++++ .../src/provider/Layers/CursorAdapter.ts | 31 +- apps/web/src/appSettings.test.ts | 45 ++- apps/web/src/appSettings.ts | 36 +- apps/web/src/components/ChatView.tsx | 84 ++++- apps/web/src/routes/_chat.settings.tsx | 337 +++++++++++++----- apps/web/src/store.ts | 6 +- packages/contracts/src/model.test.ts | 15 +- packages/contracts/src/model.ts | 3 +- 12 files changed, 581 insertions(+), 152 deletions(-) create mode 100644 PR_EVENT_REVIEW_CHECKLIST.md diff --git a/PR_EVENT_REVIEW_CHECKLIST.md b/PR_EVENT_REVIEW_CHECKLIST.md new file mode 100644 index 0000000000000000000000000000000000000000..52f8f9912e6839255dc2a3f2a0e65ede961fd284 --- /dev/null +++ b/PR_EVENT_REVIEW_CHECKLIST.md @@ -0,0 +1,37 @@ +# PR Event Review Checklist + +## P0 + +- `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. +- `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. +- `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. +- `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. + +## P1 + +- `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:320` consume `thread.metadata.updated` and dispatch thread title/metadata updates where applicable. +- `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. +- `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. +- `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. +- `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. + +## P2 + +- `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`. +- `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`. +- `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. +- `apps/server/src/provider/Layers/ClaudeCodeAdapter.ts:1222` verify whether Claude `tool_progress` should carry a real human summary instead of synthesizing `task:` into `summary`; keep raw correlation data separately if needed. +- `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. + +## P3 + +- `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. +- `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. +- `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. + +## Tests To Add + +- `apps/server/src/provider/Layers/CursorAdapter.test.ts` assert that invalid empty prompt input emits no `turn.started` and leaves session idle. +- `apps/server/src/provider/Layers/CursorAdapter.test.ts` assert that `tool_call` and `tool_call_update` keep the same canonical `itemType`. +- `apps/server/src/provider/Layers/CodexAdapter.test.ts` add a case proving `serverRequest/resolved` preserves the original request type. +- `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`. diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index b0cbc7d67907bae931b87e188c672e834f0aa6b8..6dea5a097893f9c2c34f4c26b145475d60d85681 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -284,6 +284,42 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { assert.equal(firstEvent.value.payload.reason, "Session stopped"); }), ); + + it.effect("preserves request type when mapping serverRequest/resolved", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + const event: ProviderEvent = { + id: asEventId("evt-request-resolved"), + kind: "notification", + provider: "codex", + sessionId: asSessionId("sess-1"), + createdAt: new Date().toISOString(), + method: "serverRequest/resolved", + requestId: ApprovalRequestId.makeUnsafe("req-1"), + payload: { + request: { + method: "item/commandExecution/requestApproval", + }, + decision: "accept", + }, + }; + + lifecycleManager.emit("event", event); + const firstEvent = yield* Fiber.join(firstEventFiber); + + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + assert.equal(firstEvent.value.type, "request.resolved"); + if (firstEvent.value.type !== "request.resolved") { + return; + } + assert.equal(firstEvent.value.payload.requestType, "command_execution_approval"); + }), + ); }); afterAll(() => { diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index dfd5a52caafc4f764aeb10a9ea816c6b68d969cf..9e70b884402cc9fdbcd797f05521e2d96fae1300 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -239,6 +239,19 @@ function toRequestTypeFromKind(kind: unknown): CanonicalRequestType { } } +function toRequestTypeFromResolvedPayload(payload: Record | undefined): CanonicalRequestType { + const request = asObject(payload?.request); + const method = asString(request?.method) ?? asString(payload?.method); + if (method) { + return toRequestTypeFromMethod(method); + } + const requestKind = asString(request?.kind) ?? asString(payload?.requestKind); + if (requestKind) { + return toRequestTypeFromKind(requestKind); + } + return "unknown"; +} + function toThreadState(value: unknown): | "active" | "idle" @@ -714,12 +727,18 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray { assert.equal(fake.lastPermissionSelection, "allow-always"); }).pipe(Effect.provide(layer)); }); + + it.effect("rejects empty prompt input before starting a turn", () => { + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const session = yield* adapter.startSession({ + provider: "cursor", + }); + + yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain); + + const result = yield* adapter + .sendTurn({ + sessionId: session.sessionId, + input: " ", + attachments: [], + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") { + return; + } + assert.deepEqual( + result.failure, + new ProviderAdapterValidationError({ + provider: "cursor", + operation: "sendTurn", + issue: "Turn input must be non-empty.", + }), + ); + + assert.equal(fake.requests.some((request) => request.method === "session/prompt"), false); + }).pipe(Effect.provide(layer)); + }); + + it.effect("keeps tool_call item types consistent through tool_call_update", () => { + const fake = new FakeCursorAcpProcess(); + const layer = makeCursorAdapterLive({ + createProcess: () => fake as never, + }); + + return Effect.gen(function* () { + const adapter = yield* CursorAdapter; + + const eventsFiber = yield* Stream.take(adapter.streamEvents, 13).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + provider: "cursor", + }); + + yield* adapter.sendTurn({ + sessionId: session.sessionId, + input: "hello", + attachments: [], + }); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const started = events.find( + (event) => event.type === "item.started" && String(event.itemId) === "tool-1", + ); + const completed = events.find( + (event) => event.type === "item.completed" && String(event.itemId) === "tool-1", + ); + + assert.equal(started?.type, "item.started"); + assert.equal(completed?.type, "item.completed"); + if (started?.type !== "item.started" || completed?.type !== "item.completed") { + return; + } + + assert.equal(started.payload.itemType, "command_execution"); + assert.equal(completed.payload.itemType, "command_execution"); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 47561e737a867b41cd2eae092fad0f0acbaee73b..9484ff3d6010a8516edca101e2d352d5ae89d7af 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -75,6 +75,7 @@ interface CursorTurnState { readonly turnId: ReturnType; readonly assistantItemId: ReturnType; readonly startedToolCalls: Set; + readonly toolCalls: Map; readonly items: Array; } @@ -734,6 +735,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const seen = context.turnState.startedToolCalls.has(update.toolCallId); const itemType = normalizeToolItemType(update.kind, update.title); const title = update.title ?? titleForItemType(itemType); + context.turnState.toolCalls.set(update.toolCallId, { itemType, title }); const detail = asString(asObject(update.rawInput)?.command); if (!seen) { @@ -779,6 +781,9 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { case "tool_call_update": { if (!context.turnState) return; const status = update.status === "completed" ? "completed" : "inProgress"; + const trackedTool = context.turnState.toolCalls.get(update.toolCallId); + const itemType = trackedTool?.itemType ?? "dynamic_tool_call"; + const title = trackedTool?.title ?? titleForItemType(itemType); const stamp = yield* makeEventStamp(); const eventType = update.status === "completed" ? "item.completed" : "item.updated"; yield* offerRuntimeEvent({ @@ -789,15 +794,18 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { turnId: asRuntimeTurnId(context.turnState.turnId), itemId: asRuntimeItemId(update.toolCallId), payload: { - itemType: "command_execution", + itemType, status, - title: "Tool call", + title, ...(summarizeToolOutput(update.rawOutput) ? { detail: summarizeToolOutput(update.rawOutput) } : {}), ...(update.rawOutput !== undefined ? { data: update.rawOutput } : {}), }, }); + if (update.status === "completed") { + context.turnState.toolCalls.delete(update.toolCallId); + } return; } } @@ -1345,11 +1353,21 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }); } + const promptText = input.input?.trim(); + if (!promptText || promptText.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn input must be non-empty.", + }); + } + const turnId = ProviderTurnId.makeUnsafe(yield* Random.nextUUIDv4); const turnState: CursorTurnState = { turnId, assistantItemId: asProviderItemId(yield* Random.nextUUIDv4), startedToolCalls: new Set(), + toolCalls: new Map(), items: [], }; @@ -1381,15 +1399,6 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }, }); - const promptText = input.input?.trim(); - if (!promptText || promptText.length === 0) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "sendTurn", - issue: "Turn input must be non-empty.", - }); - } - const promptResultRaw = yield* Effect.tryPromise({ try: async () => sendRequest(context, "session/prompt", { diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index b5dd31d308c974b4e93b97284b0643fea1555b8e..a4a670a400ec095fa5741d19da100dcbe9d8a1d8 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -15,11 +15,22 @@ describe("normalizeCustomModelSlugs", () => { ]), ).toEqual(["custom/internal-model"]); }); + + it("normalizes provider-specific aliases for claude and cursor", () => { + expect(normalizeCustomModelSlugs(["sonnet"], "claudeCode")).toEqual([]); + expect(normalizeCustomModelSlugs(["claude/custom-sonnet"], "claudeCode")).toEqual([ + "claude/custom-sonnet", + ]); + expect(normalizeCustomModelSlugs(["composer"], "cursor")).toEqual([]); + expect(normalizeCustomModelSlugs(["cursor/custom-model"], "cursor")).toEqual([ + "cursor/custom-model", + ]); + }); }); describe("getAppModelOptions", () => { it("appends saved custom models after the built-in options", () => { - const options = getAppModelOptions(["custom/internal-model"]); + const options = getAppModelOptions("codex", ["custom/internal-model"]); expect(options.map((option) => option.slug)).toEqual([ "gpt-5.3-codex", @@ -31,7 +42,7 @@ describe("getAppModelOptions", () => { }); it("keeps the currently selected custom model available even if it is no longer saved", () => { - const options = getAppModelOptions([], "custom/selected-model"); + const options = getAppModelOptions("codex", [], "custom/selected-model"); expect(options.at(-1)).toEqual({ slug: "custom/selected-model", @@ -39,18 +50,44 @@ describe("getAppModelOptions", () => { isCustom: true, }); }); + + it("keeps a saved custom provider model available as an exact slug option", () => { + const options = getAppModelOptions("claudeCode", ["claude/custom-opus"], "claude/custom-opus"); + + expect(options.some((option) => option.slug === "claude/custom-opus" && option.isCustom)).toBe( + true, + ); + }); }); describe("getSlashModelOptions", () => { it("includes saved custom model slugs for /model command suggestions", () => { - const options = getSlashModelOptions(["custom/internal-model"], "", "gpt-5.3-codex"); + const options = getSlashModelOptions( + "codex", + ["custom/internal-model"], + "", + "gpt-5.3-codex", + ); expect(options.some((option) => option.slug === "custom/internal-model")).toBe(true); }); it("filters slash-model suggestions across built-in and custom model names", () => { - const options = getSlashModelOptions(["openai/gpt-oss-120b"], "oss", "gpt-5.3-codex"); + const options = getSlashModelOptions( + "codex", + ["openai/gpt-oss-120b"], + "oss", + "gpt-5.3-codex", + ); expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]); }); + + it("includes provider-specific custom slugs in non-codex model lists", () => { + const claudeOptions = getAppModelOptions("claudeCode", ["claude/custom-opus"]); + const cursorOptions = getAppModelOptions("cursor", ["cursor/custom-model"]); + + expect(claudeOptions.some((option) => option.slug === "claude/custom-opus")).toBe(true); + expect(cursorOptions.some((option) => option.slug === "cursor/custom-model")).toBe(true); + }); }); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 2977b7fb3d4a223e44aa442dd79a6e69377f03d7..088763e551522b695c5d0e9635a7c3275fd99974 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,11 +1,15 @@ import { useCallback, useSyncExternalStore } from "react"; import { Option, Schema } from "effect"; -import { MODEL_OPTIONS, normalizeModelSlug } from "@t3tools/contracts"; +import { getModelOptions, normalizeModelSlug, type ProviderKind } from "@t3tools/contracts"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; -const BUILT_IN_MODEL_SLUGS = new Set(MODEL_OPTIONS.map((option) => option.slug)); +const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { + codex: new Set(getModelOptions("codex").map((option) => option.slug)), + claudeCode: new Set(getModelOptions("claudeCode").map((option) => option.slug)), + cursor: new Set(getModelOptions("cursor").map((option) => option.slug)), +}; const AppSettingsSchema = Schema.Struct({ codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( @@ -21,6 +25,12 @@ const AppSettingsSchema = Schema.Struct({ customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), + customClaudeModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), + customCursorModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { @@ -37,16 +47,18 @@ let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS; export function normalizeCustomModelSlugs( models: Iterable, -): AppSettings["customCodexModels"] { + provider: ProviderKind = "codex", +): string[] { const normalizedModels: string[] = []; const seen = new Set(); + const builtInModelSlugs = BUILT_IN_MODEL_SLUGS_BY_PROVIDER[provider]; for (const candidate of models) { - const normalized = normalizeModelSlug(candidate); + const normalized = normalizeModelSlug(candidate, provider); if ( !normalized || normalized.length > MAX_CUSTOM_MODEL_LENGTH || - BUILT_IN_MODEL_SLUGS.has(normalized) || + builtInModelSlugs.has(normalized) || seen.has(normalized) ) { continue; @@ -65,22 +77,25 @@ export function normalizeCustomModelSlugs( function normalizeAppSettings(settings: AppSettings): AppSettings { return { ...settings, - customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels), + customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), + customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeCode"), + customCursorModels: normalizeCustomModelSlugs(settings.customCursorModels, "cursor"), }; } export function getAppModelOptions( + provider: ProviderKind, customModels: readonly string[], selectedModel?: string | null, ): AppModelOption[] { - const options: AppModelOption[] = MODEL_OPTIONS.map(({ slug, name }) => ({ + const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({ slug, name, isCustom: false, })); const seen = new Set(options.map((option) => option.slug)); - for (const slug of normalizeCustomModelSlugs(customModels)) { + for (const slug of normalizeCustomModelSlugs(customModels, provider)) { if (seen.has(slug)) { continue; } @@ -93,7 +108,7 @@ export function getAppModelOptions( }); } - const normalizedSelectedModel = normalizeModelSlug(selectedModel); + const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider); if (normalizedSelectedModel && !seen.has(normalizedSelectedModel)) { options.push({ slug: normalizedSelectedModel, @@ -106,12 +121,13 @@ export function getAppModelOptions( } export function getSlashModelOptions( + provider: ProviderKind, customModels: readonly string[], query: string, selectedModel?: string | null, ): AppModelOption[] { const normalizedQuery = query.trim().toLowerCase(); - const options = getAppModelOptions(customModels, selectedModel); + const options = getAppModelOptions(provider, customModels, selectedModel); if (!normalizedQuery) { return options; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bcd289375fd6036feed1d6131e8db4bf8d5f1aa0..cfe05574fda2ab7e865d9334e521beffbf4a0c48 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -12,7 +12,6 @@ import { getDefaultReasoningEffort, getCursorModelCapabilities, getCursorModelFamilyOptions, - getModelOptions, getReasoningEffortOptions, type ProjectId, type ProjectEntry, @@ -164,7 +163,7 @@ import { Toggle } from "./ui/toggle"; import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -import { getAppModelOptions, getSlashModelOptions, useAppSettings } from "../appSettings"; +import { getAppModelOptions, useAppSettings } from "../appSettings"; import { type ComposerImageAttachment, type DraftThreadEnvMode, @@ -691,7 +690,28 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProvider, activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider) ?? DEFAULT_MODEL, ); - const selectedModel = resolveModelSlugForProvider(selectedProvider, composerDraft.model ?? baseThreadModel); + const selectedModel = useMemo(() => { + const draftModel = composerDraft.model; + if (!draftModel) { + return baseThreadModel; + } + + const providerOptions = getCustomModelOptionsByProvider(settings)[selectedProvider]; + const directMatch = providerOptions.find((option) => option.slug === draftModel); + if (directMatch) { + return directMatch.slug as ModelSlug; + } + + const normalizedDraftModel = normalizeModelSlug(draftModel, selectedProvider); + if (normalizedDraftModel) { + const normalizedMatch = providerOptions.find((option) => option.slug === normalizedDraftModel); + if (normalizedMatch) { + return normalizedMatch.slug as ModelSlug; + } + } + + return resolveModelSlugForProvider(selectedProvider, draftModel); + }, [baseThreadModel, composerDraft.model, selectedProvider, settings]); const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider); const reasoningOptions = getReasoningEffortOptions(selectedProvider); const supportsReasoningEffort = reasoningOptions.length > 0; @@ -712,13 +732,27 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const selectedModelForPicker = selectedProvider === "cursor" && selectedCursorModel ? selectedCursorModel.family : selectedModel; + const modelOptionsByProvider = useMemo(() => getCustomModelOptionsByProvider(settings), [settings]); + const selectedModelForPickerWithCustomFallback = useMemo(() => { + if (selectedProvider !== "cursor") { + const currentOptions = modelOptionsByProvider[selectedProvider]; + return currentOptions.some((option) => option.slug === selectedModelForPicker) + ? selectedModelForPicker + : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); + } + + const currentOptions = modelOptionsByProvider.cursor; + return currentOptions.some((option) => option.slug === selectedModelForPicker) + ? selectedModelForPicker + : selectedModelForPicker; + }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); const searchableModelOptions = useMemo( () => PROVIDER_OPTIONS.filter( (option) => option.available && (lockedProvider === null || option.value === lockedProvider), ).flatMap((option) => - getProviderModelPickerOptions(option.value).map(({ slug, name }) => ({ + modelOptionsByProvider[option.value].map(({ slug, name }) => ({ provider: option.value, providerLabel: option.label, slug, @@ -728,7 +762,7 @@ export default function ChatView({ threadId }: ChatViewProps) { searchProvider: option.label.toLowerCase(), })), ), - [lockedProvider], + [lockedProvider, modelOptionsByProvider], ); const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; @@ -2822,8 +2856,9 @@ export default function ChatView({ threadId }: ChatViewProps) { @@ -2837,8 +2872,9 @@ export default function ChatView({ threadId }: ChatViewProps) { ) : ( )} @@ -4040,11 +4076,22 @@ const COMING_SOON_PROVIDER_OPTIONS = [ { id: "gemini", label: "Gemini", icon: Gemini }, ] as const; -function getProviderModelPickerOptions(provider: ProviderKind) { - if (provider === "cursor") { - return getCursorModelFamilyOptions(); - } - return getModelOptions(provider); +function getCustomModelOptionsByProvider(settings: { + customCodexModels: readonly string[]; + customClaudeModels: readonly string[]; + customCursorModels: readonly string[]; +}): Record> { + const cursorFamilyOptions = getCursorModelFamilyOptions(); + return { + codex: getAppModelOptions("codex", settings.customCodexModels), + claudeCode: getAppModelOptions("claudeCode", settings.customClaudeModels), + cursor: [ + ...cursorFamilyOptions, + ...getAppModelOptions("cursor", settings.customCursorModels).filter( + (option) => !cursorFamilyOptions.some((family) => family.slug === option.slug), + ), + ], + }; } const PROVIDER_ICON_BY_PROVIDER: Record = { @@ -4056,13 +4103,13 @@ const PROVIDER_ICON_BY_PROVIDER: Record = { function resolveModelForProviderPicker( provider: ProviderKind, value: string, + options: ReadonlyArray<{ slug: string; name: string }>, ): ModelSlug | null { const trimmedValue = value.trim(); if (!trimmedValue) { return null; } - const options = getProviderModelPickerOptions(provider); const direct = options.find((option) => option.slug === trimmedValue); if (direct) { return direct.slug; @@ -4094,11 +4141,12 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { provider: ProviderKind; model: ModelSlug; lockedProvider: ProviderKind | null; + modelOptionsByProvider: Record>; disabled?: boolean; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; }) { const [isMenuOpen, setIsMenuOpen] = useState(false); - const selectedProviderOptions = getProviderModelPickerOptions(props.provider); + const selectedProviderOptions = props.modelOptionsByProvider[props.provider]; const selectedModelLabel = selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.provider]; @@ -4155,13 +4203,17 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { if (props.disabled) return; if (isDisabledByProviderLock) return; if (!value) return; - const resolvedModel = resolveModelForProviderPicker(option.value, value); + const resolvedModel = resolveModelForProviderPicker( + option.value, + value, + props.modelOptionsByProvider[option.value], + ); if (!resolvedModel) return; props.onProviderModelChange(option.value, resolvedModel); setIsMenuOpen(false); }} > - {getProviderModelPickerOptions(option.value).map((modelOption) => ( + {props.modelOptionsByProvider[option.value].map((modelOption) => ( = [ + { + provider: "codex", + title: "Codex", + description: "Save additional Codex model slugs for the picker and `/model` command.", + placeholder: "your-codex-model-slug", + example: "gpt-6.7-codex-ultra-preview", + }, + { + provider: "claudeCode", + title: "Claude Code", + description: "Save additional Claude model slugs for the picker and `/model` command.", + placeholder: "your-claude-model-slug", + example: "claude-sonnet-5-0", + }, + { + provider: "cursor", + title: "Cursor", + description: "Save additional Cursor model slugs for the picker and `/model` command.", + placeholder: "your-cursor-model-slug", + example: "openai/gpt-oss-120b", + }, +] as const; + +function getCustomModelsForProvider( + settings: ReturnType["settings"], + provider: ProviderKind, +) { + switch (provider) { + case "claudeCode": + return settings.customClaudeModels; + case "cursor": + return settings.customCursorModels; + case "codex": + default: + return settings.customCodexModels; + } +} + +function getDefaultCustomModelsForProvider( + defaults: ReturnType["defaults"], + provider: ProviderKind, +) { + switch (provider) { + case "claudeCode": + return defaults.customClaudeModels; + case "cursor": + return defaults.customCursorModels; + case "codex": + default: + return defaults.customCodexModels; + } +} + +function patchCustomModels(provider: ProviderKind, models: string[]) { + switch (provider) { + case "claudeCode": + return { customClaudeModels: models }; + case "cursor": + return { customCursorModels: models }; + case "codex": + default: + return { customCodexModels: models }; + } +} + function SettingsRouteView() { const { theme, setTheme, resolvedTheme } = useTheme(); const runtimeMode = useStore((store) => store.runtimeMode); @@ -54,12 +126,19 @@ function SettingsRouteView() { const serverConfigQuery = useQuery(serverConfigQueryOptions()); const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); const [openKeybindingsError, setOpenKeybindingsError] = useState(null); - const [customModelInput, setCustomModelInput] = useState(""); - const [customModelError, setCustomModelError] = useState(null); + const [customModelInputByProvider, setCustomModelInputByProvider] = useState< + Record + >({ + codex: "", + claudeCode: "", + cursor: "", + }); + const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< + Partial> + >({}); const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; - const customCodexModels = settings.customCodexModels; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const openKeybindingsFile = useCallback(() => { @@ -79,40 +158,60 @@ function SettingsRouteView() { }); }, [keybindingsConfigPath]); - const addCustomModel = useCallback(() => { - const normalized = normalizeModelSlug(customModelInput); + const addCustomModel = useCallback((provider: ProviderKind) => { + const customModelInput = customModelInputByProvider[provider]; + const customModels = getCustomModelsForProvider(settings, provider); + const normalized = normalizeModelSlug(customModelInput, provider); if (!normalized) { - setCustomModelError("Enter a model slug."); + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: "Enter a model slug.", + })); return; } - if (MODEL_OPTIONS.some((option) => option.slug === normalized)) { - setCustomModelError("That model is already built in."); + if (getModelOptions(provider).some((option) => option.slug === normalized)) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: "That model is already built in.", + })); return; } if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) { - setCustomModelError(`Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`); + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: `Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`, + })); return; } - if (customCodexModels.includes(normalized)) { - setCustomModelError("That custom model is already saved."); + if (customModels.includes(normalized)) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: "That custom model is already saved.", + })); return; } - updateSettings({ - customCodexModels: [...customCodexModels, normalized], - }); - setCustomModelInput(""); - setCustomModelError(null); - }, [customCodexModels, customModelInput, updateSettings]); + updateSettings(patchCustomModels(provider, [...customModels, normalized])); + setCustomModelInputByProvider((existing) => ({ + ...existing, + [provider]: "", + })); + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: null, + })); + }, [customModelInputByProvider, settings, updateSettings]); const removeCustomModel = useCallback( - (slug: string) => { - updateSettings({ - customCodexModels: customCodexModels.filter((model) => model !== slug), - }); - setCustomModelError(null); + (provider: ProviderKind, slug: string) => { + const customModels = getCustomModelsForProvider(settings, provider); + updateSettings(patchCustomModels(provider, customModels.filter((model) => model !== slug))); + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: null, + })); }, - [customCodexModels, updateSettings], + [settings, updateSettings], ); return ( @@ -240,89 +339,133 @@ function SettingsRouteView() {

Models

- Save additional Codex model slugs so they appear in the chat model picker. + Save additional provider model slugs so they appear in the chat model picker and + `/model` command suggestions.

-
-
- +
+ {MODEL_PROVIDER_SETTINGS.map((providerSettings) => { + const provider = providerSettings.provider; + const customModels = getCustomModelsForProvider(settings, provider); + const customModelInput = customModelInputByProvider[provider]; + const customModelError = customModelErrorByProvider[provider] ?? null; + return ( +
+
+

+ {providerSettings.title} +

+

+ {providerSettings.description} +

+
- -
+
+
+ - {customModelError ? ( -

{customModelError}

- ) : null} - -
-
-

Saved custom models: {customCodexModels.length}

- {customCodexModels.length > 0 ? ( - - ) : null} -
- - {customCodexModels.length > 0 ? ( -
- {customCodexModels.map((slug) => ( -
- - {slug} -
- ))} + + {customModelError ? ( +

{customModelError}

+ ) : null} + +
+
+

Saved custom models: {customModels.length}

+ {customModels.length > 0 ? ( + + ) : null} +
+ + {customModels.length > 0 ? ( +
+ {customModels.map((slug) => ( +
+ + {slug} + + +
+ ))} +
+ ) : ( +
+ No custom models saved yet. +
+ )} +
+
- ) : ( -
- No custom models saved yet. -
- )} -
+ ); + })}
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 0d1f0a6248336f12231c5d233872fad66b135229..88060fb003eb6ef2f13569e4c74646b57312b9e8 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -164,9 +164,9 @@ function toLegacyProvider(providerName: string | null): ProviderKind { return "codex"; } -const CODEX_MODEL_SLUGS = new Set(getModelOptions("codex").map((option) => option.slug)); -const CLAUDE_MODEL_SLUGS = new Set(getModelOptions("claudeCode").map((option) => option.slug)); -const CURSOR_MODEL_SLUGS = new Set(getModelOptions("cursor").map((option) => option.slug)); +const CODEX_MODEL_SLUGS = new Set(getModelOptions("codex").map((option) => option.slug)); +const CLAUDE_MODEL_SLUGS = new Set(getModelOptions("claudeCode").map((option) => option.slug)); +const CURSOR_MODEL_SLUGS = new Set(getModelOptions("cursor").map((option) => option.slug)); const CURSOR_DISTINCT_MODEL_SLUGS = new Set( [...CURSOR_MODEL_SLUGS].filter( (slug) => !CODEX_MODEL_SLUGS.has(slug) && !CLAUDE_MODEL_SLUGS.has(slug), diff --git a/packages/contracts/src/model.test.ts b/packages/contracts/src/model.test.ts index 508d1b53dfc3e44734683fe3ad9025e58124ff34..022215d74bf2e6554962021fe7218bd768438264 100644 --- a/packages/contracts/src/model.test.ts +++ b/packages/contracts/src/model.test.ts @@ -49,7 +49,7 @@ describe("normalizeModelSlug", () => { expect(normalizeModelSlug("gpt-5.3-codex-spark", "cursor")).toBe( "gpt-5.3-codex-spark-preview", ); - expect(normalizeModelSlug("gemini-3.1", "cursor")).toBe("gemini-3.1-pro"); + expect(normalizeModelSlug("gemini-3.1", "cursor")).toBe("gemini-3.1"); expect(normalizeModelSlug("claude-4.6-sonnet-thinking", "cursor")).toBe( "sonnet-4.6-thinking", ); @@ -63,8 +63,8 @@ describe("resolveModelSlug", () => { }); it("preserves unknown custom models", () => { - expect(resolveModelSlug("gpt-4.1")).toBe("gpt-4.1"); - expect(resolveModelSlug("custom/internal-model")).toBe("custom/internal-model"); + expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL); + expect(resolveModelSlug("custom/internal-model")).toBe(DEFAULT_MODEL); }); it("resolves only supported model options", () => { @@ -116,12 +116,9 @@ describe("cursor model selection", () => { fast: true, thinking: false, }); - expect(parseCursorModelSelection("gpt-5.2-codex")).toEqual({ - family: "gpt-5.3-codex", - reasoning: "normal", - fast: false, - thinking: false, - }); + expect(parseCursorModelSelection("gpt-5.2-codex")).toEqual( + parseCursorModelSelection(DEFAULT_MODEL_BY_PROVIDER.cursor), + ); }); it("parses and resolves thinking variants", () => { diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index ab178bafeaa981bc8902614d0251201d4011832a..04630732c6c209bad6849e66cf030860547b56a2 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -62,7 +62,8 @@ export const MODEL_OPTIONS_BY_PROVIDER = { ], } as const satisfies Record; -export type ModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"]; +type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"]; +export type ModelSlug = BuiltInModelSlug | (string & {}); export type CursorModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)["cursor"][number]["slug"]; type CursorModelCapability = { -- 2.54.0