From f44f12cdf4f3e2aee13cb4c2a2c1a877c7f0503d Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 29 Oct 2026 10:36:41 -0400 Subject: [PATCH 1/6] feat(fx): pick fx's backend in the model picker fx can run on Vercel AI Gateway, a Codex or Grok subscription, or a configured OpenAI-compatible endpoint, but T3 Code only offered the backend `fx provider` had set, so switching meant a terminal. The fx snapshot now probes every backend with `FX_PROVIDER` (`fx status` for sign-in, `fx models` for its catalog) and lists models as `:`. A new `subProviders` field on the provider snapshot lets the web picker and the mobile thread settings group them under collapsible backend headings; signed-out backends stay listed as unavailable with the `fx login` command to run. The adapter writes fx's per-session `provider` option before `model`, so each thread keeps its own backend without touching fx's global default. "Default" now resolves with `fx status` in the thread's folder, so a model fx's settings give one project is picked up there. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/ThreadSettingsSheet.tsx | 125 ++++++++- apps/mobile/src/lib/modelOptions.test.ts | 44 ++++ apps/mobile/src/lib/modelOptions.ts | 7 + .../Adapters/FxAdapterV2.test.ts | 84 +++++- .../components/chat/ModelPickerContent.tsx | 136 +++++++++- .../src/components/chat/modelPickerKeys.ts | 8 + docs/user/providers-fx.md | 12 +- packages/client-runtime/package.json | 4 + .../src/providerSubProviders.ts | 29 +++ packages/contracts/src/server.ts | 10 + packages/provider-fx/src/server/acpSupport.ts | 70 +++-- packages/provider-fx/src/server/adapter.ts | 28 +- packages/provider-fx/src/server/driver.ts | 11 +- .../provider-fx/src/server/status.test.ts | 239 ++++++++++++----- packages/provider-fx/src/server/status.ts | 241 +++++++++++++++--- 15 files changed, 892 insertions(+), 156 deletions(-) create mode 100644 packages/client-runtime/src/providerSubProviders.ts diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 7aafc20d08507..b305a695daa37 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -69,6 +69,7 @@ import { type ModelOption, type ProviderGroup, } from "../../lib/modelOptions"; +import { groupModelsBySubProvider } from "@t3tools/client-runtime/providerSubProviders"; import { applyProviderOptionSelection } from "../../lib/providerOptions"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { @@ -147,6 +148,10 @@ function ProviderHeader(props: { readonly driver: string | undefined; readonly iconUrl: string | undefined; readonly label: string; + /** A backend inside a provider's section, such as one of fx's: no icon. */ + readonly nested?: boolean; + /** Why the section has no usable models, such as a signed-out backend. */ + readonly detail?: string | undefined; readonly collapsible: boolean; readonly collapsed: boolean; readonly modelCount: number; @@ -154,8 +159,17 @@ function ProviderHeader(props: { }) { const content = ( <> - - {props.label} + {props.nested ? null : ( + + )} + {props.detail ? ( + + {props.label} + {props.detail} + + ) : ( + {props.label} + )} {props.collapsible ? ( <> @@ -190,7 +204,10 @@ function ProviderHeader(props: { } return ( - + {content} ); @@ -659,6 +676,15 @@ type ThreadSettingsCatalogItem = readonly key: string; readonly provider: ThreadSettingsProviderCatalog; } + | { + readonly kind: "subProvider"; + readonly key: string; + readonly expansionKey: string; + readonly label: string; + readonly detail: string | undefined; + readonly collapsed: boolean; + readonly modelCount: number; + } | { readonly kind: "model"; readonly key: string; @@ -727,6 +753,88 @@ function ThreadSettingsProviderListHeader(props: { ); } +function ThreadSettingsSubProviderListHeader(props: { + readonly item: Extract; +}) { + const session = useThreadSettingsSession(); + const onToggle = () => session.toggleProvider(props.item.expansionKey); + + return ( + + ); +} + +const modelItems = (models: ReadonlyArray) => + models.map((option, index) => ({ + kind: "model" as const, + key: `model:${option.key}`, + option, + isFirst: index === 0, + isLast: index === models.length - 1, + })); + +/** + * A provider whose models run on several backends, such as fx, lists each + * backend under its own collapsible heading; the thread's backend opens. + * Search and Favorites stay flat. + */ +function subProviderCatalogItems( + session: ThreadSettingsSessionValue, + group: ProviderGroup, + models: ReadonlyArray, +): ReadonlyArray | null { + if ( + !group.subProviders?.length || + session.searchQuery.trim().length > 0 || + session.providerFilter === FAVORITES_PROVIDER_FILTER + ) { + return null; + } + const { ungrouped, groups } = groupModelsBySubProvider( + models, + group.subProviders, + (option) => option.subtitle, + ); + return [ + ...modelItems(ungrouped), + ...groups.flatMap(({ subProvider, models: backendModels }) => { + const expansionKey = `${group.providerKey}:sub-provider:${subProvider.name}`; + const collapsed = + subProvider.unavailableReason !== undefined || + providerSectionIsCollapsed({ + defaultExpanded: backendModels.some(session.isApplied), + hasExpansionOverride: session.providerExpansionOverrides.has(expansionKey), + isNarrowed: false, + }); + return [ + { + kind: "subProvider" as const, + key: `sub-provider:${expansionKey}`, + expansionKey, + label: subProvider.name, + detail: subProvider.unavailableReason, + collapsed, + modelCount: backendModels.length, + }, + // The heading names the backend, so rows drop it. + ...(collapsed + ? [] + : modelItems(backendModels.map((option) => ({ ...option, subtitle: "" })))), + ]; + }), + ]; +} + function buildThreadSettingsCatalogItems( session: ThreadSettingsSessionValue, ): ReadonlyArray { @@ -793,13 +901,8 @@ function buildThreadSettingsCatalogItems( key: `provider:${group.providerKey}`, provider, }, - ...provider.models.map((option, index) => ({ - kind: "model" as const, - key: `model:${option.key}`, - option, - isFirst: index === 0, - isLast: index === provider.models.length - 1, - })), + ...((collapsed ? null : subProviderCatalogItems(session, group, provider.models)) ?? + modelItems(provider.models)), ...(!collapsed && updateRequiredNotice ? [ { @@ -941,6 +1044,8 @@ function ThreadSettingsMainContent(props: { if (item.kind === "provider") { content = ; + } else if (item.kind === "subProvider") { + content = ; } else if (item.kind === "model") { content = ( { it("distinguishes same-name OpenCode models without changing their routing", () => { @@ -71,6 +72,49 @@ describe("mobile model options", () => { ]); }); + it("groups fx's models under its backends, keeping a signed-out backend", () => { + const subProviders = [ + { name: "Codex subscription" }, + { name: "Grok subscription", unavailableReason: "Signed out." }, + ]; + const config = { + providers: [ + { + instanceId: "fx", + driver: "fx", + displayName: "fx", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + subProviders, + models: [ + { slug: "default", name: "Default (gpt-5.5)", isCustom: false, capabilities: null }, + { + slug: "codex:gpt-5.5", + name: "gpt-5.5", + subProvider: "Codex subscription", + isCustom: false, + capabilities: null, + }, + ], + }, + ], + } as unknown as ServerConfig; + + const [group] = groupByProvider(buildModelOptions(config, null, undefined, null)); + expect(group?.subProviders).toEqual(subProviders); + const { ungrouped, groups } = groupModelsBySubProvider( + group?.models ?? [], + group?.subProviders ?? [], + (option) => option.subtitle, + ); + expect(ungrouped.map((option) => option.key)).toEqual(["fx:default"]); + expect(groups.map(({ subProvider, models }) => [subProvider.name, models.length])).toEqual([ + ["Codex subscription", 1], + ["Grok subscription", 0], + ]); + }); + it("does not materialize catalog defaults for missing stored options", () => { const config = { providers: [ diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 7771c738042d3..120797d8a5735 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -46,6 +46,7 @@ export type ModelOption = { readonly capabilities: ModelCapabilities | null; readonly selection: ModelSelection; readonly providerUpdateRequired?: ProviderUpdateRequired; + readonly providerSubProviders?: T3ServerConfig["providers"][number]["subProviders"]; }; type ProviderUpdateRequired = Pick< @@ -58,6 +59,7 @@ export type ProviderGroup = { readonly providerLabel: string; readonly models: ReadonlyArray; readonly updateRequired?: ProviderUpdateRequired; + readonly subProviders?: ModelOption["providerSubProviders"]; }; function providerDisplayLabel(provider: { @@ -382,6 +384,7 @@ export function buildModelOptions( isDefault: model.isDefault === true, isLegacy: model.isLegacy === true, ...(updateRequired ? { providerUpdateRequired: updateRequired } : {}), + ...(provider.subProviders?.length ? { providerSubProviders: provider.subProviders } : {}), capabilities: model.capabilities, selection: normalizeSelectionOptions( { @@ -465,6 +468,7 @@ export function groupByProvider(options: ReadonlyArray): ReadonlyAr providerLabel: string; models: ModelOption[]; updateRequired: ProviderUpdateRequired | undefined; + subProviders: ModelOption["providerSubProviders"]; } >(); for (const option of options) { @@ -472,11 +476,13 @@ export function groupByProvider(options: ReadonlyArray): ReadonlyAr if (existing) { existing.models.push(option); existing.updateRequired ??= option.providerUpdateRequired; + existing.subProviders ??= option.providerSubProviders; } else { groups.set(option.providerKey, { providerLabel: option.providerLabel, models: [option], updateRequired: option.providerUpdateRequired, + subProviders: option.providerSubProviders, }); } } @@ -486,5 +492,6 @@ export function groupByProvider(options: ReadonlyArray): ReadonlyAr providerLabel: group.providerLabel, models: group.models, ...(group.updateRequired ? { updateRequired: group.updateRequired } : {}), + ...(group.subProviders ? { subProviders: group.subProviders } : {}), })); } diff --git a/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts index a4732c6efe216..c3f3e7e1b0912 100644 --- a/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts @@ -47,7 +47,7 @@ const testLayer = Layer.mergeAll( const SESSION_ID = "fx-session-01"; const ENABLED_FX_SETTINGS = Schema.decodeSync(FxSettings)({ enabled: true }); -/** The model `fx status --json` reports. */ +/** The model `fx status --json` reports in the thread's folder, on the gateway. */ const FX_DEFAULT_MODEL = "anthropic/claude-opus-5.5"; const NAMED_MODEL = "anthropic/claude-sonnet-5.5"; const CUSTOM_MODEL = "openai/gpt-6.1-sol"; @@ -91,10 +91,11 @@ const fxConfig = (input: { readonly model: string; readonly modes: ReadonlyArray; readonly mode?: string; + readonly backend?: string; /** The session's own model list, on a backend other than the gateway. */ readonly models?: ReadonlyArray; }) => [ - select("provider", "model", "gateway", ["gateway", "codex", "grok"]), + select("provider", "model", input.backend ?? "gateway", ["gateway", "codex", "grok"]), select( "model", "model", @@ -112,6 +113,7 @@ const fxConfig = (input: { const fxSession = (input: { readonly model: string; readonly modes: ReadonlyArray; + readonly backend?: string; readonly models?: ReadonlyArray; }) => ({ sessionId: SESSION_ID, @@ -179,7 +181,11 @@ const openFxSession = Effect.fn("openFxSession")(function* (input: { settings: ENABLED_FX_SETTINGS, environment: {}, selfInvocation: yield* resolveSelfInvocation(), - defaultModel: Effect.succeed(FX_DEFAULT_MODEL), + defaultChoice: (cwd) => + // "Default" resolves in the thread's own folder. + Effect.succeed( + cwd === replayDir ? { backend: "gateway", model: FX_DEFAULT_MODEL } : undefined, + ), makeRuntime: makeAcpReplayRuntime({ transcript, statusPath, @@ -312,10 +318,9 @@ describe("FxAdapterV2", () => { }).pipe(Effect.provide(testLayer), Effect.scoped), ); - // `fx status` names the gateway default, but this loaded session runs on - // Codex, whose catalog lacks it; fx would refuse the write and the thread - // could not start. - it.effect("keeps the session's model when its backend lacks fx's default", () => + // `fx status` names a gateway model, but this loaded session runs on Codex, + // whose catalog lacks it: the backend goes back first. + it.effect("moves a loaded session on another backend back to fx's default backend", () => openFxSession({ scenario: "default-other-backend", runtimeMode: "approval-required", @@ -326,13 +331,74 @@ describe("FxAdapterV2", () => { outbound("session/load"), answer( "session/load", - fxSession({ model: CODEX_MODEL, models: [CODEX_MODEL], modes: FX_0_0_13_MODES }), + fxSession({ + model: CODEX_MODEL, + backend: "codex", + models: [CODEX_MODEL], + modes: FX_0_0_13_MODES, + }), + ), + // fx then runs the gateway model it saved for that backend. + ...setConfig( + "provider", + "gateway", + fxConfig({ model: NAMED_MODEL, modes: FX_0_0_13_MODES }), + ), + ...setConfig( + "model", + FX_DEFAULT_MODEL, + fxConfig({ model: FX_DEFAULT_MODEL, modes: FX_0_0_13_MODES }), + ), + ...setConfig("mode", "ask", fxConfig({ model: FX_DEFAULT_MODEL, modes: FX_0_0_13_MODES })), + ], + }).pipe(Effect.provide(testLayer), Effect.scoped), + ); + + it.effect("switches the session's backend, then its model, for a backend's model", () => + openFxSession({ + scenario: "backend-model", + runtimeMode: "approval-required", + model: `codex:${CODEX_MODEL}`, + turnModel: "gateway:openai/gpt-5.6-sol", + frames: [ + ...initialize, + outbound("session/new"), + answer("session/new", fxSession({ model: FX_DEFAULT_MODEL, modes: FX_0_0_13_MODES })), + // fx already picks the thread's model on Codex, so only the backend is written. + ...setConfig( + "provider", + "codex", + fxConfig({ + model: CODEX_MODEL, + backend: "codex", + models: [CODEX_MODEL, "gpt-5.4-mini"], + modes: FX_0_0_13_MODES, + }), ), ...setConfig( "mode", "ask", - fxConfig({ model: CODEX_MODEL, models: [CODEX_MODEL], modes: FX_0_0_13_MODES }), + fxConfig({ + model: CODEX_MODEL, + backend: "codex", + models: [CODEX_MODEL, "gpt-5.4-mini"], + modes: FX_0_0_13_MODES, + }), ), + // The next turn moves the same session to a gateway model; the id + // after the backend goes to fx whole, slash included. + ...setConfig( + "provider", + "gateway", + fxConfig({ model: NAMED_MODEL, modes: FX_0_0_13_MODES }), + ), + ...setConfig( + "model", + "openai/gpt-5.6-sol", + fxConfig({ model: "openai/gpt-5.6-sol", modes: FX_0_0_13_MODES }), + ), + outbound("session/prompt"), + answer("session/prompt", { stopReason: "end_turn" }), ], }).pipe(Effect.provide(testLayer), Effect.scoped), ); diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index f040df764cda9..e81770bc7f747 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -9,6 +9,7 @@ import { import { resolveSelectableModel } from "@t3tools/shared/model"; import { useAtomValue } from "@effect/atom-react"; import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels"; +import { groupModelsBySubProvider } from "@t3tools/client-runtime/providerSubProviders"; import { useNavigate } from "@tanstack/react-router"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { @@ -21,12 +22,14 @@ import { type ReactNode, type RefObject, } from "react"; +import { ChevronRightIcon } from "lucide-react"; import { ModelListRow } from "./ModelListRow"; import { ModelPickerSidebar } from "./ModelPickerSidebar"; import { getProviderStatusMessage, hasProviderSetup } from "./ProviderStatusBanner"; import { modelPickerLegacySectionKey, modelPickerModelKey, + modelPickerSubProviderSectionKey, parseModelPickerLegacySectionKey, parseModelPickerModelKey, } from "./modelPickerKeys"; @@ -57,6 +60,7 @@ import { cn } from "~/lib/utils"; import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; import { TooltipProvider } from "../ui/tooltip"; import { InlineButton } from "../ui/button"; +import { Badge } from "../ui/badge"; import { isProviderInstancePickerReady, isProviderInstancePickerVisible, @@ -452,6 +456,15 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { : [], ), ); + // The thread's backend opens expanded, so its models show without a click. + const [expandedSubProviderKeys, setExpandedSubProviderKeys] = useState( + () => + new Set( + activeModel?.subProvider + ? [modelPickerSubProviderSectionKey(props.activeInstanceId, activeModel.subProvider)] + : [], + ), + ); const serverKeybindings = useAtomValue(primaryServerKeybindingsAtom); const keybindings = providedKeybindings ?? serverKeybindings; const [searchHeight, setSearchHeight] = useState(0); @@ -480,6 +493,14 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { }); }; + const toggleSubProviderSection = (key: string) => { + setExpandedSubProviderKeys((expanded) => { + const next = new Set(expanded); + if (!next.delete(key)) next.add(key); + return next; + }); + }; + const handleModelSelect = ( modelSlug: string, instanceId: ProviderInstanceId, @@ -583,6 +604,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return out; })(); + const subProvidersByInstance = new Map( + instanceEntries.flatMap((entry) => + entry.snapshot.subProviders?.length + ? [[entry.instanceId, entry.snapshot.subProviders] as const] + : [], + ), + ); const isLocked = lockedProvider !== null; const isSearching = searchQuery.trim().length > 0; // Fit the tallest tab so switching tabs never resizes the popup and moves the rail under the cursor. @@ -607,6 +635,9 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { } } for (const instanceId of tabsWithLegacyRow) addRow(instanceId); + for (const [instanceId, subProviders] of subProvidersByInstance) { + for (const _subProvider of subProviders) addRow(instanceId); + } return Math.max(0, ...unitsByTab.values()); })(); const lockedDisabledInstanceIds = (() => { @@ -698,7 +729,46 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { }; })(); + // A provider whose models run on several backends, such as fx, lists each + // backend as a collapsible heading. Search and Favorites stay flat. + const subProviderSections = (() => { + const subProviders = + isSearching || selectedInstanceId === "favorites" + ? undefined + : subProvidersByInstance.get(selectedInstanceId); + if (!subProviders || selectedInstanceId === "favorites") return null; + const { ungrouped, groups } = groupModelsBySubProvider( + filteredModels, + subProviders, + (model) => model.subProvider, + ); + return { + ungrouped, + sections: groups.map(({ subProvider, models }) => { + const key = modelPickerSubProviderSectionKey(selectedInstanceId, subProvider.name); + return { + key, + name: subProvider.name, + unavailableReason: subProvider.unavailableReason, + models, + isExpanded: expandedSubProviderKeys.has(key), + }; + }), + }; + })(); + const subProviderSectionByKey = new Map( + subProviderSections?.sections.map((section) => [section.key, section] as const), + ); + const visibleModels = (() => { + if (subProviderSections) { + return [ + ...subProviderSections.ungrouped, + ...subProviderSections.sections.flatMap((section) => + section.isExpanded ? section.models : [], + ), + ]; + } if (!legacySection) { return filteredModels; } @@ -744,11 +814,24 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { .filter((model) => model.isLegacy) .map((model) => modelPickerLegacySectionKey(model.instanceId)), ), + ...[...subProvidersByInstance].flatMap(([instanceId, subProviders]) => + subProviders.map((subProvider) => + modelPickerSubProviderSectionKey(instanceId, subProvider.name), + ), + ), ])(); const filteredItemKeys = ((): string[] => { - const modelKeys = visibleModels.map((model) => - modelPickerModelKey(model.instanceId, model.slug), - ); + const keyOf = (model: ModelPickerItem) => modelPickerModelKey(model.instanceId, model.slug); + if (subProviderSections) { + return [ + ...subProviderSections.ungrouped.map(keyOf), + ...subProviderSections.sections.flatMap((section) => [ + section.key, + ...(section.isExpanded ? section.models.map(keyOf) : []), + ]), + ]; + } + const modelKeys = visibleModels.map(keyOf); // The toggle trails the list, so expanding it reveals older models above it. return legacySection ? [...modelKeys, legacySection.key] : modelKeys; })(); @@ -904,6 +987,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { toggleLegacySection(legacyInstanceId); return; } + if (subProviderSectionByKey.has(modelKey)) { + toggleSubProviderSection(modelKey); + return; + } const model = parseModelPickerModelKey(modelKey); if (model) { handleModelSelect( @@ -996,6 +1083,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { toggleLegacySection(legacyInstanceId); return; } + if (subProviderSectionByKey.has(highlightedModelKeyRef.current)) { + toggleSubProviderSection(highlightedModelKeyRef.current); + return; + } const model = parseModelPickerModelKey(highlightedModelKeyRef.current); if (model) { handleModelSelect(model.slug, model.instanceId, e.shiftKey); @@ -1028,6 +1119,45 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ); } + const subProviderSection = subProviderSectionByKey.get(modelKey); + if (subProviderSection) { + const unavailable = subProviderSection.unavailableReason !== undefined; + return ( + +
+
+
+ {subProviderSection.name} +
+ {unavailable ? ( + + Unavailable + + ) : null} +
+
+ {subProviderSection.unavailableReason ?? + `${subProviderSection.models.length} models`} +
+
+ {unavailable ? null : ( + + )} +
+ ); + } const model = filteredModelByKey.get(modelKey); if (!model) { return null; diff --git a/apps/web/src/components/chat/modelPickerKeys.ts b/apps/web/src/components/chat/modelPickerKeys.ts index 0b4f0845e116f..4de9169c96156 100644 --- a/apps/web/src/components/chat/modelPickerKeys.ts +++ b/apps/web/src/components/chat/modelPickerKeys.ts @@ -2,6 +2,7 @@ import type { ProviderInstanceId } from "@t3tools/contracts"; const MODEL_KEY_PREFIX = "model:"; const LEGACY_SECTION_KEY_PREFIX = "legacy-models:"; +const SUB_PROVIDER_SECTION_KEY_PREFIX = "sub-provider:"; export function modelPickerModelKey(instanceId: ProviderInstanceId, slug: string): string { return `${MODEL_KEY_PREFIX}${instanceId.length}:${instanceId}${slug}`; @@ -45,3 +46,10 @@ export function parseModelPickerLegacySectionKey(key: string): ProviderInstanceI ? (key.slice(LEGACY_SECTION_KEY_PREFIX.length) as ProviderInstanceId) : null; } + +export function modelPickerSubProviderSectionKey( + instanceId: ProviderInstanceId, + subProvider: string, +): string { + return `${SUB_PROVIDER_SECTION_KEY_PREFIX}${instanceId.length}:${instanceId}${subProvider}`; +} diff --git a/docs/user/providers-fx.md b/docs/user/providers-fx.md index 430f7a705a499..52303da99a841 100644 --- a/docs/user/providers-fx.md +++ b/docs/user/providers-fx.md @@ -17,13 +17,17 @@ If `fx` is not on the server's `PATH`, set **Binary path** to the executable. Every fx instance on the same machine shares that machine's fx login. T3 Code does not keep a separate login per instance. -## Models and effort +## Backends, models, and effort -The model picker lists **Default** first. That option runs the model `fx status` reports on the server's machine. A model set only for one project in fx's settings is not picked up, so choose it from the list instead. If the thread's fx backend does not offer that model, the thread keeps its current one. The other entries come from `fx models`. A custom model you add in the provider settings goes to fx as is, and fx reports an error if its backend does not offer it. +fx can run models through several backends: Vercel AI Gateway, a Codex subscription, a Grok subscription, and any OpenAI-compatible endpoint you configure in fx's settings. In the model picker, the fx tab lists each backend under its own heading. Open a backend to see the models it offers, then pick one. Search lists every backend's models together. -fx also has an **Effort** control. A new thread starts at Medium. +The backend is part of each thread's model choice, so two threads can run on different backends at the same time. Switching it in T3 Code changes only that thread's fx session. It never changes the default `fx provider` sets for your terminal. + +Sign-ins are different: every fx instance on a machine shares that machine's fx login. A backend you are signed out of shows as unavailable, with the command to run on that machine, such as `fx login grok`. Refresh the provider after signing in. -T3 Code does not show fx's backend selector. To use Codex, Grok, or Vercel AI Gateway, run `fx provider` on the environment's machine. +**Default** runs whatever fx would start a new session on in the thread's project folder, including a model fx's settings give only that folder. Its label shows the machine-wide default. A custom model you add in the provider settings goes to fx as is, on the thread's current backend, and fx reports an error if that backend does not offer it. + +fx also has an **Effort** control. A new thread starts at Medium. Type `$` in the composer to list the skills fx finds in its skill folders, in the order fx's own skill menu shows them. When two different skills share a name, the menu lists the one fx loads for that name: the first copy in fx's folder order, starting with the workspace. The menu shows one skill per name regardless of case; to run a skill whose name differs only in case, such as `Review` next to `review`, type its exact name. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index f52115577f108..fa1b747ad4419 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -131,6 +131,10 @@ "types": "./src/providerUpdateRequiredModels.ts", "default": "./src/providerUpdateRequiredModels.ts" }, + "./providerSubProviders": { + "types": "./src/providerSubProviders.ts", + "default": "./src/providerSubProviders.ts" + }, "./voice-input": { "types": "./src/voice-input/index.ts", "default": "./src/voice-input/index.ts" diff --git a/packages/client-runtime/src/providerSubProviders.ts b/packages/client-runtime/src/providerSubProviders.ts new file mode 100644 index 0000000000000..dbd90c395ea01 --- /dev/null +++ b/packages/client-runtime/src/providerSubProviders.ts @@ -0,0 +1,29 @@ +import type { ServerProviderSubProvider } from "@t3tools/contracts"; + +export interface SubProviderGroup { + readonly subProvider: ServerProviderSubProvider; + readonly models: ReadonlyArray; +} + +/** + * Splits a provider's models into the ones outside any sub-provider, such as + * "Default", and one group per sub-provider in the provider's order. A + * signed-out sub-provider keeps its group, with no models. + */ +export function groupModelsBySubProvider( + models: ReadonlyArray, + subProviders: ReadonlyArray, + subProviderOf: (model: Model) => string | undefined, +): { + readonly ungrouped: ReadonlyArray; + readonly groups: ReadonlyArray>; +} { + const names = new Set(subProviders.map((subProvider) => subProvider.name)); + return { + ungrouped: models.filter((model) => !names.has(subProviderOf(model) ?? "")), + groups: subProviders.map((subProvider) => ({ + subProvider, + models: models.filter((model) => subProviderOf(model) === subProvider.name), + })), + }; +} diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 3d45dfb87310f..a75b09da2d9b0 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -87,6 +87,14 @@ export const ServerProviderModel = Schema.Struct({ }); export type ServerProviderModel = typeof ServerProviderModel.Type; +/** A backend a provider's models run on, such as one of fx's. */ +export const ServerProviderSubProvider = Schema.Struct({ + name: TrimmedNonEmptyString, + /** Why none of its models can run now, such as a signed-out backend. */ + unavailableReason: Schema.optional(TrimmedNonEmptyString), +}); +export type ServerProviderSubProvider = typeof ServerProviderSubProvider.Type; + export const ServerProviderUpdateRequiredModel = Schema.Struct({ slug: TrimmedNonEmptyString, name: TrimmedNonEmptyString, @@ -254,6 +262,8 @@ export const ServerProvider = Schema.Struct({ availability: Schema.optional(ServerProviderAvailability), unavailableReason: Schema.optional(TrimmedNonEmptyString), models: Schema.Array(ServerProviderModel), + // Pickers group `models` under these, in order, by `subProvider`. + subProviders: Schema.optional(Schema.Array(ServerProviderSubProvider)), // Kept apart from `models` so clients that predate it never offer them. updateRequiredModels: Schema.optionalKey(Schema.Array(ServerProviderUpdateRequiredModel)), slashCommands: Schema.Array(ServerProviderSlashCommand).pipe( diff --git a/packages/provider-fx/src/server/acpSupport.ts b/packages/provider-fx/src/server/acpSupport.ts index b77a4431faf1a..019756945cd7a 100644 --- a/packages/provider-fx/src/server/acpSupport.ts +++ b/packages/provider-fx/src/server/acpSupport.ts @@ -25,6 +25,7 @@ import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAda export const FX_DRIVER_KIND = ProviderDriverKind.make("fx"); /** T3's slug for "Default": the model `fx status` reports, never an fx model id. */ export const FX_DEFAULT_MODEL_SLUG = "default"; +const FX_PROVIDER_CONFIG_ID = "provider"; const FX_MODEL_CONFIG_ID = "model"; const FX_MODE_CONFIG_ID = "mode"; const FX_EFFORT_CONFIG_ID = "effort"; @@ -157,36 +158,71 @@ export function withFxSessionMode( }; } +/** A model on one fx backend (`gateway`, `codex`, `grok`, or a configured endpoint's id). */ +export interface FxModelChoice { + readonly backend: string | undefined; + readonly model: string; +} + +/** fx backend ids never contain `:`, so the first one ends the backend. */ +export const fxModelSlug = (backend: string, model: string) => `${backend}:${model}`; + +/** A slug without a known backend is a model id for the session's current backend. */ +function parseFxModelSlug(slug: string, backends: ReadonlyArray): FxModelChoice { + const separator = slug.indexOf(":"); + const backend = separator > 0 ? slug.slice(0, separator) : undefined; + return backend !== undefined && backends.includes(backend) + ? { backend, model: slug.slice(separator + 1) } + : { backend: undefined, model: slug }; +} + +const selectOption = ( + options: ReadonlyArray, + id: string, +): Extract | undefined => { + const option = options.find((entry) => entry.id === id); + return option?.type === "select" ? option : undefined; +}; + /** - * Writes config id `model` (fx's `provider` option shares its category). - * "Default" picks the model `fx status` reports when the session lists it. - * An unlisted model the thread picked is still sent: fx's backends accept or - * refuse it themselves, so a thread never runs on a model it did not pick. + * Writes fx's session-only `provider` (the backend), then `model`. "Default" + * is what `fx status` reports for the session's folder, skipped when that + * backend does not list it; any other unlisted model is still sent, so a + * thread never runs on a model it did not pick. */ export const applyFxModelSelection = Effect.fn("applyFxModelSelection")(function* ( runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], modelSelection: ModelSelection, - defaultModel: Effect.Effect | undefined, + defaultChoice: Effect.Effect | undefined, ): Effect.fn.Return { - const option = (yield* runtime.getConfigOptions).find( - (entry) => entry.id === FX_MODEL_CONFIG_ID && entry.type === "select", - ); - const current = option?.type === "select" ? option.currentValue : undefined; + const backendOption = selectOption(yield* runtime.getConfigOptions, FX_PROVIDER_CONFIG_ID); const requested = modelSelection.model.trim(); const isDefault = requested.length === 0 || requested === FX_DEFAULT_MODEL_SLUG; - const target = isDefault ? defaultModel && (yield* defaultModel) : requested; - if (target === undefined || target === current) return current; - // The status default can belong to another fx backend than the session's - // (after `fx provider`, or a loaded older session); then the session's - // own model stands. + const target = isDefault + ? defaultChoice && (yield* defaultChoice) + : parseFxModelSlug( + requested, + backendOption ? collectSessionConfigOptionValues(backendOption) : [], + ); + if ( + target?.backend !== undefined && + backendOption !== undefined && + target.backend !== backendOption.currentValue + ) { + yield* runtime.setConfigOption(FX_PROVIDER_CONFIG_ID, target.backend); + } + const modelOption = selectOption(yield* runtime.getConfigOptions, FX_MODEL_CONFIG_ID); + const current = modelOption?.currentValue; + if (target === undefined || target.model === current) return current; if ( isDefault && - (option === undefined || !collectSessionConfigOptionValues(option).includes(target)) + (modelOption === undefined || + !collectSessionConfigOptionValues(modelOption).includes(target.model)) ) { return current; } - yield* runtime.setConfigOption(FX_MODEL_CONFIG_ID, target, { allowUnlistedValue: true }); - return target; + yield* runtime.setConfigOption(FX_MODEL_CONFIG_ID, target.model, { allowUnlistedValue: true }); + return target.model; }); /** The shared option loop writes a stored effort; a thread without one starts at medium. */ diff --git a/packages/provider-fx/src/server/adapter.ts b/packages/provider-fx/src/server/adapter.ts index b48293f4fb986..73424ed0feb10 100644 --- a/packages/provider-fx/src/server/adapter.ts +++ b/packages/provider-fx/src/server/adapter.ts @@ -20,6 +20,7 @@ import { applyFxModelSelection, FX_DRIVER_KIND, fxPermissionDisposition, + type FxModelChoice, fxSessionModeForPolicy, makeFxAcpRuntime, withFxSessionMode, @@ -152,8 +153,8 @@ export interface FxAdapterV2Options { readonly settings: FxSettings; readonly environment: NodeJS.ProcessEnv; readonly selfInvocation: SelfInvocation; - /** fx's id for "Default", the model the provider snapshot read from `fx status`. */ - readonly defaultModel?: Effect.Effect; + /** The backend and model "Default" runs in a folder, which `fx status` reports there. */ + readonly defaultChoice?: (cwd: string) => Effect.Effect; readonly nativeLogging?: Parameters[0]["nativeLogging"]; readonly testHooks?: Parameters[0]["testHooks"]; /** Replaces the `fx acp` launch (replay tests). fx's session setup still applies. */ @@ -176,15 +177,25 @@ function makeFxAcpAdapterFlavor(options: FxAdapterV2Options): AcpAdapterV2Flavor fxSettings: options.settings, environment: { ...options.environment, ...processEnvironment }, })); + // A runtime serves one folder; "Default" resolves there. + const runtimeCwds = new WeakMap(); return { driver: FX_DRIVER_KIND, runtimeHarness: "fx", capabilities: FxProviderCapabilitiesV2, - makeRuntime: (input) => makeRuntime(input).pipe(Effect.map(withFxSessionMode)), - applyModelSelection: ({ runtime, modelSelection }) => - applyFxModelSelection(runtime, modelSelection, options.defaultModel).pipe( - Effect.tap(() => applyFxEffortDefault(runtime, modelSelection)), + makeRuntime: (input) => + makeRuntime(input).pipe( + Effect.map(withFxSessionMode), + Effect.tap((runtime) => Effect.sync(() => runtimeCwds.set(runtime, input.cwd))), ), + applyModelSelection: ({ runtime, modelSelection }) => { + const cwd = runtimeCwds.get(runtime); + return applyFxModelSelection( + runtime, + modelSelection, + cwd === undefined ? undefined : options.defaultChoice?.(cwd), + ).pipe(Effect.tap(() => applyFxEffortDefault(runtime, modelSelection))); + }, sessionModeForPolicy: fxSessionModeForPolicy, permissionDisposition: fxPermissionDisposition, extractSubagentUpdate: extractFxSubagentUpdate, @@ -214,9 +225,8 @@ export type FxAdapterV2DriverEnv = | ProviderEventLoggers.ProviderEventLoggers | ProviderHost.ProviderHost; -/** `defaultModel` reads fx's id for "Default" from the provider snapshot. */ export const makeFxAdapterV2Driver = ( - defaultModel: Effect.Effect, + defaultChoice: (cwd: string) => Effect.Effect, ): ProviderAdapterDriver => ({ driverKind: FX_DRIVER_KIND, configSchema: FxSettings, @@ -232,7 +242,7 @@ export const makeFxAdapterV2Driver = ( settings: { ...input.config, enabled: input.enabled }, environment: yield* mergeProviderInstanceEnvironment(input.environment, hostEnvironment), selfInvocation, - defaultModel, + defaultChoice, nativeLogging: (threadId) => makeNativeLogger({ nativeEventLogger: providerEventLoggers.native, diff --git a/packages/provider-fx/src/server/driver.ts b/packages/provider-fx/src/server/driver.ts index 48384c8054b9a..d982c553508e3 100644 --- a/packages/provider-fx/src/server/driver.ts +++ b/packages/provider-fx/src/server/driver.ts @@ -14,7 +14,7 @@ import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { buildInitialFxProviderSnapshot, checkFxProviderStatus, - fxDefaultModelId, + readFxDefaultChoice, } from "./status.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { @@ -115,6 +115,8 @@ export const FxDriver: ProviderDriver = { checkProvider: checkFxProviderStatus(effectiveConfig, processEnv).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), ), }).pipe( Effect.mapError( @@ -128,9 +130,10 @@ export const FxDriver: ProviderDriver = { ), ); - // "Default" selects the model the snapshot read from `fx status`. - const orchestrationAdapter = yield* makeFxAdapterV2Driver( - snapshot.getSnapshot.pipe(Effect.map((current) => fxDefaultModelId(current.models))), + const orchestrationAdapter = yield* makeFxAdapterV2Driver((cwd) => + readFxDefaultChoice(effectiveConfig, processEnv, cwd).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), ) .create({ instanceId, displayName, accentColor, environment, enabled, config }) .pipe( diff --git a/packages/provider-fx/src/server/status.test.ts b/packages/provider-fx/src/server/status.test.ts index 3f9ae01ae969a..b90309ec876d7 100644 --- a/packages/provider-fx/src/server/status.test.ts +++ b/packages/provider-fx/src/server/status.test.ts @@ -1,8 +1,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import type * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; @@ -11,7 +14,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as TestClock from "effect/testing/TestClock"; import { FxSettings } from "../settings.ts"; -import { checkFxProviderStatus } from "./status.ts"; +import { checkFxProviderStatus, readFxDefaultChoice } from "./status.ts"; const encoder = new TextEncoder(); const settings = Schema.decodeSync(FxSettings)({ enabled: true }); @@ -38,40 +41,59 @@ function processHandle(stdout: string, code: number, answered: Effect.Effect +// `fx status --json` and `fx models --json` from fx 0.0.15, trimmed to the +// fields T3 reads plus a few it ignores. +const status = (auth: string, fields: Record = {}) => + JSON.stringify({ kind: "status", model: "default", auth, auth_refreshable: false, ...fields }); +const models = (ids: ReadonlyArray) => + JSON.stringify({ kind: "models", count: ids.length, private_models_hidden: false, ids }); +const GATEWAY_MODELS = models(["anthropic/claude-sonnet-5.5", "anthropic/claude-opus-5.5"]); +const SIGNED_OUT_LISTING = JSON.stringify({ + kind: "models", + error: "could not list models: AuthenticationRejected", + code: "AuthenticationRejected", +}); + +interface Invocation { + readonly args: string; + readonly backend: string | undefined; + readonly cwd: string | undefined; +} + +/** + * Answers `fx` invocations keyed by `FX_PROVIDER` and arguments, such as + * `codex status --json`; the unscoped status is ` status --json`. HOME holds + * an fx profile whose settings define `profileProviders`. + */ +const check = (outputs: Record, profileProviders: ReadonlyArray = [], versionDelay: Duration.Input = 0, versionSpawned?: Deferred.Deferred) => Effect.gen(function* () { - const outputs: Record = { - "--version": "fx 0.0.13\n", - "status --json": statusJson, - // `fx models --json` from fx 0.0.13, ids trimmed. - "models --json": JSON.stringify({ - kind: "models", - count: 2, - shown_count: 2, - more_count: 0, - private_models_hidden: false, - ids: ["anthropic/claude-sonnet-5.5", "anthropic/claude-opus-5.5"], + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-fx-status-" }); + yield* fileSystem.makeDirectory(path.join(home, ".fx")); + yield* fileSystem.writeFileString( + path.join(home, ".fx", "settings.json"), + JSON.stringify({ + providers: Object.fromEntries(profileProviders.map((id) => [id, { protocol: "x" }])), }), - }; - const invoked: Array = []; + ); + const invoked: Array = []; const spawner = ChildProcessSpawner.make((command) => { - const args = ChildProcess.isStandardCommand(command) ? command.args.join(" ") : ""; - invoked.push(args); - const stdout = outputs[args]; - return Effect.succeed( - processHandle( - stdout ?? "", - stdout === undefined ? 2 : 0, - args === "--version" ? Effect.sleep(versionDelay) : Effect.void, - ), - ); + const standard = ChildProcess.isStandardCommand(command) ? command : undefined; + const args = standard?.args.join(" ") ?? ""; + const backend = standard?.options.env?.FX_PROVIDER; + invoked.push({ args, backend, cwd: standard?.options.cwd }); + const stdout = args === "--version" ? "fx 0.0.15\n" : outputs[`${backend ?? ""} ${args}`]; + return Effect.gen(function* () { + if (args === "--version" && versionSpawned) yield* Deferred.succeed(versionSpawned, undefined); + return processHandle(stdout ?? "", stdout === undefined ? 2 : 0, args === "--version" ? Effect.sleep(versionDelay) : Effect.void); + }); }); - const draft = yield* checkFxProviderStatus(settings, {}).pipe( + const draft = yield* checkFxProviderStatus(settings, { HOME: home }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); return { draft, invoked }; - }).pipe(Effect.provide(NodeServices.layer)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); describe("checkFxProviderStatus", () => { it.effect.each([ @@ -79,9 +101,9 @@ describe("checkFxProviderStatus", () => { label: "answers after 6 s", versionDelay: "6 seconds", status: "ready", - version: "0.0.13", + version: "0.0.15", message: undefined, - invoked: ["--version", "status --json", "models --json"], + invoked: ["--version", "status --json", "status --json", "status --json", "status --json", "models --json"], }, { label: "never answers", @@ -93,61 +115,154 @@ describe("checkFxProviderStatus", () => { }, ] as const)("gives `fx --version` 10 s when it $label", (row) => Effect.gen(function* () { + const versionSpawned = yield* Deferred.make(); const fiber = yield* check( - JSON.stringify({ kind: "status", model: "anthropic/claude-opus-5.5", auth: "fx login" }), - row.versionDelay, + { " status --json": status("fx login"), "gateway status --json": status("fx login"), "gateway models --json": GATEWAY_MODELS, "codex status --json": status("missing"), "grok status --json": status("missing") }, + [], row.versionDelay, versionSpawned, ).pipe(Effect.forkChild); + yield* Deferred.await(versionSpawned); yield* TestClock.adjust("10 seconds"); const { draft, invoked } = yield* Fiber.join(fiber); assert.equal(draft.status, row.status); assert.equal(draft.version, row.version); assert.equal(draft.message, row.message); - assert.deepEqual(invoked, [...row.invoked]); + assert.deepEqual(invoked.map(({args}) => args).toSorted(), [...row.invoked].toSorted()); }), ); - // `fx status --json` from fx 0.0.13, trimmed to the fields T3 reads plus a few it ignores. - it.effect("reports sign-in from `fx status` without opening an ACP session", () => + it.effect("lists each backend's models and marks signed-out backends", () => Effect.gen(function* () { - const signedIn = yield* check( - JSON.stringify({ - kind: "status", - model: "anthropic/claude-opus-5.5", - auth: "fx login", - auth_refreshable: true, - permission_mode: "ask", - }), + const { draft, invoked } = yield* check( + { + " status --json": status("chatgpt subscription", { + model: "gpt-5.5", + model_source: "Codex subscription", + }), + "gateway status --json": status("fx login"), + "gateway models --json": GATEWAY_MODELS, + "codex status --json": status("chatgpt subscription"), + "codex models --json": models(["gpt-5.5", "gpt-5.4-mini"]), + "grok status --json": status("missing"), + "local status --json": status("missing", { + auth_help: "The configured provider credential is unavailable.", + }), + }, + ["local"], ); - assert.deepEqual(signedIn.invoked, ["--version", "status --json", "models --json"]); - assert.equal(signedIn.draft.status, "ready"); - assert.equal(signedIn.draft.version, "0.0.13"); - assert.deepEqual(signedIn.draft.auth, { status: "authenticated", label: "fx login" }); + // Never `fx acp`, which saves a session; signed-out backends list nothing. + assert.deepEqual(invoked.map(({ backend, args }) => `${backend ?? ""} ${args}`).toSorted(), [ + " --version", + " status --json", + "codex models --json", + "codex status --json", + "gateway models --json", + "gateway status --json", + "grok status --json", + "local status --json", + ]); + assert.equal(draft.status, "ready"); + assert.deepEqual(draft.auth, { status: "authenticated", label: "chatgpt subscription" }); assert.deepEqual( - signedIn.draft.models.map((model) => model.slug), - ["default", "anthropic/claude-sonnet-5.5", "anthropic/claude-opus-5.5"], + draft.models.map((model) => [model.slug, model.name, model.subProvider]), + [ + ["default", "Default (gpt-5.5)", undefined], + [ + "gateway:anthropic/claude-sonnet-5.5", + "anthropic/claude-sonnet-5.5", + "Vercel AI Gateway", + ], + ["gateway:anthropic/claude-opus-5.5", "anthropic/claude-opus-5.5", "Vercel AI Gateway"], + ["codex:gpt-5.5", "gpt-5.5", "Codex subscription"], + ["codex:gpt-5.4-mini", "gpt-5.4-mini", "Codex subscription"], + ], ); - // "Default" carries the model `fx status` reports, which the adapter selects for it. - assert.deepEqual(signedIn.draft.models[0]?.aliases, ["anthropic/claude-opus-5.5"]); - assert.isDefined(signedIn.draft.versionAdvisory); + assert.deepEqual(draft.subProviders, [ + { name: "Vercel AI Gateway" }, + { name: "Codex subscription" }, + { + name: "Grok subscription", + unavailableReason: "Signed out. Run `fx login grok` on this machine.", + }, + { name: "local", unavailableReason: "The configured provider credential is unavailable." }, + ]); + assert.isDefined(draft.versionAdvisory); + }), + ); - const signedOut = yield* check( - JSON.stringify({ - kind: "status", - model: "spacexai/grok-4.7", - auth: "missing", - auth_refreshable: false, - auth_help: - "fx needs access to Vercel AI Gateway. Run fx login to sign in, fx setup to use an API key, or set AI_GATEWAY_API_KEY.", - }), + it.effect("stays usable while fx's default backend is signed out", () => + Effect.gen(function* () { + const { draft } = yield* check({ + " status --json": status("missing", { model: "spacexai/grok-4.7" }), + "gateway status --json": status("missing"), + "gateway models --json": GATEWAY_MODELS, + "codex status --json": status("chatgpt subscription"), + "codex models --json": models(["gpt-5.5"]), + "grok status --json": status("missing"), + }); + assert.equal(draft.status, "warning"); + assert.deepEqual(draft.auth, { status: "authenticated", label: "Codex subscription" }); + assert.equal( + draft.message, + "fx's default backend, Vercel AI Gateway, is signed out. Pick a model on another backend or sign in.", ); + // The gateway lists its public catalog signed out, but none of it can run. + assert.deepEqual( + draft.models.map((model) => model.slug), + ["default", "codex:gpt-5.5"], + ); + }), + ); + + it.effect("reports a machine signed out of every backend", () => + Effect.gen(function* () { + const signedOut = yield* check({ + " status --json": status("missing", { model: "spacexai/grok-4.7" }), + "gateway status --json": status("missing"), + "codex status --json": status("missing"), + "codex models --json": SIGNED_OUT_LISTING, + "grok status --json": status("missing"), + }); assert.equal(signedOut.draft.status, "error"); assert.equal(signedOut.draft.auth.status, "unauthenticated"); assert.equal(signedOut.draft.message, "Run `fx login` to sign in."); // An fx whose status output T3 cannot read is not reported as signed out. - const unknown = yield* check("not json"); + const unknown = yield* check({ " status --json": "not json" }); assert.equal(unknown.draft.status, "warning"); assert.equal(unknown.draft.auth.status, "unknown"); }), ); }); + +describe("readFxDefaultChoice", () => { + // fx's settings can give one folder its own model, which `fx status` reports only there. + it.effect("reads fx's default backend and model in the thread's folder", () => + Effect.forEach( + [ + [{ model: "openai/gpt-5.5" }, { backend: "gateway", model: "openai/gpt-5.5" }], + [ + { model: "gpt-5.5", model_source: "Codex subscription" }, + { backend: "codex", model: "gpt-5.5" }, + ], + [ + { model: "llama3", model_source: "local" }, + { backend: "local", model: "llama3" }, + ], + ] as const, + ([fields, expected]) => + Effect.gen(function* () { + const cwds: Array = []; + const spawner = ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command)) cwds.push(command.options.cwd); + return Effect.succeed(processHandle(status("fx login", fields), 0)); + }); + const choice = yield* readFxDefaultChoice(settings, {}, "/work/project").pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + assert.deepEqual(choice, expected); + assert.deepEqual(cwds, ["/work/project"]); + }), + { discard: true }, + ).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/provider-fx/src/server/status.ts b/packages/provider-fx/src/server/status.ts index c71e909197df2..dc111f9fa7966 100644 --- a/packages/provider-fx/src/server/status.ts +++ b/packages/provider-fx/src/server/status.ts @@ -1,10 +1,16 @@ -import { type ServerProviderAuth, type ServerProviderModel } from "@t3tools/contracts"; +import { + type ServerProviderAuth, + type ServerProviderModel, + type ServerProviderSubProvider, +} from "@t3tools/contracts"; import type { FxSettings } from "../settings.ts"; import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as ChildProcess from "effect/process/ChildProcess"; @@ -15,6 +21,8 @@ import { FX_DRIVER_KIND, FX_EFFORT_OPTION_DESCRIPTOR, FX_SUPPORTED_RUNTIME_MODES, + type FxModelChoice, + fxModelSlug, } from "./acpSupport.ts"; import { AUTH_PROBE_TIMEOUT_MS, @@ -37,44 +45,77 @@ const FX_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [FX_EFFORT_OPTION_DESCRIPTOR], }); +/** fx's built-in backends, in the order its `provider` option lists them. */ +const FX_BUILT_IN_BACKENDS = [ + { id: "gateway", name: "Vercel AI Gateway", login: "fx login" }, + { id: "codex", name: "Codex subscription", login: "fx login codex" }, + { id: "grok", name: "Grok subscription", login: "fx login grok" }, +] as const; + +/** What `fx status` and `fx models` report for one backend. */ +interface FxBackendProbe { + readonly id: string; + readonly name: string; + /** Undefined when its status could not be read. */ + readonly signedIn: boolean | undefined; + readonly signInHint: string; + readonly models: ReadonlyArray | undefined; +} + /** - * "Default" keeps the model `fx status` reports as its alias, which the - * adapter selects for it. `fx models` lists the rest. + * "Default" runs what fx would start a new session on in the thread's + * folder; the label shows the machine-wide choice. Other models carry their + * backend in the slug, and only signed-in backends list any. */ const fxModels = ( settings: FxSettings, defaultModel?: string, - listed: ReadonlyArray = [], + backends: ReadonlyArray = [], ): ReadonlyArray => providerModelsFromSettings( [ { slug: FX_DEFAULT_MODEL_SLUG, name: defaultModel === undefined ? "Default" : `Default (${defaultModel})`, - ...(defaultModel === undefined ? {} : { aliases: [defaultModel] }), isCustom: false, isDefault: true, capabilities: FX_MODEL_CAPABILITIES, }, - ...listed - .filter((id, index) => id !== FX_DEFAULT_MODEL_SLUG && listed.indexOf(id) === index) - .map((id) => ({ - slug: id, - name: id, - isCustom: false, - capabilities: FX_MODEL_CAPABILITIES, - })), + ...backends.flatMap((backend) => + backend.signedIn === false + ? [] + : [...new Set(backend.models)].map((id) => ({ + slug: fxModelSlug(backend.id, id), + name: id, + subProvider: backend.name, + isCustom: false, + capabilities: FX_MODEL_CAPABILITIES, + })), + ), ], settings.customModels, FX_MODEL_CAPABILITIES, ); +const fxSubProviders = ( + backends: ReadonlyArray, +): ReadonlyArray => + backends.map((backend) => { + const unavailableReason = + backend.signedIn === false + ? backend.signInHint + : backend.models === undefined + ? "fx could not list this backend's models." + : undefined; + return { name: backend.name, ...(unavailableReason ? { unavailableReason } : {}) }; + }); + const snapshot = ( settings: FxSettings, checkedAt: string, probe: ProviderProbeResult, defaultModel?: string, - listed?: ReadonlyArray, + backends: ReadonlyArray = [], ): ServerProviderDraft => ({ ...buildServerProvider({ // Gives the snapshot its version advisory; fx runs no enrichment pass. @@ -82,9 +123,10 @@ const snapshot = ( presentation: FX_PRESENTATION, enabled: settings.enabled, checkedAt, - models: fxModels(settings, defaultModel, listed), + models: fxModels(settings, defaultModel, backends), probe, }), + ...(backends.length > 0 ? { subProviders: fxSubProviders(backends) } : {}), // Every `fx acp` session is saved under `~/.fx` and ACP has no session // delete, so a throwaway text-generation session would stay on disk. supportsTextGeneration: false, @@ -109,6 +151,7 @@ const runFxCli = Effect.fn("runFxCli")(function* ( settings: FxSettings, args: ReadonlyArray, environment: NodeJS.ProcessEnv, + cwd?: string, ) { const command = settings.binaryPath || "fx"; const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment }); @@ -117,15 +160,36 @@ const runFxCli = Effect.fn("runFxCli")(function* ( ChildProcess.make(spawnCommand.command, spawnCommand.args, { env: environment, shell: spawnCommand.shell, + ...(cwd === undefined ? {} : { cwd }), }), ); }); -/** `fx status --json` on fx 0.0.13: `auth` names the login method, `missing` when signed out. */ +/** Stdout of a successful run, or none. Never opens an ACP session, which fx would save. */ +const readFxCli = ( + settings: FxSettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv, + cwd?: string, +) => + runFxCli(settings, args, environment, cwd).pipe( + Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), + Effect.map(Option.filter((output) => output.code === 0)), + Effect.map(Option.map((output) => output.stdout)), + Effect.orElseSucceed(() => Option.none()), + ); + +/** + * `fx status --json`: `auth` names the login method, `missing` when signed + * out. `model_source` names a backend other than the gateway, by its label + * for a built-in one and its id for a configured endpoint. + */ const FxStatus = Schema.fromJsonString( Schema.Struct({ auth: Schema.optional(Schema.String), + auth_help: Schema.optional(Schema.String), model: Schema.optional(Schema.String), + model_source: Schema.optional(Schema.String), }), ); const decodeFxStatus = Schema.decodeUnknownOption(FxStatus); @@ -133,6 +197,12 @@ const FxModels = Schema.fromJsonString( Schema.Struct({ ids: Schema.optional(Schema.Array(Schema.String)) }), ); const decodeFxModels = Schema.decodeUnknownOption(FxModels); +const FxProfileSettings = Schema.fromJsonString( + Schema.Struct({ + providers: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + }), +); +const decodeFxProfileSettings = Schema.decodeUnknownOption(FxProfileSettings); const VERSION_PROBE_TIMEOUT_MS = 10_000; @@ -144,14 +214,78 @@ function fxAuth(auth: string | undefined): ServerProviderAuth { : { status: "authenticated", label: method }; } +const fxStatusBackend = (modelSource: string | undefined) => { + const source = nonEmptyTrimmed(modelSource); + if (source === undefined) return "gateway"; + return FX_BUILT_IN_BACKENDS.find((backend) => backend.name === source)?.id ?? source; +}; + +/** Configured endpoints are the `providers` keys of fx's profile settings. */ +const readFxConfiguredBackends = Effect.fn("readFxConfiguredBackends")(function* ( + environment: NodeJS.ProcessEnv, +) { + const home = environment.HOME?.trim(); + if (!home) return []; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const text = yield* fileSystem + .readFileString(path.join(home, ".fx", "settings.json")) + .pipe(Effect.orElseSucceed(() => "")); + const providers = Option.getOrUndefined(decodeFxProfileSettings(text))?.providers ?? {}; + // fx's own id rule; the built-in names are reserved. + return Object.keys(providers).filter( + (id) => + /^[A-Za-z][\w-]*$/.test(id) && + !FX_BUILT_IN_BACKENDS.some((backend) => backend.id === id.toLowerCase()), + ); +}); + +/** + * `FX_PROVIDER` points `fx status` and `fx models` at one backend. fx + * refuses a backend without a saved model unless `FX_MODEL` names one; any + * value does, since neither command uses it. + */ +const probeFxBackend = Effect.fn("probeFxBackend")(function* ( + settings: FxSettings, + environment: NodeJS.ProcessEnv, + backend: { readonly id: string; readonly name: string; readonly login?: string }, +) { + const env = { ...environment, FX_PROVIDER: backend.id, FX_MODEL: FX_DEFAULT_MODEL_SLUG }; + const status = Option.getOrUndefined( + Option.flatMap(yield* readFxCli(settings, ["status", "--json"], env), decodeFxStatus), + ); + const auth = fxAuth(status?.auth).status; + const signedIn = auth === "unknown" ? undefined : auth === "authenticated"; + const models = + signedIn === false + ? undefined + : Option.getOrUndefined( + Option.flatMap(yield* readFxCli(settings, ["models", "--json"], env), decodeFxModels), + )?.ids?.flatMap((id) => nonEmptyTrimmed(id) ?? []); + return { + id: backend.id, + name: backend.name, + signedIn, + signInHint: + backend.login === undefined + ? (nonEmptyTrimmed(status?.auth_help) ?? "fx has no credential for this backend.") + : `Signed out. Run \`${backend.login}\` on this machine.`, + models, + } satisfies FxBackendProbe; +}); + /** - * Reads `fx --version`, `fx status --json` (login and default model) and - * `fx models --json`. None of them opens an ACP session, which fx would save. + * Reads `fx --version`, `fx status --json` (the default backend and model) + * and, for every backend, its sign-in and `fx models --json`. */ export const checkFxProviderStatus = Effect.fn("checkFxProviderStatus")(function* ( settings: FxSettings, environment: NodeJS.ProcessEnv, -): Effect.fn.Return { +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { const checkedAt = DateTime.formatIso(yield* DateTime.now); if (!settings.enabled) { return snapshot(settings, checkedAt, { @@ -189,19 +323,27 @@ export const checkFxProviderStatus = Effect.fn("checkFxProviderStatus")(function }); } - const read = (args: ReadonlyArray) => - runFxCli(settings, args, environment).pipe( - Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), - Effect.map(Option.filter((output) => output.code === 0)), - Effect.map(Option.map((output) => output.stdout)), - Effect.orElseSucceed(() => Option.none()), - ); - const status = Option.flatMap(yield* read(["status", "--json"]), decodeFxStatus); - const listed = Option.flatMap(yield* read(["models", "--json"]), decodeFxModels).pipe( - Option.map((models) => models.ids?.flatMap((id) => nonEmptyTrimmed(id) ?? []) ?? []), + const status = Option.getOrUndefined( + Option.flatMap(yield* readFxCli(settings, ["status", "--json"], environment), decodeFxStatus), ); - const auth = fxAuth(Option.getOrUndefined(status)?.auth); - const defaultModel = nonEmptyTrimmed(Option.getOrUndefined(status)?.model); + const backends = yield* Effect.forEach( + [ + ...FX_BUILT_IN_BACKENDS, + ...(yield* readFxConfiguredBackends(environment)).map((id) => ({ id, name: id })), + ], + (backend) => probeFxBackend(settings, environment, backend), + { concurrency: "unbounded" }, + ); + const defaultModel = nonEmptyTrimmed(status?.model); + const defaultBackend = backends.find( + (backend) => backend.id === fxStatusBackend(status?.model_source), + ); + const usable = backends.find((backend) => backend.signedIn !== false && backend.models?.length); + const statusAuth = fxAuth(status?.auth); + const auth: ServerProviderAuth = + statusAuth.status !== "authenticated" && usable?.signedIn === true + ? { status: "authenticated", label: usable.name } + : statusAuth; const probe = auth.status === "unauthenticated" ? { status: "error" as const, message: "Run `fx login` to sign in." } @@ -210,21 +352,44 @@ export const checkFxProviderStatus = Effect.fn("checkFxProviderStatus")(function status: "warning" as const, message: "fx is installed but login state could not be determined.", } - : Option.isNone(listed) + : defaultBackend?.signedIn === false ? { status: "warning" as const, - message: "fx is installed but model listing failed. Model options may be incomplete.", + message: `fx's default backend, ${defaultBackend.name}, is signed out. Pick a model on another backend or sign in.`, } - : { status: "ready" as const }; + : usable === undefined + ? { + status: "warning" as const, + message: + "fx is installed but model listing failed. Model options may be incomplete.", + } + : { status: "ready" as const }; return snapshot( settings, checkedAt, { installed: true, version, auth, ...probe }, defaultModel, - Option.getOrUndefined(listed), + backends, ); }); -/** fx's id for "Default": the alias the snapshot gave it, if `fx status` reported one. */ -export const fxDefaultModelId = (models: ReadonlyArray) => - models.find((model) => model.slug === FX_DEFAULT_MODEL_SLUG)?.aliases?.[0]; +/** + * The backend and model fx starts a new session on in `cwd`, which can come + * from fx's settings for that folder. Undefined when `fx status` fails. + */ +export const readFxDefaultChoice = Effect.fn("readFxDefaultChoice")(function* ( + settings: FxSettings, + environment: NodeJS.ProcessEnv, + cwd: string, +): Effect.fn.Return { + const status = Option.getOrUndefined( + Option.flatMap( + yield* readFxCli(settings, ["status", "--json"], environment, cwd), + decodeFxStatus, + ), + ); + const model = nonEmptyTrimmed(status?.model); + return model === undefined + ? undefined + : { backend: fxStatusBackend(status?.model_source), model }; +}); From 1106cb4bd697fc78eb99fcc826da8aa863172c0a Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 29 Oct 2026 10:45:58 -0400 Subject: [PATCH 2/6] fix(fx): a backend with unreadable sign-in no longer hides a signed-in one Co-Authored-By: Claude Opus 5.5 --- .../provider-fx/src/server/status.test.ts | 52 ++++++++++++++++--- packages/provider-fx/src/server/status.ts | 5 +- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/packages/provider-fx/src/server/status.test.ts b/packages/provider-fx/src/server/status.test.ts index b90309ec876d7..1971333d63f2c 100644 --- a/packages/provider-fx/src/server/status.test.ts +++ b/packages/provider-fx/src/server/status.test.ts @@ -65,7 +65,12 @@ interface Invocation { * `codex status --json`; the unscoped status is ` status --json`. HOME holds * an fx profile whose settings define `profileProviders`. */ -const check = (outputs: Record, profileProviders: ReadonlyArray = [], versionDelay: Duration.Input = 0, versionSpawned?: Deferred.Deferred) => +const check = ( + outputs: Record, + profileProviders: ReadonlyArray = [], + versionDelay: Duration.Input = 0, + versionSpawned?: Deferred.Deferred, +) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -85,8 +90,13 @@ const check = (outputs: Record, profileProviders: ReadonlyArray< invoked.push({ args, backend, cwd: standard?.options.cwd }); const stdout = args === "--version" ? "fx 0.0.15\n" : outputs[`${backend ?? ""} ${args}`]; return Effect.gen(function* () { - if (args === "--version" && versionSpawned) yield* Deferred.succeed(versionSpawned, undefined); - return processHandle(stdout ?? "", stdout === undefined ? 2 : 0, args === "--version" ? Effect.sleep(versionDelay) : Effect.void); + if (args === "--version" && versionSpawned) + yield* Deferred.succeed(versionSpawned, undefined); + return processHandle( + stdout ?? "", + stdout === undefined ? 2 : 0, + args === "--version" ? Effect.sleep(versionDelay) : Effect.void, + ); }); }); const draft = yield* checkFxProviderStatus(settings, { HOME: home }).pipe( @@ -103,7 +113,14 @@ describe("checkFxProviderStatus", () => { status: "ready", version: "0.0.15", message: undefined, - invoked: ["--version", "status --json", "status --json", "status --json", "status --json", "models --json"], + invoked: [ + "--version", + "status --json", + "status --json", + "status --json", + "status --json", + "models --json", + ], }, { label: "never answers", @@ -117,8 +134,16 @@ describe("checkFxProviderStatus", () => { Effect.gen(function* () { const versionSpawned = yield* Deferred.make(); const fiber = yield* check( - { " status --json": status("fx login"), "gateway status --json": status("fx login"), "gateway models --json": GATEWAY_MODELS, "codex status --json": status("missing"), "grok status --json": status("missing") }, - [], row.versionDelay, versionSpawned, + { + " status --json": status("fx login"), + "gateway status --json": status("fx login"), + "gateway models --json": GATEWAY_MODELS, + "codex status --json": status("missing"), + "grok status --json": status("missing"), + }, + [], + row.versionDelay, + versionSpawned, ).pipe(Effect.forkChild); yield* Deferred.await(versionSpawned); yield* TestClock.adjust("10 seconds"); @@ -126,7 +151,7 @@ describe("checkFxProviderStatus", () => { assert.equal(draft.status, row.status); assert.equal(draft.version, row.version); assert.equal(draft.message, row.message); - assert.deepEqual(invoked.map(({args}) => args).toSorted(), [...row.invoked].toSorted()); + assert.deepEqual(invoked.map(({ args }) => args).toSorted(), [...row.invoked].toSorted()); }), ); @@ -210,6 +235,19 @@ describe("checkFxProviderStatus", () => { draft.models.map((model) => model.slug), ["default", "codex:gpt-5.5"], ); + + // An earlier backend whose sign-in fx could not report does not hide Codex's. + const unreadable = yield* check({ + " status --json": status("missing", { model: "spacexai/grok-4.7" }), + "gateway models --json": GATEWAY_MODELS, + "codex status --json": status("chatgpt subscription"), + "codex models --json": models(["gpt-5.5"]), + "grok status --json": status("missing"), + }); + assert.deepEqual(unreadable.draft.auth, { + status: "authenticated", + label: "Codex subscription", + }); }), ); diff --git a/packages/provider-fx/src/server/status.ts b/packages/provider-fx/src/server/status.ts index dc111f9fa7966..3aff635e56ec1 100644 --- a/packages/provider-fx/src/server/status.ts +++ b/packages/provider-fx/src/server/status.ts @@ -339,10 +339,11 @@ export const checkFxProviderStatus = Effect.fn("checkFxProviderStatus")(function (backend) => backend.id === fxStatusBackend(status?.model_source), ); const usable = backends.find((backend) => backend.signedIn !== false && backend.models?.length); + const signedIn = backends.find((backend) => backend.signedIn === true); const statusAuth = fxAuth(status?.auth); const auth: ServerProviderAuth = - statusAuth.status !== "authenticated" && usable?.signedIn === true - ? { status: "authenticated", label: usable.name } + statusAuth.status !== "authenticated" && signedIn !== undefined + ? { status: "authenticated", label: signedIn.name } : statusAuth; const probe = auth.status === "unauthenticated" From 8bd50528b083c7b495082a997288bb76132e315b Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 2 Nov 2026 10:49:36 -0500 Subject: [PATCH 3/6] fix(fx): retain selection when Default backend is unavailable --- .../Adapters/FxAdapterV2.test.ts | 62 +++++++++++++------ packages/provider-fx/src/server/acpSupport.ts | 9 ++- 2 files changed, 50 insertions(+), 21 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts index c3f3e7e1b0912..5bac4ae0c8985 100644 --- a/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts @@ -153,6 +153,7 @@ const openFxSession = Effect.fn("openFxSession")(function* (input: { readonly frames: ReadonlyArray; readonly runtimeMode: RuntimeMode; readonly model?: string; + readonly defaultBackend?: string; /** Loads this fx session instead of creating one. */ readonly loadSessionId?: string; /** Then starts a turn on this model. */ @@ -184,7 +185,9 @@ const openFxSession = Effect.fn("openFxSession")(function* (input: { defaultChoice: (cwd) => // "Default" resolves in the thread's own folder. Effect.succeed( - cwd === replayDir ? { backend: "gateway", model: FX_DEFAULT_MODEL } : undefined, + cwd === replayDir + ? { backend: input.defaultBackend ?? "gateway", model: FX_DEFAULT_MODEL } + : undefined, ), makeRuntime: makeAcpReplayRuntime({ transcript, @@ -296,26 +299,45 @@ describe("FxAdapterV2", () => { ).pipe(Effect.provide(testLayer), Effect.scoped), ); - it.effect("puts a loaded session that ran a named model back on fx's default for Default", () => - openFxSession({ - scenario: "default-model", - runtimeMode: "approval-required", - model: "default", - loadSessionId: SESSION_ID, - frames: [ - ...initialize, - outbound("session/load"), - answer("session/load", fxSession({ model: NAMED_MODEL, modes: FX_0_0_13_MODES })), - // Config id `model`, never fx's `provider`, and never T3's "default". - ...setConfig( - "model", - FX_DEFAULT_MODEL, - fxConfig({ model: FX_DEFAULT_MODEL, modes: FX_0_0_13_MODES }), - ), - // Written on load even though fx reports ask. - ...setConfig("mode", "ask", fxConfig({ model: FX_DEFAULT_MODEL, modes: FX_0_0_13_MODES })), + it.effect("restores Default only when its backend is selectable", () => + Effect.forEach( + [ + { backend: "gateway", hasBackendOption: true, restore: true }, + { backend: "unavailable", hasBackendOption: true, restore: false }, + { backend: "gateway", hasBackendOption: false, restore: false }, ], - }).pipe(Effect.provide(testLayer), Effect.scoped), + ({ backend, hasBackendOption, restore }) => + openFxSession({ + scenario: `default-model-${backend}-${hasBackendOption}`, + runtimeMode: "approval-required", + model: "default", + defaultBackend: backend, + loadSessionId: SESSION_ID, + frames: [ + ...initialize, + outbound("session/load"), + answer("session/load", { + ...fxSession({ model: NAMED_MODEL, modes: FX_0_0_13_MODES }), + configOptions: fxConfig({ model: NAMED_MODEL, modes: FX_0_0_13_MODES }).filter( + (option) => hasBackendOption || option.id !== "provider", + ), + }), + ...(restore + ? setConfig( + "model", + FX_DEFAULT_MODEL, + fxConfig({ model: FX_DEFAULT_MODEL, modes: FX_0_0_13_MODES }), + ) + : []), + ...setConfig( + "mode", + "ask", + fxConfig({ model: restore ? FX_DEFAULT_MODEL : NAMED_MODEL, modes: FX_0_0_13_MODES }), + ), + ], + }), + { discard: true }, + ).pipe(Effect.provide(testLayer), Effect.scoped), ); // `fx status` names a gateway model, but this loaded session runs on Codex, diff --git a/packages/provider-fx/src/server/acpSupport.ts b/packages/provider-fx/src/server/acpSupport.ts index 019756945cd7a..111924cc764a6 100644 --- a/packages/provider-fx/src/server/acpSupport.ts +++ b/packages/provider-fx/src/server/acpSupport.ts @@ -204,9 +204,15 @@ export const applyFxModelSelection = Effect.fn("applyFxModelSelection")(function requested, backendOption ? collectSessionConfigOptionValues(backendOption) : [], ); + const canSelectBackend = + target?.backend === undefined || + (backendOption !== undefined && + (target.backend === backendOption.currentValue || + collectSessionConfigOptionValues(backendOption).includes(target.backend))); if ( target?.backend !== undefined && backendOption !== undefined && + canSelectBackend && target.backend !== backendOption.currentValue ) { yield* runtime.setConfigOption(FX_PROVIDER_CONFIG_ID, target.backend); @@ -216,7 +222,8 @@ export const applyFxModelSelection = Effect.fn("applyFxModelSelection")(function if (target === undefined || target.model === current) return current; if ( isDefault && - (modelOption === undefined || + (!canSelectBackend || + modelOption === undefined || !collectSessionConfigOptionValues(modelOption).includes(target.model)) ) { return current; From 07b2e69f5f56ea0c00e13b5ef9cedbf58e37529d Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 2 Nov 2026 11:12:39 -0500 Subject: [PATCH 4/6] fix(fx): reject Default when its model is unavailable --- .../Adapters/FxAdapterV2.test.ts | 90 ++++++++++++------- packages/provider-fx/src/server/acpSupport.ts | 17 ++-- 2 files changed, 67 insertions(+), 40 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts index 5bac4ae0c8985..c15c1b3aabf73 100644 --- a/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts @@ -1,6 +1,6 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { describe, it } from "@effect/vitest"; +import { assert, describe, it } from "@effect/vitest"; import { MessageId, NodeId, @@ -21,7 +21,9 @@ import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/Pr import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; import * as DateTime from "effect/DateTime"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -343,36 +345,62 @@ describe("FxAdapterV2", () => { // `fx status` names a gateway model, but this loaded session runs on Codex, // whose catalog lacks it: the backend goes back first. it.effect("moves a loaded session on another backend back to fx's default backend", () => - openFxSession({ - scenario: "default-other-backend", - runtimeMode: "approval-required", - model: "default", - loadSessionId: SESSION_ID, - frames: [ - ...initialize, - outbound("session/load"), - answer( - "session/load", - fxSession({ - model: CODEX_MODEL, - backend: "codex", - models: [CODEX_MODEL], - modes: FX_0_0_13_MODES, - }), - ), - // fx then runs the gateway model it saved for that backend. - ...setConfig( - "provider", - "gateway", - fxConfig({ model: NAMED_MODEL, modes: FX_0_0_13_MODES }), - ), - ...setConfig( - "model", - FX_DEFAULT_MODEL, - fxConfig({ model: FX_DEFAULT_MODEL, modes: FX_0_0_13_MODES }), - ), - ...setConfig("mode", "ask", fxConfig({ model: FX_DEFAULT_MODEL, modes: FX_0_0_13_MODES })), - ], + Effect.gen(function* () { + for (const modelAvailable of [true, false]) { + const result = yield* openFxSession({ + scenario: `default-other-backend-${modelAvailable}`, + runtimeMode: "approval-required", + model: "default", + loadSessionId: SESSION_ID, + frames: [ + ...initialize, + outbound("session/load"), + answer( + "session/load", + fxSession({ + model: CODEX_MODEL, + backend: "codex", + models: [CODEX_MODEL], + modes: FX_0_0_13_MODES, + }), + ), + // fx then runs the gateway model it saved for that backend. + ...setConfig( + "provider", + "gateway", + fxConfig({ + model: NAMED_MODEL, + ...(modelAvailable ? {} : { models: [NAMED_MODEL] }), + modes: FX_0_0_13_MODES, + }), + ), + ...(modelAvailable + ? [ + ...setConfig( + "model", + FX_DEFAULT_MODEL, + fxConfig({ model: FX_DEFAULT_MODEL, modes: FX_0_0_13_MODES }), + ), + ...setConfig( + "mode", + "ask", + fxConfig({ model: FX_DEFAULT_MODEL, modes: FX_0_0_13_MODES }), + ), + ] + : []), + ], + }).pipe(Effect.exit); + if (modelAvailable) { + assert.isTrue(Exit.isSuccess(result)); + } else if (Exit.isFailure(result)) { + assert.include( + Cause.pretty(result.cause), + "The default fx model is unavailable on its configured backend.", + ); + } else { + assert.fail("Expected Default selection to reject the unavailable model"); + } + } }).pipe(Effect.provide(testLayer), Effect.scoped), ); diff --git a/packages/provider-fx/src/server/acpSupport.ts b/packages/provider-fx/src/server/acpSupport.ts index 111924cc764a6..d15872dd54ad6 100644 --- a/packages/provider-fx/src/server/acpSupport.ts +++ b/packages/provider-fx/src/server/acpSupport.ts @@ -184,12 +184,7 @@ const selectOption = ( return option?.type === "select" ? option : undefined; }; -/** - * Writes fx's session-only `provider` (the backend), then `model`. "Default" - * is what `fx status` reports for the session's folder, skipped when that - * backend does not list it; any other unlisted model is still sent, so a - * thread never runs on a model it did not pick. - */ +/** fx's model catalog changes with its backend, so it must be read after the provider write. */ export const applyFxModelSelection = Effect.fn("applyFxModelSelection")(function* ( runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], modelSelection: ModelSelection, @@ -220,13 +215,17 @@ export const applyFxModelSelection = Effect.fn("applyFxModelSelection")(function const modelOption = selectOption(yield* runtime.getConfigOptions, FX_MODEL_CONFIG_ID); const current = modelOption?.currentValue; if (target === undefined || target.model === current) return current; + if (isDefault && !canSelectBackend) return current; if ( isDefault && - (!canSelectBackend || - modelOption === undefined || + (modelOption === undefined || !collectSessionConfigOptionValues(modelOption).includes(target.model)) ) { - return current; + return yield* new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: "The default fx model is unavailable on its configured backend.", + data: { configId: FX_MODEL_CONFIG_ID }, + }); } yield* runtime.setConfigOption(FX_MODEL_CONFIG_ID, target.model, { allowUnlistedValue: true }); return target.model; From 2ba756c5cc26c04ff308018c8907bea9341b26e5 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 2 Nov 2026 13:38:24 -0500 Subject: [PATCH 5/6] fix(fx): restore backend and model after failed selection --- .../Adapters/FxAdapterV2.test.ts | 11 ++- .../provider-fx/src/server/acpSupport.test.ts | 72 ++++++++++++++++++- packages/provider-fx/src/server/acpSupport.ts | 69 +++++++++++------- 3 files changed, 126 insertions(+), 26 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts index c15c1b3aabf73..6a17edffc49fe 100644 --- a/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts @@ -387,7 +387,16 @@ describe("FxAdapterV2", () => { fxConfig({ model: FX_DEFAULT_MODEL, modes: FX_0_0_13_MODES }), ), ] - : []), + : setConfig( + "provider", + "codex", + fxConfig({ + backend: "codex", + model: CODEX_MODEL, + models: [CODEX_MODEL], + modes: FX_0_0_13_MODES, + }), + )), ], }).pipe(Effect.exit); if (modelAvailable) { diff --git a/packages/provider-fx/src/server/acpSupport.test.ts b/packages/provider-fx/src/server/acpSupport.test.ts index ae40665cb031c..6f34102e5b95a 100644 --- a/packages/provider-fx/src/server/acpSupport.test.ts +++ b/packages/provider-fx/src/server/acpSupport.test.ts @@ -1,9 +1,14 @@ import { assert, describe, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/compat"; import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; -import { fxPermissionDisposition } from "./acpSupport.ts"; +import { applyFxModelSelection, fxPermissionDisposition } from "./acpSupport.ts"; const editRequest = { sessionId: "fx-session-01", @@ -31,3 +36,68 @@ describe("fx permission prompts", () => { ); }); }); + +describe("fx model selection", () => { + it.effect.each(["unavailable", "rejected"] as const)( + "restores the original backend and model when Default is %s", + (failure) => + Effect.gen(function* () { + let backend = "codex"; + let model = "codex-custom"; + const configOptions = (): ReadonlyArray => [ + { + id: "provider", + name: "provider", + type: "select", + currentValue: backend, + options: ["codex", "gateway"].map((value) => ({ value, name: value })), + }, + { + id: "model", + name: "model", + type: "select", + currentValue: model, + options: (backend === "codex" + ? ["codex-default", "codex-custom"] + : ["gateway-fallback", ...(failure === "rejected" ? ["gateway-default"] : [])] + ).map((value) => ({ value, name: value })), + }, + ]; + const runtime = { + getConfigOptions: Effect.sync(configOptions), + setConfigOption: (id: string, value: string | boolean) => + Effect.gen(function* () { + if (id === "provider") { + backend = String(value); + model = backend === "codex" ? "codex-default" : "gateway-fallback"; + } else if (id === "model") { + if (value === "gateway-default") { + return yield* new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: "Model rejected", + }); + } + model = String(value); + } + return { configOptions: configOptions() }; + }), + }; + const result = yield* applyFxModelSelection( + runtime, + { instanceId: ProviderInstanceId.make("fx"), model: "default" }, + Effect.succeed({ backend: "gateway", model: "gateway-default" }), + ).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(result)); + if (Exit.isFailure(result)) { + assert.include( + Cause.pretty(result.cause), + failure === "unavailable" + ? "The default fx model is unavailable on its configured backend." + : "Model rejected", + ); + } + assert.equal(backend, "codex"); + assert.equal(model, "codex-custom"); + }), + ); +}); diff --git a/packages/provider-fx/src/server/acpSupport.ts b/packages/provider-fx/src/server/acpSupport.ts index d15872dd54ad6..c63ce530c2a74 100644 --- a/packages/provider-fx/src/server/acpSupport.ts +++ b/packages/provider-fx/src/server/acpSupport.ts @@ -186,11 +186,17 @@ const selectOption = ( /** fx's model catalog changes with its backend, so it must be read after the provider write. */ export const applyFxModelSelection = Effect.fn("applyFxModelSelection")(function* ( - runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], + runtime: Pick< + AcpSessionRuntime.AcpSessionRuntime["Service"], + "getConfigOptions" | "setConfigOption" + >, modelSelection: ModelSelection, defaultChoice: Effect.Effect | undefined, ): Effect.fn.Return { - const backendOption = selectOption(yield* runtime.getConfigOptions, FX_PROVIDER_CONFIG_ID); + const originalOptions = yield* runtime.getConfigOptions; + const backendOption = selectOption(originalOptions, FX_PROVIDER_CONFIG_ID); + const originalBackend = backendOption?.currentValue; + const originalModel = selectOption(originalOptions, FX_MODEL_CONFIG_ID)?.currentValue; const requested = modelSelection.model.trim(); const isDefault = requested.length === 0 || requested === FX_DEFAULT_MODEL_SLUG; const target = isDefault @@ -204,31 +210,46 @@ export const applyFxModelSelection = Effect.fn("applyFxModelSelection")(function (backendOption !== undefined && (target.backend === backendOption.currentValue || collectSessionConfigOptionValues(backendOption).includes(target.backend))); - if ( + const changingBackend = target?.backend !== undefined && backendOption !== undefined && canSelectBackend && - target.backend !== backendOption.currentValue - ) { - yield* runtime.setConfigOption(FX_PROVIDER_CONFIG_ID, target.backend); - } - const modelOption = selectOption(yield* runtime.getConfigOptions, FX_MODEL_CONFIG_ID); - const current = modelOption?.currentValue; - if (target === undefined || target.model === current) return current; - if (isDefault && !canSelectBackend) return current; - if ( - isDefault && - (modelOption === undefined || - !collectSessionConfigOptionValues(modelOption).includes(target.model)) - ) { - return yield* new EffectAcpErrors.AcpRequestError({ - code: -32602, - errorMessage: "The default fx model is unavailable on its configured backend.", - data: { configId: FX_MODEL_CONFIG_ID }, - }); - } - yield* runtime.setConfigOption(FX_MODEL_CONFIG_ID, target.model, { allowUnlistedValue: true }); - return target.model; + target.backend !== backendOption.currentValue; + return yield* Effect.gen(function* () { + if (changingBackend && target?.backend !== undefined) { + yield* runtime.setConfigOption(FX_PROVIDER_CONFIG_ID, target.backend); + } + const modelOption = selectOption(yield* runtime.getConfigOptions, FX_MODEL_CONFIG_ID); + const current = modelOption?.currentValue; + if (target === undefined || target.model === current) return current; + if (isDefault && !canSelectBackend) return current; + if ( + isDefault && + (modelOption === undefined || + !collectSessionConfigOptionValues(modelOption).includes(target.model)) + ) { + return yield* new EffectAcpErrors.AcpRequestError({ + code: -32602, + errorMessage: "The default fx model is unavailable on its configured backend.", + data: { configId: FX_MODEL_CONFIG_ID }, + }); + } + yield* runtime.setConfigOption(FX_MODEL_CONFIG_ID, target.model, { allowUnlistedValue: true }); + return target.model; + }).pipe( + Effect.onError(() => { + if (!changingBackend || originalBackend === undefined) return Effect.void; + return Effect.gen(function* () { + yield* runtime.setConfigOption(FX_PROVIDER_CONFIG_ID, originalBackend); + const restoredModel = selectOption(yield* runtime.getConfigOptions, FX_MODEL_CONFIG_ID); + if (originalModel !== undefined && restoredModel?.currentValue !== originalModel) { + yield* runtime.setConfigOption(FX_MODEL_CONFIG_ID, originalModel, { + allowUnlistedValue: true, + }); + } + }).pipe(Effect.ignoreCause({ log: true })); + }), + ); }); /** The shared option loop writes a stored effort; a thread without one starts at medium. */ From f8b6b9a3a9bb93c3a2233a65ec67b17b9837a415 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Fri, 6 Nov 2026 11:46:22 -0500 Subject: [PATCH 6/6] test(fx): handle protected process environment values --- packages/provider-fx/src/server/status.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/provider-fx/src/server/status.test.ts b/packages/provider-fx/src/server/status.test.ts index 1971333d63f2c..c49f467e01e10 100644 --- a/packages/provider-fx/src/server/status.test.ts +++ b/packages/provider-fx/src/server/status.test.ts @@ -6,6 +6,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; @@ -86,7 +87,11 @@ const check = ( const spawner = ChildProcessSpawner.make((command) => { const standard = ChildProcess.isStandardCommand(command) ? command : undefined; const args = standard?.args.join(" ") ?? ""; - const backend = standard?.options.env?.FX_PROVIDER; + const backendValue = standard?.options.env?.FX_PROVIDER; + const backend = + typeof backendValue === "string" || backendValue === undefined + ? backendValue + : Redacted.value(backendValue); invoked.push({ args, backend, cwd: standard?.options.cwd }); const stdout = args === "--version" ? "fx 0.0.15\n" : outputs[`${backend ?? ""} ${args}`]; return Effect.gen(function* () {