From 004cfaba30b20e2fe3106cd7ca742b59bd542ef2 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Thu, 23 Jul 2026 01:09:09 +0300 Subject: [PATCH 1/5] Use recommended provider model defaults --- apps/server/src/codexAppServerManager.test.ts | 5 +- apps/server/src/codexAppServerManager.ts | 7 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 32 ++- .../src/provider/Layers/ClaudeAdapter.ts | 23 +- .../src/provider/Layers/GrokAdapter.test.ts | 8 + .../server/src/provider/Layers/GrokAdapter.ts | 14 +- apps/web/src/appSettings.test.ts | 5 +- apps/web/src/components/ChatView.tsx | 12 +- apps/web/src/components/DiffPanel.logic.ts | 13 +- apps/web/src/components/Sidebar.tsx | 13 +- .../chat/composerProviderRegistry.test.tsx | 2 +- .../kanban/useKanbanTaskScratchDraft.ts | 8 +- apps/web/src/composerDraftStore.test.ts | 127 ++++++++++++ apps/web/src/composerDraftStore.ts | 26 ++- apps/web/src/hooks/useHandleNewThread.ts | 11 +- apps/web/src/lib/chatFirstSend.test.ts | 10 + apps/web/src/lib/chatFirstSend.ts | 13 +- apps/web/src/lib/projectCreation.test.ts | 5 + apps/web/src/lib/projectCreation.ts | 7 +- apps/web/src/lib/threadHandoff.test.ts | 3 +- apps/web/src/lib/threadHandoff.ts | 11 +- apps/web/src/providerModelOptions.test.ts | 41 ++++ apps/web/src/providerModelOptions.ts | 44 +++- packages/contracts/src/model.ts | 98 +++++++-- packages/contracts/src/providerDiscovery.ts | 2 + packages/shared/src/model.test.ts | 127 +++++++++++- packages/shared/src/model.ts | 196 ++++++++++++++++++ 27 files changed, 773 insertions(+), 90 deletions(-) diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index dd5a5adb3..b760382fa 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -819,7 +819,7 @@ describe("resolveCodexModelForAccount", () => { planType: "plus", sparkEnabled: false, }), - ).toBe("gpt-5.5"); + ).toBe("gpt-5.6-sol"); }); it("keeps spark for supported plans", () => { @@ -1394,6 +1394,7 @@ describe("CodexAppServerManager discovery", () => { name: "GPT-5.6 Sol", supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"], defaultReasoningEffort: "low", + isDefault: true, additionalSpeedTiers: ["fast"], }, }, @@ -1404,6 +1405,7 @@ describe("CodexAppServerManager discovery", () => { name: "GPT-5.6 Sol", supported_reasoning_efforts: ["low", "medium", "high", "xhigh", "max", "ultra"], default_reasoning_effort: "low", + is_default: true, additional_speed_tiers: ["fast"], }, }, @@ -1459,6 +1461,7 @@ describe("CodexAppServerManager discovery", () => { { slug: "gpt-5.6-sol", name: "GPT-5.6 Sol", + isDefault: true, supportedReasoningEfforts: [ { value: "low" }, { value: "medium" }, diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 69ed42d93..71d8322ab 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -6,6 +6,7 @@ import readline from "node:readline"; import { ApprovalRequestId, + DEFAULT_MODEL_BY_PROVIDER, EventId, type ProviderComposerCapabilities, ProviderItemId, @@ -242,7 +243,7 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ "unknown thread", "does not exist", ]; -const CODEX_DEFAULT_MODEL = "gpt-5.5"; +const CODEX_DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; const CODEX_SPARK_MODEL = "gpt-5.3-codex-spark"; const CODEX_SPARK_DISABLED_PLAN_TYPES = new Set(["free", "go", "plus"]); const CODEX_DISCOVERY_SESSION_IDLE_MS = 10 * 60 * 1000; @@ -3423,6 +3424,8 @@ export class CodexAppServerManager extends EventEmitter 0 ? { supportedReasoningEfforts } : {}), ...(trimmedDefaultReasoningEffort && supportedReasoningEfforts.some( diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 248830b6b..a917eb5a2 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { Options as ClaudeQueryOptions, + ModelInfo, PermissionMode, PermissionResult, SDKControlGetContextUsageResponse, @@ -339,13 +340,26 @@ describe("ClaudeAdapterLive", () => { it.effect("uses the configured Claude executable for pre-session model discovery", () => { const harness = makeHarness(); + (harness.query as { supportedModels: () => Promise }).supportedModels = async () => [ + { + value: "opus[1m]", + resolvedModel: "claude-opus-4-8[1m]", + displayName: "Claude Opus 4.8 (1M context)", + description: "Complex agentic coding", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"], + supportsAdaptiveThinking: true, + supportsFastMode: true, + supportsAutoMode: false, + }, + ]; return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; if (!adapter.listModels) { return assert.fail("Claude adapter should support model discovery."); } - yield* adapter.listModels({ + const result = yield* adapter.listModels({ provider: "claudeAgent", cwd: "/tmp/claude-model-discovery", binaryPath: "/managed/claude-models", @@ -356,6 +370,22 @@ describe("ClaudeAdapterLive", () => { "/managed/claude-models", ); assert.equal(harness.query.closeCalls, 1); + assert.deepEqual(result.models, [ + { + slug: "opus[1m]", + name: "Claude Opus 4.8 (1M context)", + resolvedModel: "claude-opus-4-8[1m]", + description: "Complex agentic coding", + supportedReasoningEfforts: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High" }, + { value: "max", label: "Max" }, + ], + supportsFastMode: true, + }, + ]); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 21a101dda..47267c775 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -54,6 +54,7 @@ import { type ProviderListSkillsResult, type ProviderListAgentsResult, type ProviderListModelsResult, + type ProviderModelDescriptor, getAgentMentionAliases, } from "@synara/contracts"; import { @@ -299,6 +300,24 @@ function mapSupportedCommands(commands: SlashCommand[]): ProviderListCommandsRes }; } +function mapClaudeModelInfo(model: ModelInfo): ProviderModelDescriptor { + const resolvedModel = model.resolvedModel?.trim(); + const description = model.description.trim(); + const supportedReasoningEfforts = model.supportedEffortLevels?.map((effort) => ({ + value: effort, + label: effort === "xhigh" ? "Extra High" : `${effort.charAt(0).toUpperCase()}${effort.slice(1)}`, + })); + return { + slug: model.value, + name: model.displayName, + ...(resolvedModel ? { resolvedModel } : {}), + ...(model.value === "default" ? { isDefault: true as const } : {}), + ...(description ? { description } : {}), + ...(supportedReasoningEfforts?.length ? { supportedReasoningEfforts } : {}), + ...(model.supportsFastMode !== undefined ? { supportsFastMode: model.supportsFastMode } : {}), + }; +} + function neverResolvingUserMessageStream(): AsyncIterable { return { [Symbol.asyncIterator](): AsyncIterator { @@ -3748,7 +3767,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { .supportedModels() .then((models) => { modelsCache.set(modelCacheKey, { - models: models.map((m) => ({ slug: m.value, name: m.displayName })), + models: models.map(mapClaudeModelInfo), source: "sdk", cached: false, }); @@ -4327,7 +4346,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { })().catch(() => undefined); const models = await tempQuery.supportedModels(); return { - models: models.map((model) => ({ slug: model.value, name: model.displayName })), + models: models.map(mapClaudeModelInfo), source: "sdk", cached: false, }; diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 46d85ab33..2d9aec1aa 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -96,6 +96,11 @@ describe("GrokAdapter runtime event scoping", () => { object: "model", aliases: ["grok-code-fast", "grok-code-fast-1", "grok-build-0.1", "ignored-alias"], }, + { + id: "grok-4.5", + object: "model", + aliases: ["grok-4.5-latest", "grok-build-latest"], + }, { id: "grok-code-fast-1-0825", object: "model" }, { id: "grok-4.3", object: "model" }, { id: " " }, @@ -106,6 +111,9 @@ describe("GrokAdapter runtime event scoping", () => { { slug: "grok-build-0.1", name: "Grok Build 0.1" }, { slug: "grok-code-fast", name: "Grok Code Fast" }, { slug: "grok-code-fast-1", name: "Grok Code Fast 1" }, + { slug: "grok-4.5", name: "Grok 4.5" }, + { slug: "grok-4.5-latest", name: "Grok 4.5" }, + { slug: "grok-build-latest", name: "Grok Build Latest" }, { slug: "grok-code-fast-1-0825", name: "Grok Code Fast 1 0825" }, ]); }); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index ee8d9af96..96257d16d 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -421,6 +421,12 @@ function parseGrokResume(raw: unknown): { sessionId: string } | undefined { } function formatGrokModelName(slug: string): string { + if (slug === "grok-build-latest") { + return "Grok Build Latest"; + } + if (slug === "grok-4.5" || slug === "grok-4.5-latest") { + return "Grok 4.5"; + } if (slug === "grok-build-0.1") { return "Grok Build 0.1"; } @@ -431,7 +437,13 @@ function formatGrokModelName(slug: string): string { } function isGrokBuildApiModelSlug(slug: string): boolean { - return slug === "grok-build-0.1" || /^grok-code-fast(?:-\d+(?:-\d+)?)?$/u.test(slug); + return ( + slug === "grok-build-latest" || + slug === "grok-4.5" || + slug === "grok-4.5-latest" || + slug === "grok-build-0.1" || + /^grok-code-fast(?:-\d+(?:-\d+)?)?$/u.test(slug) + ); } function readXaiModelAliases(rawModel: Record): string[] { diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index e908aa68d..c4494b4fb 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -65,6 +65,9 @@ describe("getAppModelOptions", () => { const options = getAppModelOptions("codex", ["custom/internal-model"]); expect(options.map((option) => option.slug)).toEqual([ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", @@ -223,7 +226,7 @@ describe("resolveAppModelSelection", () => { }, "", ), - ).toBe("gpt-5.5"); + ).toBe("gpt-5.6-sol"); }); it("resolves display names through the shared resolver", () => { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e86766393..498a97ed1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -38,7 +38,11 @@ import { ProviderInteractionMode, RuntimeMode, } from "@synara/contracts"; -import { getModelCapabilities, normalizeModelSlug } from "@synara/shared/model"; +import { + getModelCapabilities, + getRecommendedDefaultModelSelection, + normalizeModelSlug, +} from "@synara/shared/model"; import { resolveTailUserMessageEditTarget } from "@synara/shared/conversationEdit"; import { threadExportBlockedReason } from "@synara/shared/threadExport"; import { buildTemporaryWorktreeBranchName } from "@synara/shared/git"; @@ -1674,10 +1678,8 @@ export default function ChatView({ ? buildLocalDraftThread( threadId, draftThread, - fallbackDraftProject?.defaultModelSelection ?? { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, - }, + fallbackDraftProject?.defaultModelSelection ?? + getRecommendedDefaultModelSelection("codex")!, localDraftError, ) : undefined, diff --git a/apps/web/src/components/DiffPanel.logic.ts b/apps/web/src/components/DiffPanel.logic.ts index a030b2a73..f7808b225 100644 --- a/apps/web/src/components/DiffPanel.logic.ts +++ b/apps/web/src/components/DiffPanel.logic.ts @@ -3,12 +3,8 @@ // Exports: resolveDiffPanelThread, diff view source helpers // Depends on: ChatView.logic draft-thread normalization. -import { - DEFAULT_MODEL_BY_PROVIDER, - type ModelSelection, - type ThreadId, - type TurnId, -} from "@synara/contracts"; +import { type ModelSelection, type ThreadId, type TurnId } from "@synara/contracts"; +import { getRecommendedDefaultModelSelection } from "@synara/shared/model"; import type { FileDiffMetadata } from "@pierre/diffs/react"; import type { DraftThreadState } from "../composerDraftStore"; @@ -59,10 +55,7 @@ export function resolveDiffPanelThread(input: { return buildLocalDraftThread( input.threadId, input.draftThread, - input.fallbackModelSelection ?? { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, - }, + input.fallbackModelSelection ?? getRecommendedDefaultModelSelection("codex")!, null, ); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cebe41890..ce65df21e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -88,7 +88,10 @@ import { type ServerLocalServerProcess, } from "@synara/contracts"; import { isGenericChatThreadTitle } from "@synara/shared/chatThreads"; -import { getDefaultModel } from "@synara/shared/model"; +import { + getDefaultModel, + getRecommendedDefaultModelSelection, +} from "@synara/shared/model"; import { pluralize } from "@synara/shared/text"; import { localServerAddressLabel, localServerMatchesRun } from "@synara/shared/localServers"; import { resolveThreadWorkspaceCwd } from "@synara/shared/threadEnvironment"; @@ -2922,16 +2925,10 @@ export default function Sidebar() { throw new Error("The target project could not be resolved."); } - const providerDefaultModel = getDefaultModel(provider); const modelSelection = activeProject.defaultModelSelection?.provider === provider ? activeProject.defaultModelSelection - : providerDefaultModel - ? { - provider, - model: providerDefaultModel, - } - : null; + : getRecommendedDefaultModelSelection(provider); if (!modelSelection) { throw new Error("Select a Pi model before importing a Pi thread."); } diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index ca2e0335a..82ce1a07a 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -238,7 +238,7 @@ describe("getComposerProviderState", () => { it("preserves a stored runtime Codex effort for dispatch before discovery resolves", () => { const state = getComposerProviderState({ provider: "codex", - model: "gpt-5.6-sol", + model: "gpt-5.7-sol", prompt: "", modelOptions: { codex: { diff --git a/apps/web/src/components/kanban/useKanbanTaskScratchDraft.ts b/apps/web/src/components/kanban/useKanbanTaskScratchDraft.ts index 1a7b7a5cf..f2e287cf4 100644 --- a/apps/web/src/components/kanban/useKanbanTaskScratchDraft.ts +++ b/apps/web/src/components/kanban/useKanbanTaskScratchDraft.ts @@ -4,7 +4,7 @@ // Exports: useKanbanTaskScratchDraft import type { ModelSlug, ProviderKind } from "@synara/contracts"; -import { getDefaultModel } from "@synara/shared/model"; +import { getRecommendedDefaultModelSelection } from "@synara/shared/model"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { @@ -59,9 +59,11 @@ export function useKanbanTaskScratchDraft(input: { readonly defaultProvider: Pro const draftModelSelection = scratchDraft.modelSelectionByProvider[selectedProvider] ?? stickyModelSelectionByProvider[selectedProvider]; + const recommendedModelSelection = getRecommendedDefaultModelSelection(selectedProvider); const selectedModel: ModelSlug | null = - draftModelSelection?.model ?? getDefaultModel(selectedProvider); - const selectedProviderModelOptions = draftModelSelection?.options; + draftModelSelection?.model ?? recommendedModelSelection?.model ?? null; + const selectedProviderModelOptions = + draftModelSelection?.options ?? recommendedModelSelection?.options; const previousSelectedProviderRef = useRef<{ threadId: string; diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index d31da87ee..cb3beaf9e 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -2485,6 +2485,133 @@ describe("composerDraftStore modelSelection", () => { expect(state.selectedModel).toBe("opencode/gpt-5-nano"); }); + it("applies the recommended Codex model and high effort independently of catalog order", () => { + const state = deriveEffectiveComposerModelState({ + draft: { modelSelectionByProvider: {}, activeProvider: "codex" }, + selectedProvider: "codex", + threadModelSelection: null, + projectModelSelection: null, + customModelsByProvider: { + codex: [], + claudeAgent: [], + cursor: [], + antigravity: [], + grok: [], + droid: [], + kilo: [], + opencode: [], + pi: [], + }, + availableModelOptionsByProvider: { + codex: [ + { slug: "gpt-5.5", name: "GPT-5.5" }, + { + slug: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + supportedReasoningEfforts: [{ value: "low" }, { value: "high" }], + }, + ], + }, + }); + + expect(state).toEqual({ + selectedModel: "gpt-5.6-sol", + modelOptions: { codex: { reasoningEffort: "high" } }, + }); + }); + + it("preserves an explicit thread model instead of silently upgrading it", () => { + const state = deriveEffectiveComposerModelState({ + draft: { modelSelectionByProvider: {}, activeProvider: "codex" }, + selectedProvider: "codex", + threadModelSelection: modelSelection("codex", "gpt-5.5"), + projectModelSelection: null, + customModelsByProvider: { + codex: [], + claudeAgent: [], + cursor: [], + antigravity: [], + grok: [], + droid: [], + kilo: [], + opencode: [], + pi: [], + }, + availableModelOptionsByProvider: { + codex: [ + { slug: "gpt-5.6-sol", name: "GPT-5.6 Sol" }, + { slug: "gpt-5.5", name: "GPT-5.5" }, + ], + }, + }); + + expect(state).toEqual({ selectedModel: "gpt-5.5", modelOptions: null }); + }); + + it("selects Claude Opus from resolved metadata instead of a stale first row", () => { + const state = deriveEffectiveComposerModelState({ + draft: { modelSelectionByProvider: {}, activeProvider: "claudeAgent" }, + selectedProvider: "claudeAgent", + threadModelSelection: null, + projectModelSelection: null, + customModelsByProvider: { + codex: [], + claudeAgent: [], + cursor: [], + antigravity: [], + grok: [], + droid: [], + kilo: [], + opencode: [], + pi: [], + }, + availableModelOptionsByProvider: { + claudeAgent: [ + { slug: "sonnet", name: "Sonnet", resolvedModel: "claude-sonnet-5" }, + { + slug: "opus[1m]", + name: "Opus", + resolvedModel: "claude-opus-4-8[1m]", + supportedReasoningEfforts: [{ value: "high" }], + }, + ], + }, + }); + + expect(state).toEqual({ + selectedModel: "opus[1m]", + modelOptions: { claudeAgent: { effort: "high" } }, + }); + }); + + it("selects Droid Auto without adding a reasoning override", () => { + const state = deriveEffectiveComposerModelState({ + draft: { modelSelectionByProvider: {}, activeProvider: "droid" }, + selectedProvider: "droid", + threadModelSelection: null, + projectModelSelection: null, + customModelsByProvider: { + codex: [], + claudeAgent: [], + cursor: [], + antigravity: [], + grok: [], + droid: [], + kilo: [], + opencode: [], + pi: [], + }, + availableModelOptionsByProvider: { + droid: [ + { slug: "claude-opus-4-8", name: "Claude Opus 4.8" }, + { slug: "auto", name: "Auto Model" }, + ], + }, + }); + + expect(state).toEqual({ selectedModel: "auto", modelOptions: null }); + }); + it("preserves the persisted OpenCode thread model when discovery omits it", () => { const state = deriveEffectiveComposerModelState({ draft: { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index a2fcc6825..2c8297d29 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -31,7 +31,10 @@ import * as Equal from "effect/Equal"; import { DeepMutable } from "effect/Types"; import { getDefaultModel, + getRecommendedDefaultModelSelection, + type RecommendedModelCandidate, normalizeModelSlug, + resolveRecommendedModelSelection, resolveSelectableModel, resolveModelSlugForProvider, } from "@synara/shared/model"; @@ -706,8 +709,10 @@ function deriveEffectiveComposerModelOptions(input: { | undefined; threadModelSelection: ModelSelection | null | undefined; projectModelSelection: ModelSelection | null | undefined; + policyModelSelection?: ModelSelection | null | undefined; }): ProviderModelOptions | null { const baseOptions = mergeProviderModelOptionsFromSelections( + input.policyModelSelection, input.projectModelSelection, input.threadModelSelection, ); @@ -1825,7 +1830,7 @@ export function deriveEffectiveComposerModelState(input: { projectModelSelection: ModelSelection | null | undefined; customModelsByProvider: Record; availableModelOptionsByProvider?: Partial< - Record> + Record> >; }): EffectiveComposerModelState { const resolveAvailableModel = (candidate: string | null | undefined): ModelSlug | null => { @@ -1864,6 +1869,10 @@ export function deriveEffectiveComposerModelState(input: { ) : null; const unlistedDraftModel = input.selectedProvider === "pi" ? selectedDraftModel : null; + const policyModelSelection = resolveRecommendedModelSelection( + input.selectedProvider, + input.availableModelOptionsByProvider?.[input.selectedProvider], + ); const selectedModel = resolveAvailableModel(activeSelection?.model) ?? resolveAvailableModel( @@ -1880,11 +1889,15 @@ export function deriveEffectiveComposerModelState(input: { persistedThreadModel ?? persistedProjectModel ?? unlistedDraftModel ?? - input.availableModelOptionsByProvider?.[input.selectedProvider]?.[0]?.slug ?? + policyModelSelection?.model ?? selectedDraftModel ?? baseModel ?? getDefaultModel("codex"); - const modelOptions = deriveEffectiveComposerModelOptions(input); + const modelOptions = deriveEffectiveComposerModelOptions({ + ...input, + policyModelSelection: + policyModelSelection?.model === selectedModel ? policyModelSelection : undefined, + }); return { selectedModel, @@ -1922,10 +1935,9 @@ export function resolvePreferredComposerModelSelection(input: { : null) ?? (input.projectModelSelection?.provider === preferredProvider ? input.projectModelSelection - : null) ?? { - provider: preferredProvider === "pi" ? "codex" : preferredProvider, - model: getDefaultModel(preferredProvider === "pi" ? "codex" : preferredProvider), - } + : null) ?? + getRecommendedDefaultModelSelection(preferredProvider) ?? + getRecommendedDefaultModelSelection("codex")! ); } diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index a93746e16..539e17113 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -1,5 +1,5 @@ import { type ProjectId, ThreadId } from "@synara/contracts"; -import { getDefaultModel } from "@synara/shared/model"; +import { getRecommendedDefaultModelSelection } from "@synara/shared/model"; import { useNavigate, useRouter } from "@tanstack/react-router"; import { startTransition, useCallback } from "react"; import { useAppSettings } from "../appSettings"; @@ -65,14 +65,11 @@ export function useHandleNewThread() { if (!options?.provider) { return; } - const defaultModel = getDefaultModel(options.provider); - if (!defaultModel) { + const defaultModelSelection = getRecommendedDefaultModelSelection(options.provider); + if (!defaultModelSelection) { return; } - setModelSelection(threadId, { - provider: options.provider, - model: defaultModel, - }); + setModelSelection(threadId, defaultModelSelection); }; const restoreComposerDraft = ( threadId: ThreadId, diff --git a/apps/web/src/lib/chatFirstSend.test.ts b/apps/web/src/lib/chatFirstSend.test.ts index 5848734b8..0ccea94ab 100644 --- a/apps/web/src/lib/chatFirstSend.test.ts +++ b/apps/web/src/lib/chatFirstSend.test.ts @@ -45,6 +45,11 @@ describe("resolveFirstSendTarget", () => { title: "Yes it takes", kind: "chat", createWorkspaceRootIfMissing: true, + defaultModelSelection: { + provider: "codex", + model: "gpt-5.6-sol", + options: { reasoningEffort: "high" }, + }, }, }); }); @@ -70,6 +75,11 @@ describe("resolveFirstSendTarget", () => { title: "app", kind: "project", createWorkspaceRootIfMissing: false, + defaultModelSelection: { + provider: "codex", + model: "gpt-5.6-sol", + options: { reasoningEffort: "high" }, + }, }, }); }); diff --git a/apps/web/src/lib/chatFirstSend.ts b/apps/web/src/lib/chatFirstSend.ts index 32f5ab7ca..171daa81e 100644 --- a/apps/web/src/lib/chatFirstSend.ts +++ b/apps/web/src/lib/chatFirstSend.ts @@ -1,4 +1,5 @@ -import { DEFAULT_MODEL_BY_PROVIDER, type ModelSelection } from "@synara/contracts"; +import { type ModelSelection } from "@synara/contracts"; +import { getRecommendedDefaultModelSelection } from "@synara/shared/model"; import { workspaceRootsEqual } from "@synara/shared/threadWorkspace"; import type { Project } from "../types"; @@ -99,10 +100,7 @@ export function resolveFirstSendTarget(input: { title, kind: "chat", createWorkspaceRootIfMissing: true, - defaultModelSelection: { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, - }, + defaultModelSelection: getRecommendedDefaultModelSelection("codex")!, }, }; } @@ -125,10 +123,7 @@ export function resolveFirstSendTarget(input: { title: buildProjectTitleFromWorkspaceRoot(selectedWorkspaceRoot), kind: "project", createWorkspaceRootIfMissing: false, - defaultModelSelection: { - provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, - }, + defaultModelSelection: getRecommendedDefaultModelSelection("codex")!, }, }; } diff --git a/apps/web/src/lib/projectCreation.test.ts b/apps/web/src/lib/projectCreation.test.ts index 1dcf93bd0..f85a13e96 100644 --- a/apps/web/src/lib/projectCreation.test.ts +++ b/apps/web/src/lib/projectCreation.test.ts @@ -70,6 +70,11 @@ describe("createOrRecoverProjectFromPath", () => { title: "synara", workspaceRoot: WORKSPACE_ROOT, createWorkspaceRootIfMissing: false, + defaultModelSelection: { + provider: "codex", + model: "gpt-5.6-sol", + options: { reasoningEffort: "high" }, + }, }), ); expect(createdProjectId).not.toBeNull(); diff --git a/apps/web/src/lib/projectCreation.ts b/apps/web/src/lib/projectCreation.ts index 78b556723..fcd48ddc1 100644 --- a/apps/web/src/lib/projectCreation.ts +++ b/apps/web/src/lib/projectCreation.ts @@ -4,7 +4,7 @@ // Exports: createOrRecoverProjectFromPath import { type NativeApi, type OrchestrationShellSnapshot, type ProjectId } from "@synara/contracts"; -import { getDefaultModel } from "@synara/shared/model"; +import { getRecommendedDefaultModelSelection } from "@synara/shared/model"; import { extractDuplicateProjectCreateProjectId, @@ -60,10 +60,7 @@ export async function createOrRecoverProjectFromPath(input: { title, workspaceRoot, createWorkspaceRootIfMissing: input.createIfMissing === true, - defaultModelSelection: { - provider: "codex", - model: getDefaultModel("codex"), - }, + defaultModelSelection: getRecommendedDefaultModelSelection("codex")!, createdAt, }); diff --git a/apps/web/src/lib/threadHandoff.test.ts b/apps/web/src/lib/threadHandoff.test.ts index 2ac5e7b15..01c85a9cc 100644 --- a/apps/web/src/lib/threadHandoff.test.ts +++ b/apps/web/src/lib/threadHandoff.test.ts @@ -475,7 +475,8 @@ describe("threadHandoff", () => { }), ).toEqual({ provider: "codex", - model: "gpt-5.5", + model: "gpt-5.6-sol", + options: { reasoningEffort: "high" }, }); }); }); diff --git a/apps/web/src/lib/threadHandoff.ts b/apps/web/src/lib/threadHandoff.ts index 91b9433cc..8f191be58 100644 --- a/apps/web/src/lib/threadHandoff.ts +++ b/apps/web/src/lib/threadHandoff.ts @@ -13,7 +13,7 @@ import { type ProviderKind, type ThreadHandoffImportedMessage, } from "@synara/contracts"; -import { getDefaultModel } from "@synara/shared/model"; +import { getRecommendedDefaultModelSelection } from "@synara/shared/model"; import { isLatestTurnSettled } from "../session-logic"; import { type Thread } from "../types"; import { stripEmbeddedAssistantSelections } from "./assistantSelections"; @@ -311,12 +311,9 @@ export function resolveThreadHandoffModelSelection(input: { if (isCompatibleSelection(input.projectDefaultModelSelection)) { return input.projectDefaultModelSelection; } - const defaultModel = getDefaultModel(input.targetProvider); - if (!defaultModel) { + const defaultSelection = getRecommendedDefaultModelSelection(input.targetProvider); + if (!defaultSelection) { throw new Error("Select a Pi model before handing off to Pi."); } - return { - provider: input.targetProvider, - model: defaultModel, - }; + return defaultSelection; } diff --git a/apps/web/src/providerModelOptions.test.ts b/apps/web/src/providerModelOptions.test.ts index 1704e65ca..18b744277 100644 --- a/apps/web/src/providerModelOptions.test.ts +++ b/apps/web/src/providerModelOptions.test.ts @@ -152,6 +152,47 @@ describe("mergeDynamicModelOptions", () => { }, ]); }); + + it("preserves Claude SDK order and metadata while hiding the generic default row", () => { + expect( + mergeDynamicModelOptions({ + provider: "claudeAgent", + staticOptions: [], + dynamicModels: [ + { + slug: "default", + name: "Default (recommended)", + resolvedModel: "claude-sonnet-5", + isDefault: true, + }, + { + slug: "sonnet", + name: "Sonnet", + resolvedModel: "claude-sonnet-5", + }, + { + slug: "opus[1m]", + name: "Opus", + resolvedModel: "claude-opus-4-8[1m]", + supportedReasoningEfforts: [{ value: "low" }, { value: "high" }], + }, + ], + }), + ).toEqual([ + { + slug: "claude-sonnet-5", + name: "Claude Sonnet 5", + resolvedModel: "claude-sonnet-5", + isDefault: true, + }, + { + slug: "claude-opus-4-8", + name: "Opus", + resolvedModel: "claude-opus-4-8[1m]", + supportedReasoningEfforts: [{ value: "low" }, { value: "high" }], + }, + ]); + }); }); describe("providerModelCostMultiplierLabel", () => { diff --git a/apps/web/src/providerModelOptions.ts b/apps/web/src/providerModelOptions.ts index e0bca8c3f..bec707827 100644 --- a/apps/web/src/providerModelOptions.ts +++ b/apps/web/src/providerModelOptions.ts @@ -32,9 +32,16 @@ export type ProviderOptions = ProviderModelOptions[ProviderKind]; export interface ProviderModelOption { slug: string; name: string; + resolvedModel?: string; + isDefault?: true; description?: string; upstreamProviderId?: string; upstreamProviderName?: string; + supportedReasoningEfforts?: ReadonlyArray<{ + value: string; + label?: string | undefined; + description?: string | undefined; + }>; } export interface ProviderModelOptionGroup { @@ -112,12 +119,33 @@ export function mergeDynamicModelOptions(input: { dynamicModels: ReadonlyArray<{ slug: string; name?: string | null | undefined; + resolvedModel?: string | null | undefined; + isDefault?: true | undefined; description?: string | null | undefined; upstreamProviderId?: string | null | undefined; upstreamProviderName?: string | null | undefined; + supportedReasoningEfforts?: + | ReadonlyArray<{ + value: string; + label?: string | undefined; + description?: string | undefined; + }> + | undefined; }>; }): ReadonlyArray { const staticNameBySlug = new Map(input.staticOptions.map((model) => [model.slug, model.name])); + const claudeResolvedDefaultSlug = + input.provider === "claudeAgent" + ? input.dynamicModels + .find( + (model) => + model.isDefault === true || model.slug.trim().toLowerCase() === "default", + ) + ?.resolvedModel?.trim() + : undefined; + const normalizedClaudeResolvedDefaultSlug = claudeResolvedDefaultSlug + ? normalizeDynamicModelSlug("claudeAgent", claudeResolvedDefaultSlug) + : undefined; const dynamicNormalizedSlugs = new Set(); const normalizedDynamicOptions: ProviderModelOption[] = []; @@ -133,6 +161,8 @@ export function mergeDynamicModelOptions(input: { } const normalizedSlug = normalizeDynamicModelSlug(input.provider, dynamicModel.slug); + const isDefault = + dynamicModel.isDefault === true || normalizedSlug === normalizedClaudeResolvedDefaultSlug; const rawSlug = dynamicModel.slug.trim().toLowerCase(); const displayNameFallback = formatProviderModelOptionName({ provider: input.provider, @@ -151,6 +181,10 @@ export function mergeDynamicModelOptions(input: { rawName.toLowerCase() !== normalizedSlug.toLowerCase() ? rawName : displayNameFallback), + ...(dynamicModel.resolvedModel?.trim() + ? { resolvedModel: dynamicModel.resolvedModel.trim() } + : {}), + ...(isDefault ? { isDefault: true as const } : {}), ...(dynamicModel.description?.trim() ? { description: dynamicModel.description.trim() } : {}), ...(dynamicModel.upstreamProviderId?.trim() ? { upstreamProviderId: dynamicModel.upstreamProviderId.trim() } @@ -158,6 +192,9 @@ export function mergeDynamicModelOptions(input: { ...(dynamicModel.upstreamProviderName?.trim() ? { upstreamProviderName: dynamicModel.upstreamProviderName.trim() } : {}), + ...(dynamicModel.supportedReasoningEfforts?.length + ? { supportedReasoningEfforts: dynamicModel.supportedReasoningEfforts } + : {}), }); } @@ -185,12 +222,7 @@ export function mergeDynamicModelOptions(input: { ? [] : staticBuiltInModels.filter((model) => !dynamicNormalizedSlugs.has(model.slug)); - const orderedDynamicOptions = - input.provider === "claudeAgent" - ? normalizedDynamicOptions.toReversed() - : normalizedDynamicOptions; - - return [...orderedDynamicOptions, ...missingStaticBuiltIns, ...customOnlyModels]; + return [...normalizedDynamicOptions, ...missingStaticBuiltIns, ...customOnlyModels]; } /** Returns a compact label for provider descriptions that begin with an `Nx` cost multiplier. */ diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 607d0fe04..8d4e0ed61 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -222,6 +222,18 @@ const CODEX_GPT_5_5_CAPABILITIES: ModelCapabilities = { ], }; +const CODEX_GPT_5_6_CAPABILITIES: ModelCapabilities = { + ...CODEX_GPT_5_CAPABILITIES, + reasoningEffortLevels: [ + { value: "none", label: "None" }, + { value: "low", label: "Low", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High" }, + { value: "max", label: "Max" }, + ], +}; + const GROK_BUILD_CAPABILITIES: ModelCapabilities = { reasoningEffortLevels: [ { value: "none", label: "None" }, @@ -436,6 +448,21 @@ type ModelDefinition = { */ export const MODEL_OPTIONS_BY_PROVIDER = { codex: [ + { + slug: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + capabilities: CODEX_GPT_5_6_CAPABILITIES, + }, + { + slug: "gpt-5.6-terra", + name: "GPT-5.6 Terra", + capabilities: CODEX_GPT_5_6_CAPABILITIES, + }, + { + slug: "gpt-5.6-luna", + name: "GPT-5.6 Luna", + capabilities: CODEX_GPT_5_6_CAPABILITIES, + }, { slug: "gpt-5.5", name: "GPT-5.5", @@ -536,6 +563,16 @@ export const MODEL_OPTIONS_BY_PROVIDER = { // `agy models` so CLI updates appear without a Synara release. antigravity: [], grok: [ + { + slug: "grok-build-latest", + name: "Grok Build Latest", + capabilities: GROK_BUILD_CAPABILITIES, + }, + { + slug: "grok-4.5", + name: "Grok 4.5", + capabilities: GROK_BUILD_CAPABILITIES, + }, { slug: "grok-build-0.1", name: "Grok Build 0.1", @@ -732,6 +769,14 @@ export const MODEL_OPTIONS_BY_PROVIDER = { }, ], opencode: [ + { + slug: "openai/gpt-5.6-sol", + name: "OpenAI GPT-5.6 Sol", + capabilities: { + ...CODEX_GPT_5_6_CAPABILITIES, + variantOptions: CODEX_GPT_5_6_CAPABILITIES.reasoningEffortLevels, + }, + }, { slug: "openai/gpt-5", name: "OpenAI GPT-5", @@ -745,6 +790,17 @@ export const MODEL_OPTIONS_BY_PROVIDER = { }, ], kilo: [ + { + slug: "kilo/kilo-auto/frontier", + name: "Kilo Auto Frontier", + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + contextWindowOptions: [], + }, + }, { slug: "kilo/kilo-auto/free", name: "Kilo Auto Free", @@ -759,6 +815,11 @@ export const MODEL_OPTIONS_BY_PROVIDER = { ], pi: [], cursor: [ + { + slug: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + capabilities: CODEX_GPT_5_6_CAPABILITIES, + }, { slug: "auto", name: "Auto", @@ -823,14 +884,14 @@ export type ModelSlug = BuiltInModelSlug | (string & {}); export type ProviderWithDefaultModel = Exclude; export const DEFAULT_MODEL_BY_PROVIDER: Record = { - codex: "gpt-5.5", - claudeAgent: "claude-sonnet-5", - cursor: "auto", - antigravity: "Gemini 3.5 Flash", - grok: "grok-build", - droid: "claude-opus-4-8", - kilo: "kilo/kilo-auto/free", - opencode: "openai/gpt-5", + codex: "gpt-5.6-sol", + claudeAgent: "claude-opus-4-8", + cursor: "gpt-5.6-sol", + antigravity: "gemini-3.6-flash", + grok: "grok-build-latest", + droid: "auto", + kilo: "kilo/kilo-auto/frontier", + opencode: "openai/gpt-5.6-sol", }; // Backward compatibility for existing Codex-only call sites. @@ -840,6 +901,11 @@ export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini" as const; export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record> = { codex: { + "5.6": "gpt-5.6-sol", + "gpt-5.6": "gpt-5.6-sol", + "5.6-sol": "gpt-5.6-sol", + "5.6-terra": "gpt-5.6-terra", + "5.6-luna": "gpt-5.6-luna", "5.5": "gpt-5.5", "5.4": "gpt-5.4", "5.3": "gpt-5.3-codex", @@ -883,13 +949,16 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record { expect(normalizeModelSlug("opus-4.6", "claudeAgent")).toBe("claude-opus-4-6"); expect(normalizeModelSlug("claude-haiku-4-5-20251001", "claudeAgent")).toBe("claude-haiku-4-5"); expect(normalizeModelSlug("4.3", "grok")).toBe("grok-build"); - expect(normalizeModelSlug("grok-latest", "grok")).toBe("grok-build"); + expect(normalizeModelSlug("grok-latest", "grok")).toBe("grok-build-latest"); expect(normalizeModelSlug("grok-code-fast-1", "grok")).toBe("grok-build-0.1"); expect(normalizeModelSlug("grok-code-fast-1-0825", "grok")).toBe("grok-build-0.1"); }); @@ -180,6 +182,129 @@ describe("resolveSelectableModel", () => { }); }); +describe("recommended provider defaults", () => { + it("uses current product defaults with explicit high effort where Scient owns it", () => { + expect(getRecommendedDefaultModelSelection("codex")).toEqual({ + provider: "codex", + model: "gpt-5.6-sol", + options: { reasoningEffort: "high" }, + }); + expect(getRecommendedDefaultModelSelection("claudeAgent")).toEqual({ + provider: "claudeAgent", + model: "claude-opus-4-8", + options: { effort: "high" }, + }); + expect(getRecommendedDefaultModelSelection("cursor")).toEqual({ + provider: "cursor", + model: "gpt-5.6-sol", + options: { reasoningEffort: "high" }, + }); + expect(getRecommendedDefaultModelSelection("antigravity")).toEqual({ + provider: "antigravity", + model: "gemini-3.6-flash", + options: { reasoningEffort: "high" }, + }); + expect(getRecommendedDefaultModelSelection("grok")).toEqual({ + provider: "grok", + model: "grok-build-latest", + options: { reasoningEffort: "high" }, + }); + expect(getRecommendedDefaultModelSelection("droid")).toEqual({ + provider: "droid", + model: "auto", + }); + expect(getRecommendedDefaultModelSelection("kilo")).toEqual({ + provider: "kilo", + model: "kilo/kilo-auto/frontier", + }); + expect(getRecommendedDefaultModelSelection("opencode")).toEqual({ + provider: "opencode", + model: "openai/gpt-5.6-sol", + options: { variant: "high" }, + }); + expect(getRecommendedDefaultModelSelection("pi")).toBeNull(); + }); + + it("chooses Claude Opus through resolved SDK metadata regardless of catalog order", () => { + expect( + resolveRecommendedModelSelection("claudeAgent", [ + { + slug: "sonnet", + name: "Claude Sonnet 5", + resolvedModel: "claude-sonnet-5", + supportedReasoningEfforts: [{ value: "high" }], + }, + { + slug: "opus[1m]", + name: "Claude Opus 4.8 (1M context)", + resolvedModel: "claude-opus-4-8[1m]", + supportedReasoningEfforts: [{ value: "low" }, { value: "high" }], + }, + ]), + ).toEqual({ + provider: "claudeAgent", + model: "opus[1m]", + options: { effort: "high" }, + }); + }); + + it("uses the recommended available model instead of the first catalog row", () => { + expect( + resolveRecommendedModelSelection("codex", [ + { slug: "gpt-5.5", name: "GPT-5.5" }, + { + slug: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + supportedReasoningEfforts: [{ value: "low" }, { value: "high" }], + }, + ]), + ).toEqual({ + provider: "codex", + model: "gpt-5.6-sol", + options: { reasoningEffort: "high" }, + }); + }); + + it("keeps Droid on its provider router without forcing reasoning effort", () => { + expect( + resolveRecommendedModelSelection("droid", [ + { slug: "claude-opus-4-8", name: "Claude Opus 4.8" }, + { + slug: "auto", + name: "Auto Model", + supportedReasoningEfforts: [{ value: "high" }], + }, + ]), + ).toEqual({ provider: "droid", model: "auto" }); + }); + + it("selects the best available Pi model and high thinking from its live catalog", () => { + expect( + resolveRecommendedModelSelection("pi", [ + { slug: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5" }, + { + slug: "openai/gpt-5.6-sol", + name: "GPT-5.6 Sol", + supportedReasoningEfforts: [{ value: "medium" }, { value: "high" }], + }, + ]), + ).toEqual({ + provider: "pi", + model: "openai/gpt-5.6-sol", + options: { thinkingLevel: "high" }, + }); + }); + + it("falls back to the provider-advertised default when preferred models are unavailable", () => { + expect( + resolveRecommendedModelSelection("opencode", [ + { slug: "vendor/tiny", name: "Tiny" }, + { slug: "vendor/reliable", name: "Reliable", isDefault: true }, + ]), + ).toEqual({ provider: "opencode", model: "vendor/reliable" }); + }); +}); + describe("getModelCapabilities reasoningEffortLevels", () => { const values = (provider: "codex" | "claudeAgent" | "grok" | "droid", model: string | null) => getModelCapabilities(provider, model).reasoningEffortLevels.map((l) => l.value); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 63583e5d5..a5e0039ad 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -18,6 +18,7 @@ import { type OpenCodeModelOptions, type ProviderOptionDescriptor, type ProviderOptionSelection, + type ProviderModelDescriptor, type PiModelOptions, type PiThinkingLevel, type ProviderKind, @@ -43,6 +44,201 @@ export interface SelectableModelOption { name: string; } +export type RecommendedModelCandidate = SelectableModelOption & + Partial< + Pick< + ProviderModelDescriptor, + "resolvedModel" | "isDefault" | "supportedReasoningEfforts" + > + >; + +const RECOMMENDED_MODEL_IDENTIFIERS: Record< + ProviderKind, + ReadonlyArray> +> = { + codex: [["gpt-5-6-sol"], ["gpt-5-6"], ["gpt-5-5"]], + claudeAgent: [["claude-opus-4-8"], ["opus"]], + cursor: [["gpt-5-6-sol"], ["auto"]], + antigravity: [["gemini-3-6-flash"], ["gemini-3-5-flash"]], + grok: [["grok-build-latest"], ["grok-4-5-latest"], ["grok-4-5"], ["grok-build"]], + droid: [["auto"]], + kilo: [["kilo-auto/frontier"]], + opencode: [ + ["gpt-5-6-sol"], + ["claude-opus-4-8"], + ["gemini-3-6-flash"], + ["grok-4-5"], + ], + pi: [ + ["gpt-5-6-sol"], + ["claude-opus-4-8"], + ["gemini-3-6-flash"], + ["grok-4-5"], + ], +}; + +function canonicalModelIdentity(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/\[[^\]]*\]$/u, "") + .replace(/[._\s]+/gu, "-") + .replace(/-+/gu, "-"); +} + +function candidateModelIdentities(candidate: RecommendedModelCandidate): string[] { + return [candidate.slug, candidate.resolvedModel, candidate.name] + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .map(canonicalModelIdentity); +} + +function candidateMatchesIdentifier( + candidate: RecommendedModelCandidate, + identifier: string, +): boolean { + const canonicalIdentifier = canonicalModelIdentity(identifier); + return candidateModelIdentities(candidate).some( + (identity) => + identity === canonicalIdentifier || + identity.endsWith(`/${canonicalIdentifier}`) || + identity.endsWith(`:${canonicalIdentifier}`), + ); +} + +function findRecommendedCandidate( + provider: ProviderKind, + candidates: ReadonlyArray, +): RecommendedModelCandidate | undefined { + for (const identifiers of RECOMMENDED_MODEL_IDENTIFIERS[provider]) { + const candidate = candidates.find((option) => + identifiers.some((identifier) => candidateMatchesIdentifier(option, identifier)), + ); + if (candidate) { + return candidate; + } + } + + if (provider === "claudeAgent") { + return candidates + .filter((candidate) => + candidateModelIdentities(candidate).some((identity) => identity.includes("opus")), + ) + .toSorted((left, right) => + candidateModelIdentities(right) + .join(" ") + .localeCompare(candidateModelIdentities(left).join(" "), undefined, { + numeric: true, + }), + )[0]; + } + + return undefined; +} + +function candidateSupportsHighEffort( + provider: ProviderKind, + candidate: RecommendedModelCandidate, +): boolean { + const runtimeEfforts = candidate.supportedReasoningEfforts; + if (runtimeEfforts && runtimeEfforts.length > 0) { + return runtimeEfforts.some((effort) => effort.value === "high"); + } + + // Antigravity has no static catalog, but this product-recommended model supports + // the high reasoning option. Live metadata remains authoritative when present. + if ( + provider === "antigravity" && + candidateMatchesIdentifier(candidate, "gemini-3-6-flash") + ) { + return true; + } + + const capabilities = getModelCapabilities(provider, candidate.slug); + return [...capabilities.reasoningEffortLevels, ...(capabilities.variantOptions ?? [])].some( + (effort) => effort.value === "high", + ); +} + +function recommendedModelSelection( + provider: ProviderKind, + candidate: RecommendedModelCandidate, +): ModelSelection { + const highEffort = candidateSupportsHighEffort(provider, candidate); + switch (provider) { + case "codex": + return { + provider, + model: candidate.slug, + ...(highEffort ? { options: { reasoningEffort: "high" } } : {}), + }; + case "claudeAgent": + return { + provider, + model: candidate.slug, + ...(highEffort ? { options: { effort: "high" } } : {}), + }; + case "cursor": + case "antigravity": + return { + provider, + model: candidate.slug, + ...(highEffort ? { options: { reasoningEffort: "high" } } : {}), + }; + case "grok": + return { + provider, + model: candidate.slug, + ...(highEffort ? { options: { reasoningEffort: "high" } } : {}), + }; + case "droid": + // Droid's Auto model owns both routing and reasoning effort. + return { provider, model: candidate.slug }; + case "kilo": + // Kilo Frontier routes the request to the best model for the task. + return { provider, model: candidate.slug }; + case "opencode": + return { + provider, + model: candidate.slug, + ...(highEffort ? { options: { variant: "high" } } : {}), + }; + case "pi": + return { + provider, + model: candidate.slug, + ...(highEffort ? { options: { thinkingLevel: "high" } } : {}), + }; + } +} + +/** Product-level default used before a provider catalog is available. */ +export function getRecommendedDefaultModelSelection( + provider: ProviderKind, +): ModelSelection | null { + const model = getDefaultModel(provider); + return model ? recommendedModelSelection(provider, { slug: model, name: model }) : null; +} + +/** + * Resolves a fresh provider selection against the models that account can actually use. + * Catalog order is presentation-only: policy wins, then the provider's advertised default, + * and only then the first available model. + */ +export function resolveRecommendedModelSelection( + provider: ProviderKind, + candidates: ReadonlyArray | null | undefined, +): ModelSelection | null { + if (!candidates || candidates.length === 0) { + return getRecommendedDefaultModelSelection(provider); + } + + const candidate = + findRecommendedCandidate(provider, candidates) ?? + candidates.find((option) => option.isDefault === true) ?? + candidates[0]; + return candidate ? recommendedModelSelection(provider, candidate) : null; +} + const PI_THINKING_LEVEL_SET = new Set([ "off", "minimal", From 81f8bd3ad9a485d893bfc11f5b64bb80dee77eef Mon Sep 17 00:00:00 2001 From: Yaacov Date: Thu, 23 Jul 2026 01:13:41 +0300 Subject: [PATCH 2/5] Apply repository formatting --- .../src/provider/Layers/ClaudeAdapter.test.ts | 27 +++++++-------- .../src/provider/Layers/ClaudeAdapter.ts | 3 +- apps/web/src/components/Sidebar.tsx | 5 +-- apps/web/src/providerModelOptions.ts | 3 +- packages/shared/src/model.ts | 33 ++++--------------- 5 files changed, 24 insertions(+), 47 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index a917eb5a2..7beb47a55 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -340,19 +340,20 @@ describe("ClaudeAdapterLive", () => { it.effect("uses the configured Claude executable for pre-session model discovery", () => { const harness = makeHarness(); - (harness.query as { supportedModels: () => Promise }).supportedModels = async () => [ - { - value: "opus[1m]", - resolvedModel: "claude-opus-4-8[1m]", - displayName: "Claude Opus 4.8 (1M context)", - description: "Complex agentic coding", - supportsEffort: true, - supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"], - supportsAdaptiveThinking: true, - supportsFastMode: true, - supportsAutoMode: false, - }, - ]; + (harness.query as { supportedModels: () => Promise }).supportedModels = + async () => [ + { + value: "opus[1m]", + resolvedModel: "claude-opus-4-8[1m]", + displayName: "Claude Opus 4.8 (1M context)", + description: "Complex agentic coding", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"], + supportsAdaptiveThinking: true, + supportsFastMode: true, + supportsAutoMode: false, + }, + ]; return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; if (!adapter.listModels) { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 47267c775..2050b245a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -305,7 +305,8 @@ function mapClaudeModelInfo(model: ModelInfo): ProviderModelDescriptor { const description = model.description.trim(); const supportedReasoningEfforts = model.supportedEffortLevels?.map((effort) => ({ value: effort, - label: effort === "xhigh" ? "Extra High" : `${effort.charAt(0).toUpperCase()}${effort.slice(1)}`, + label: + effort === "xhigh" ? "Extra High" : `${effort.charAt(0).toUpperCase()}${effort.slice(1)}`, })); return { slug: model.value, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ce65df21e..fbbb91046 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -88,10 +88,7 @@ import { type ServerLocalServerProcess, } from "@synara/contracts"; import { isGenericChatThreadTitle } from "@synara/shared/chatThreads"; -import { - getDefaultModel, - getRecommendedDefaultModelSelection, -} from "@synara/shared/model"; +import { getDefaultModel, getRecommendedDefaultModelSelection } from "@synara/shared/model"; import { pluralize } from "@synara/shared/text"; import { localServerAddressLabel, localServerMatchesRun } from "@synara/shared/localServers"; import { resolveThreadWorkspaceCwd } from "@synara/shared/threadEnvironment"; diff --git a/apps/web/src/providerModelOptions.ts b/apps/web/src/providerModelOptions.ts index bec707827..d771cf761 100644 --- a/apps/web/src/providerModelOptions.ts +++ b/apps/web/src/providerModelOptions.ts @@ -138,8 +138,7 @@ export function mergeDynamicModelOptions(input: { input.provider === "claudeAgent" ? input.dynamicModels .find( - (model) => - model.isDefault === true || model.slug.trim().toLowerCase() === "default", + (model) => model.isDefault === true || model.slug.trim().toLowerCase() === "default", ) ?.resolvedModel?.trim() : undefined; diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index a5e0039ad..3862c9827 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -46,16 +46,10 @@ export interface SelectableModelOption { export type RecommendedModelCandidate = SelectableModelOption & Partial< - Pick< - ProviderModelDescriptor, - "resolvedModel" | "isDefault" | "supportedReasoningEfforts" - > + Pick >; -const RECOMMENDED_MODEL_IDENTIFIERS: Record< - ProviderKind, - ReadonlyArray> -> = { +const RECOMMENDED_MODEL_IDENTIFIERS: Record>> = { codex: [["gpt-5-6-sol"], ["gpt-5-6"], ["gpt-5-5"]], claudeAgent: [["claude-opus-4-8"], ["opus"]], cursor: [["gpt-5-6-sol"], ["auto"]], @@ -63,18 +57,8 @@ const RECOMMENDED_MODEL_IDENTIFIERS: Record< grok: [["grok-build-latest"], ["grok-4-5-latest"], ["grok-4-5"], ["grok-build"]], droid: [["auto"]], kilo: [["kilo-auto/frontier"]], - opencode: [ - ["gpt-5-6-sol"], - ["claude-opus-4-8"], - ["gemini-3-6-flash"], - ["grok-4-5"], - ], - pi: [ - ["gpt-5-6-sol"], - ["claude-opus-4-8"], - ["gemini-3-6-flash"], - ["grok-4-5"], - ], + opencode: [["gpt-5-6-sol"], ["claude-opus-4-8"], ["gemini-3-6-flash"], ["grok-4-5"]], + pi: [["gpt-5-6-sol"], ["claude-opus-4-8"], ["gemini-3-6-flash"], ["grok-4-5"]], }; function canonicalModelIdentity(value: string): string { @@ -146,10 +130,7 @@ function candidateSupportsHighEffort( // Antigravity has no static catalog, but this product-recommended model supports // the high reasoning option. Live metadata remains authoritative when present. - if ( - provider === "antigravity" && - candidateMatchesIdentifier(candidate, "gemini-3-6-flash") - ) { + if (provider === "antigravity" && candidateMatchesIdentifier(candidate, "gemini-3-6-flash")) { return true; } @@ -212,9 +193,7 @@ function recommendedModelSelection( } /** Product-level default used before a provider catalog is available. */ -export function getRecommendedDefaultModelSelection( - provider: ProviderKind, -): ModelSelection | null { +export function getRecommendedDefaultModelSelection(provider: ProviderKind): ModelSelection | null { const model = getDefaultModel(provider); return model ? recommendedModelSelection(provider, { slug: model, name: model }) : null; } From 3b48500cbe3c8d7aba243f68604e780590c8b9d7 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Thu, 23 Jul 2026 01:26:34 +0300 Subject: [PATCH 3/5] Update browser expectation for Codex default --- apps/web/src/components/ChatView.browser.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 09d046f3b..bf7657ae9 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -3265,7 +3265,7 @@ describe("ChatView timeline estimator parity (full app)", () => { expect( useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]?.modelSelectionByProvider .codex, - ).toMatchObject({ provider: "codex", model: "gpt-5.5" }); + ).toMatchObject({ provider: "codex", model: "gpt-5.6-sol" }); }); expect(document.querySelector('[data-slot="menu-popup"]')).toBeNull(); From 0daa084f35c1410c64d26c73240b667b25075690 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Thu, 23 Jul 2026 01:41:40 +0300 Subject: [PATCH 4/5] Preserve explicit drafts before model discovery --- .../components/chat/TraitsPicker.browser.tsx | 2 +- apps/web/src/composerDraftStore.test.ts | 32 +++++++++++++++++++ apps/web/src/composerDraftStore.ts | 5 +++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index eccbcdbd3..3d276a434 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -408,7 +408,7 @@ describe("TraitsPicker (Codex)", () => { it("shows Fast in the trigger label when fast mode is active", async () => { await using _ = await mountCodexPicker({ - options: { fastMode: true }, + options: { reasoningEffort: "medium", fastMode: true }, }); await vi.waitFor(() => { diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index cb3beaf9e..3624d164c 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -2548,6 +2548,38 @@ describe("composerDraftStore modelSelection", () => { expect(state).toEqual({ selectedModel: "gpt-5.5", modelOptions: null }); }); + it("preserves an explicit draft model before provider discovery is available", () => { + const state = deriveEffectiveComposerModelState({ + draft: { + modelSelectionByProvider: { + claudeAgent: modelSelection("claudeAgent", "claude-haiku-4-5", { + thinking: true, + }), + }, + activeProvider: "claudeAgent", + }, + selectedProvider: "claudeAgent", + threadModelSelection: null, + projectModelSelection: null, + customModelsByProvider: { + codex: [], + claudeAgent: [], + cursor: [], + antigravity: [], + grok: [], + droid: [], + kilo: [], + opencode: [], + pi: [], + }, + }); + + expect(state).toEqual({ + selectedModel: "claude-haiku-4-5", + modelOptions: { claudeAgent: { thinking: true } }, + }); + }); + it("selects Claude Opus from resolved metadata instead of a stale first row", () => { const state = deriveEffectiveComposerModelState({ draft: { modelSelectionByProvider: {}, activeProvider: "claudeAgent" }, diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2c8297d29..77de19599 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1869,6 +1869,10 @@ export function deriveEffectiveComposerModelState(input: { ) : null; const unlistedDraftModel = input.selectedProvider === "pi" ? selectedDraftModel : null; + const offlineDraftModel = + (input.availableModelOptionsByProvider?.[input.selectedProvider]?.length ?? 0) === 0 + ? selectedDraftModel + : null; const policyModelSelection = resolveRecommendedModelSelection( input.selectedProvider, input.availableModelOptionsByProvider?.[input.selectedProvider], @@ -1889,6 +1893,7 @@ export function deriveEffectiveComposerModelState(input: { persistedThreadModel ?? persistedProjectModel ?? unlistedDraftModel ?? + offlineDraftModel ?? policyModelSelection?.model ?? selectedDraftModel ?? baseModel ?? From a3118c0f3afc546e9bbeaf20f44a08e7ebd653a6 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Thu, 23 Jul 2026 02:42:47 +0300 Subject: [PATCH 5/5] Respect live provider model availability --- apps/web/src/components/ChatView.tsx | 17 +++++----- apps/web/src/composerDraftStore.test.ts | 41 +++++++++++++++++++++++++ apps/web/src/composerDraftStore.ts | 17 ++++++++-- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 498a97ed1..7686f000f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2382,14 +2382,6 @@ export default function ChatView({ openCodeDynamicModelsQuery.data, piDynamicModelsQuery.data, ]); - const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ - threadId, - selectedProvider, - threadModelSelection: activeThread?.modelSelection, - projectModelSelection: activeProject?.defaultModelSelection, - customModelsByProvider, - availableModelOptionsByProvider: modelOptionsByProvider, - }); const runtimeModelsByProvider = useMemo( () => ({ claudeAgent: claudeDynamicModelsQuery.data?.models ?? [], @@ -2414,6 +2406,15 @@ export default function ChatView({ piDynamicModelsQuery.data?.models, ], ); + const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ + threadId, + selectedProvider, + threadModelSelection: activeThread?.modelSelection, + projectModelSelection: activeProject?.defaultModelSelection, + customModelsByProvider, + availableModelOptionsByProvider: modelOptionsByProvider, + runtimeModelOptionsByProvider: runtimeModelsByProvider, + }); const providerModelsQueryByProvider = { claudeAgent: claudeDynamicModelsQuery, codex: codexDynamicModelsQuery, diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 3624d164c..e275ae866 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -2520,6 +2520,47 @@ describe("composerDraftStore modelSelection", () => { }); }); + it("does not select an unavailable static recommendation over the live account catalog", () => { + const runtimeModels = [ + { + slug: "gpt-5.6-terra", + name: "GPT-5.6 Terra", + isDefault: true as const, + supportedReasoningEfforts: [{ value: "low" }, { value: "high" }], + }, + ]; + const state = deriveEffectiveComposerModelState({ + draft: { modelSelectionByProvider: {}, activeProvider: "codex" }, + selectedProvider: "codex", + threadModelSelection: null, + projectModelSelection: null, + customModelsByProvider: { + codex: [], + claudeAgent: [], + cursor: [], + antigravity: [], + grok: [], + droid: [], + kilo: [], + opencode: [], + pi: [], + }, + availableModelOptionsByProvider: { + codex: [ + ...runtimeModels, + { slug: "gpt-5.6-sol", name: "GPT-5.6 Sol" }, + { slug: "gpt-5.5", name: "GPT-5.5" }, + ], + }, + runtimeModelOptionsByProvider: { codex: runtimeModels }, + }); + + expect(state).toEqual({ + selectedModel: "gpt-5.6-terra", + modelOptions: { codex: { reasoningEffort: "high" } }, + }); + }); + it("preserves an explicit thread model instead of silently upgrading it", () => { const state = deriveEffectiveComposerModelState({ draft: { modelSelectionByProvider: {}, activeProvider: "codex" }, diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 77de19599..832e51134 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1832,6 +1832,9 @@ export function deriveEffectiveComposerModelState(input: { availableModelOptionsByProvider?: Partial< Record> >; + runtimeModelOptionsByProvider?: Partial< + Record> + >; }): EffectiveComposerModelState { const resolveAvailableModel = (candidate: string | null | undefined): ModelSlug | null => { const availableOptions = input.availableModelOptionsByProvider?.[input.selectedProvider]; @@ -1873,9 +1876,12 @@ export function deriveEffectiveComposerModelState(input: { (input.availableModelOptionsByProvider?.[input.selectedProvider]?.length ?? 0) === 0 ? selectedDraftModel : null; + const runtimeModelOptions = input.runtimeModelOptionsByProvider?.[input.selectedProvider]; const policyModelSelection = resolveRecommendedModelSelection( input.selectedProvider, - input.availableModelOptionsByProvider?.[input.selectedProvider], + runtimeModelOptions && runtimeModelOptions.length > 0 + ? runtimeModelOptions + : input.availableModelOptionsByProvider?.[input.selectedProvider], ); const selectedModel = resolveAvailableModel(activeSelection?.model) ?? @@ -5167,7 +5173,10 @@ export function useEffectiveComposerModelState(input: { projectModelSelection: ModelSelection | null | undefined; customModelsByProvider: Record; availableModelOptionsByProvider?: Partial< - Record> + Record> + >; + runtimeModelOptionsByProvider?: Partial< + Record> >; }): EffectiveComposerModelState { const draft = useComposerThreadDraft(input.threadId); @@ -5183,9 +5192,13 @@ export function useEffectiveComposerModelState(input: { ...(input.availableModelOptionsByProvider !== undefined ? { availableModelOptionsByProvider: input.availableModelOptionsByProvider } : {}), + ...(input.runtimeModelOptionsByProvider !== undefined + ? { runtimeModelOptionsByProvider: input.runtimeModelOptionsByProvider } + : {}), }), [ input.availableModelOptionsByProvider, + input.runtimeModelOptionsByProvider, draft, input.customModelsByProvider, input.projectModelSelection,