diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index ad7a33334fc7..f61f43077747 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -175,6 +175,7 @@ export const make = Effect.gen(function* () { sourceControlSshPasswordPrompts: true, providerHandoff: true, threadMessageCorrection: true, + modelRouting: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), ...(desktopMcpPath === undefined ? {} : { computerView: true }), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 91b28d7beead..7bf815b0c654 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -533,7 +533,7 @@ const make = Effect.gen(function* () { provider: "mt", method: "thread.turn.start", detail: - "MT Model has no ready providers to route to. Enable Codex, Claude, Cursor, or another provider in Settings.", + "MT Auto has no ready providers to route to. Enable Codex, Claude, Cursor, or another provider in Settings.", }); } const resolveActiveSession = (threadId: ThreadId) => @@ -683,7 +683,7 @@ const make = Effect.gen(function* () { tone: "info", kind: "thread.model-changed", summary: isMtModelSelection(stickyModelSelection) - ? (mtDecision?.reason ?? `MT Model → ${desiredModelSelection.model}`) + ? (mtDecision?.reason ?? `MT Auto → ${desiredModelSelection.model}`) : `Switched model to ${desiredModelSelection.model}`, payload: { fromInstanceId: String(currentInstanceId), diff --git a/apps/server/src/provider/mtModelCloudflare.ts b/apps/server/src/provider/mtModelCloudflare.ts index 2d773b48607b..66a7fc5b4ba8 100644 --- a/apps/server/src/provider/mtModelCloudflare.ts +++ b/apps/server/src/provider/mtModelCloudflare.ts @@ -1,7 +1,7 @@ // @effect-diagnostics globalTimersInEffect:off - the classifier races a raw fetch against a deadline. // @effect-diagnostics preferSchemaOverJson:off - the worker response is untyped JSON, validated by hand below. /** - * Optional Cloudflare classifier for MT Model. + * Optional Cloudflare classifier for MT Auto. * * MT Code matches T3 Code: local and $0 by default. This client stays dark * unless `MT_MODEL_ROUTER_URL` is set to an explicit worker URL. We never diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 6d47bf37978b..d2325b69d4f5 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -405,7 +405,7 @@ export function deriveLockedProvider(input: { if (!threadHasStarted(input.thread)) { return null; } - // MT Model is a harness router. The live session sits on whatever backend + // MT Auto is a harness router. The live session sits on whatever backend // it picked; locking to that backend would steal the picker away from MT. if (isMtModelSelection(input.thread?.modelSelection)) { return null; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 25954b135d27..0aa32c35e611 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2273,7 +2273,14 @@ function ChatViewContent(props: ChatViewProps) { versionMismatchServerLabel, ]); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; - const pickerProviders = useMemo(() => withMtModelProvider(providerStatuses), [providerStatuses]); + // Only the machine that will run the turn can route it; an older server + // rejects the `mt` instance instead of picking a backend. + const environmentCanRouteModels = + activeEnvironment?.serverConfig?.environment.capabilities.modelRouting !== false; + const pickerProviders = useMemo( + () => withMtModelProvider(providerStatuses, { serverCanRoute: environmentCanRouteModels }), + [environmentCanRouteModels, providerStatuses], + ); const unlockedSelectedProvider = resolveSelectableProvider( providerStatuses, selectedProviderByThreadId ?? threadProvider, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f323d38061a0..e3c02d356395 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -264,6 +264,8 @@ import type { ReviewCommentContext } from "../../reviewCommentContext"; import { useThreadShells } from "../../state/entities"; import { searchThreadReferences } from "../../threadReferenceSearch"; +import { useEnvironmentPresentation } from "~/state/presentation"; + const runtimeModeConfig: Record< RuntimeMode, { label: string; description: string; icon: LucideIcon } @@ -803,16 +805,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Instance-aware projection of the wire provider list. One entry per // configured instance (default built-in + any custom `providerInstances.*`), // sorted default-first per driver kind for a stable picker order. + // The turn runs on the thread's machine, and only a machine that understands + // the `mt` instance can route it. An older server answers with "unknown + // provider instance 'mt'", so the entry is withheld for those environments. + const { presentation: composerEnvironmentPresentation } = useEnvironmentPresentation( + _activeThreadEnvironmentId ?? environmentId, + ); + const serverCanRoute = + composerEnvironmentPresentation?.serverConfig?.environment.capabilities.modelRouting !== false; const providerInstanceEntries = useMemo>( () => prependMtModelPickerEntry( sortProviderInstanceEntries( applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), ), + { serverCanRoute }, ), - [providerStatuses, settings], + [providerStatuses, serverCanRoute, settings], + ); + const pickerProviders = useMemo( + () => withMtModelProvider(providerStatuses, { serverCanRoute }), + [providerStatuses, serverCanRoute], ); - const pickerProviders = useMemo(() => withMtModelProvider(providerStatuses), [providerStatuses]); const selectedProviderByThreadId = composerDraft.activeProvider ?? null; const threadProvider = activeThread?.session?.providerInstanceId ?? @@ -857,7 +871,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // 5. First enabled entry overall / default instance for the kind. // const selectedInstanceId = useMemo(() => { - // Session instance is the *routed* backend after MT Model picks one. + // Session instance is the *routed* backend after MT Auto picks one. // Keep the sticky MT selection in the picker unless the draft explicitly // moved to another provider. if ( diff --git a/apps/web/src/mtModel.test.ts b/apps/web/src/mtModel.test.ts index d31d60893925..716544cee983 100644 --- a/apps/web/src/mtModel.test.ts +++ b/apps/web/src/mtModel.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { prependMtModelPickerEntry, withMtModelProvider } from "./mtModel.ts"; describe("withMtModelProvider", () => { - it("prepends MT Model when another provider is ready", () => { + it("prepends MT Auto when another provider is ready", () => { const providers = withMtModelProvider([ { instanceId: ProviderInstanceId.make("claudeAgent"), @@ -32,7 +32,7 @@ describe("withMtModelProvider", () => { expect(providers[0]?.models[0]?.slug).toBe("mt-auto"); }); - it("hides MT Model when no backend is ready", () => { + it("hides MT Auto when no backend is ready", () => { expect( withMtModelProvider([ { @@ -52,7 +52,7 @@ describe("withMtModelProvider", () => { ).toBe(false); }); - it("still offers MT Model when the only backend is in warning", () => { + it("still offers MT Auto when the only backend is in warning", () => { expect( withMtModelProvider([ { @@ -79,4 +79,43 @@ describe("withMtModelProvider", () => { ])[0]?.instanceId, ).toBe("mt"); }); + + it("withholds the router from a machine whose server cannot route", () => { + // An older server answers a routed turn with "unknown provider instance + // 'mt'", so offering it there is a guaranteed failure. + const backends = [ + { + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "unknown" }, + checkedAt: "2026-08-18T00:00:00.000Z", + models: [ + { + slug: "claude-sonnet-5", + name: "Sonnet 5", + isCustom: false, + isDefault: true, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + }, + ] satisfies ReadonlyArray; + expect( + withMtModelProvider(backends, { serverCanRoute: false }).map( + (provider) => provider.instanceId, + ), + ).not.toContain("mt"); + expect( + withMtModelProvider(backends, { serverCanRoute: true }).map( + (provider) => provider.instanceId, + ), + ).toContain("mt"); + }); + }); diff --git a/apps/web/src/mtModel.ts b/apps/web/src/mtModel.ts index 06580fc7cd69..16108aeca9dc 100644 --- a/apps/web/src/mtModel.ts +++ b/apps/web/src/mtModel.ts @@ -22,7 +22,7 @@ export function createMtModelCapabilities() { id: MT_MODEL_ROUTE_MODE_OPTION_ID, label: "Routing", description: - "How aggressively MT Model spends quota on frontier models. Cost keeps chores cheap; Intelligence upgrades harder tasks. Routing is local and free.", + "How aggressively MT Auto spends quota on frontier models. Cost keeps chores cheap; Intelligence upgrades harder tasks. Routing is local and free.", type: "select", currentValue: DEFAULT_MT_MODEL_ROUTE_MODE, options: [ @@ -80,12 +80,22 @@ export function createMtModelProviderSnapshot(): ServerProvider { }; } +/** + * MT Auto is a harness-level router: the client offers it, but the SERVER has + * to understand the `mt` instance when the turn starts. A machine running an + * older build rejects it outright ("references unknown provider instance + * 'mt'"), so the entry is withheld unless that environment says it can route. + */ export function withMtModelProvider( providers: ReadonlyArray, + options?: { readonly serverCanRoute?: boolean | undefined }, ): ReadonlyArray { if (providers.some((provider) => isMtModelInstanceId(provider.instanceId))) { return providers; } + if (options?.serverCanRoute === false) { + return providers; + } const hasReadyBackend = providers.some( (provider) => !isMtModelInstanceId(provider.instanceId) && @@ -101,10 +111,14 @@ export function withMtModelProvider( export function prependMtModelPickerEntry( entries: ReadonlyArray, + options?: { readonly serverCanRoute?: boolean | undefined }, ): ReadonlyArray { if (entries.some((entry) => isMtModelInstanceId(entry.instanceId))) { return entries; } + if (options?.serverCanRoute === false) { + return entries; + } const hasReadyBackend = entries.some( (entry) => !isMtModelInstanceId(entry.instanceId) && diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 7c74e09b4d22..7e07688c5ecf 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -78,6 +78,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server can start a fresh provider session and replay bounded thread context when a started thread switches to an incompatible provider. */ providerHandoff: Schema.optionalKey(Schema.Boolean), + /** Server understands the `mt` router instance and picks a real backend per + turn. Absent on servers from before MT Auto, which reject the instance + outright - so clients must not offer it for those environments. */ + modelRouting: Schema.optionalKey(Schema.Boolean), /** Server understands thread.message.correct. Absent on older servers, so clients hide the action instead of sending it. */ threadMessageCorrection: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 4a77aed88eb7..9edd98a8905c 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -136,8 +136,8 @@ const ANTIGRAVITY_DRIVER_KIND = ProviderDriverKind.make("antigravity"); export const MT_MODEL_DRIVER_KIND = ProviderDriverKind.make("mt"); export const MT_MODEL_INSTANCE_ID = ProviderInstanceId.make("mt"); export const MT_MODEL_SLUG = "mt-auto"; -export const MT_MODEL_DISPLAY_NAME = "MT Model"; -export const MT_MODEL_PROVIDER_LABEL = "MT Code"; +export const MT_MODEL_DISPLAY_NAME = "MT Auto"; +export const MT_MODEL_PROVIDER_LABEL = "Munim"; export const MT_MODEL_ROUTE_MODE_OPTION_ID = "routeMode"; export const MT_MODEL_ROUTE_MODES = ["cost", "balance", "intelligence"] as const; export type MtModelRouteMode = (typeof MT_MODEL_ROUTE_MODES)[number]; diff --git a/packages/shared/src/mtModelRouter.ts b/packages/shared/src/mtModelRouter.ts index fe74e9652c82..7bf0d48116d4 100644 --- a/packages/shared/src/mtModelRouter.ts +++ b/packages/shared/src/mtModelRouter.ts @@ -1,5 +1,5 @@ /** - * MT Model router — local analog of Cursor Auto / Cursor Router. + * MT Auto router — local analog of Cursor Auto / Cursor Router. * * Cursor trains Compass on hundreds of thousands of live turns. We score each * turn locally (same $0 model as T3 Code: no cloud classifier). The resolved @@ -408,7 +408,7 @@ function describeRoute( if (classification.source === "cloudflare") { parts.push("cloudflare"); } - return `MT Model → ${candidate.model} (${parts.join(", ")})`; + return `MT Auto → ${candidate.model} (${parts.join(", ")})`; } function unwrapClassificationRecord(input: unknown): Record | null {