diff --git a/apps/web/package.json b/apps/web/package.json index 20e180aad46b..53547e6cfc22 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,6 +28,7 @@ "@pierre/trees": "1.0.0-beta.6", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", diff --git a/apps/web/src/components/chat/ComposerUsageLimits.tsx b/apps/web/src/components/chat/ComposerUsageLimits.tsx index 04d32f610855..514f379d9f33 100644 --- a/apps/web/src/components/chat/ComposerUsageLimits.tsx +++ b/apps/web/src/components/chat/ComposerUsageLimits.tsx @@ -4,7 +4,7 @@ import { GaugeIcon } from "lucide-react"; import { ensureLocalApi } from "../../localApi"; import { Button } from "../ui/button"; -import { getDriverOption } from "../settings/providerDriverMeta"; +import { providerClients } from "../settings/providerDriverMeta"; import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; import { LimitWindows, ResetCredits } from "../usage/UsageLimits"; import { ComposerBanner } from "./ComposerBanner"; @@ -13,7 +13,7 @@ import type { ComposerBannerStackItem } from "./ComposerBannerStack"; /** Driver name, then the instance when there could be more than one of that driver. */ function accountLabel(account: UsageLimitsReport["accounts"][number]): string { if (!account.instanceId) return account.label; - const driver = getDriverOption(account.driver)?.label ?? String(account.driver); + const driver = providerClients.get(account.driver)?.label ?? String(account.driver); const instance = account.displayName?.trim() || (String(account.instanceId) !== String(account.driver) ? account.instanceId : ""); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.import.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.import.test.tsx index 8b052d76168a..79d10c3c6bb2 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.import.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.import.test.tsx @@ -132,7 +132,9 @@ vi.mock("../settings/CodexSetupSection", () => ({ CodexSetupSection: () => null, AddManagedCodexAccountDialog: () => null, })); -vi.mock("../settings/providerDriverMeta", () => ({ getDriverOption: () => ({ label: "Agent" }) })); +vi.mock("../settings/providerDriverMeta", () => ({ + providerClients: { get: () => ({ label: "Agent" }) }, +})); vi.mock("../settings/providerStatus", () => ({ getProviderSummary: () => ({ headline: "Checking", detail: null }), })); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.terminal.test.tsx b/apps/web/src/components/onboarding/WelcomeWizard.terminal.test.tsx index cf7c38ec5875..0a3ae9abc311 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.terminal.test.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.terminal.test.tsx @@ -119,7 +119,7 @@ vi.mock("../settings/CodexSetupSection", () => ({ AddManagedCodexAccountDialog: () => null, })); vi.mock("../settings/providerDriverMeta", () => ({ - getDriverOption: (driver: string) => ({ label: driver }), + providerClients: { get: (driver: string) => ({ label: driver }) }, })); vi.mock("../settings/providerStatus", () => ({ getProviderSummary: () => ({ headline: "Setup required" }), diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index e94078271799..9a4549efc9fa 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -74,7 +74,7 @@ import { terminalEnvironment } from "../../state/terminal"; import { useAtomCommand } from "../../state/use-atom-command"; import { connectPairing } from "../../connection/onboarding"; import { getProviderSummary } from "../settings/providerStatus"; -import { getDriverOption } from "../settings/providerDriverMeta"; +import { providerClients } from "../settings/providerDriverMeta"; import { ChatGptWelcomeCoordinator } from "../settings/ChatGptWelcomeCoordinator"; import { AddManagedCodexAccountDialog, CodexSetupSection } from "../settings/CodexSetupSection"; import { readCodexSetupMode } from "../settings/CodexSetupSection.logic"; @@ -969,7 +969,7 @@ function AgentCard({ readonly terminalAvailable: boolean; readonly onOpenTerminal: () => void; }) { - const meta = getDriverOption(ProviderDriverKind.make(driver)); + const meta = providerClients.get(ProviderDriverKind.make(driver)); const displayName = provider?.displayName || (driver === "claudeAgent" ? "Claude Code" : (meta?.label ?? driver)); const summary = getProviderSummary(provider); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index b329d6ef134b..e923a081b6e4 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -33,7 +33,7 @@ import { Badge } from "../ui/badge"; import { Input } from "../ui/input"; import { RadioGroup } from "../ui/radio-group"; import { toastManager } from "../ui/toast"; -import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta"; +import { providerClients } from "./providerDriverMeta"; import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; import { SettingsGroup } from "./SettingsGroup"; import { SettingsRow } from "./settingsLayout"; @@ -81,7 +81,7 @@ function deriveInstanceId(driver: ProviderDriverKind, label: string): string { const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); -const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!; +const DEFAULT_DRIVER_OPTION = providerClients.definitions[0]!; const EMPTY_CONFIG_DRAFT: Record = {}; /** * Validate an instance id against the same slug rules the server applies in @@ -149,7 +149,7 @@ export function AddProviderInstanceDialog({ return ids; }, [settings.providerInstances, settings.providers]); - const driverOption = DRIVER_OPTION_BY_VALUE[driver] ?? DEFAULT_DRIVER_OPTION; + const driverOption = providerClients.get(driver) ?? DEFAULT_DRIVER_OPTION; const isAcpRegistry = driver === ACP_REGISTRY_DRIVER_KIND; const defaultIdentity: ProviderIdentityDraft = { label: driverOption.label, @@ -433,16 +433,17 @@ export function AddProviderInstanceDialog({ aria-labelledby="add-instance-driver-label" className="grid grid-cols-1 sm:grid-cols-2" > - {DRIVER_OPTIONS.filter((option) => option.value !== ACP_REGISTRY_DRIVER_KIND).map( - (option) => { + {providerClients.definitions + .filter((option) => option.driverKind !== ACP_REGISTRY_DRIVER_KIND) + .map((option) => { return ( @@ -462,8 +463,7 @@ export function AddProviderInstanceDialog({ ) : null} ); - }, - )} + })} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index eab2fba04cb2..e147dd2cb447 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -46,7 +46,10 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import type { DriverOption, ProviderEnvironmentFieldDefinition } from "./providerDriverMeta"; +import type { + ProviderClientDefinition, + ProviderEnvironmentField, +} from "@t3tools/provider-core/client"; import { deriveProviderSettingsFields, ProviderSettingsForm } from "./ProviderSettingsForm"; import { ProviderModelsSection } from "./ProviderModelsSection"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; @@ -216,7 +219,7 @@ export function providerEnvironmentWithoutNames( export function nextProviderEnvironmentWithFieldValue( environment: ReadonlyArray | undefined, - field: ProviderEnvironmentFieldDefinition, + field: ProviderEnvironmentField, value: string, ): ReadonlyArray { const trimmed = value.trim(); @@ -250,11 +253,11 @@ export function nextProviderEnvironmentWithFieldValue( } function ProviderEnvironmentFieldRow(props: { - readonly field: ProviderEnvironmentFieldDefinition; + readonly field: ProviderEnvironmentField; readonly variable: ProviderInstanceEnvironmentVariable | undefined; readonly idPrefix: string; - readonly onCommit: (field: ProviderEnvironmentFieldDefinition, value: string) => void; - readonly onRemove: (field: ProviderEnvironmentFieldDefinition) => void; + readonly onCommit: (field: ProviderEnvironmentField, value: string) => void; + readonly onRemove: (field: ProviderEnvironmentField) => void; }) { const inputId = `${props.idPrefix}-environment-${props.field.name}`; const value = props.variable?.valueRedacted ? "" : (props.variable?.value ?? ""); @@ -472,7 +475,7 @@ export function ProviderEnvironmentSection(props: { interface ProviderInstanceCardProps { readonly instanceId: ProviderInstanceId; readonly instance: ProviderInstanceConfig; - readonly driverOption: DriverOption | undefined; + readonly driverOption: ProviderClientDefinition | undefined; readonly liveProvider: ServerProvider | undefined; readonly mode: "list" | "editor"; readonly selected?: boolean | undefined; @@ -719,10 +722,10 @@ export function ProviderInstanceCard({ ); updateEnvironment([...dedicatedEnvironment, ...environment]); }; - const updateEnvironmentField = (field: ProviderEnvironmentFieldDefinition, value: string) => { + const updateEnvironmentField = (field: ProviderEnvironmentField, value: string) => { updateEnvironment(nextProviderEnvironmentWithFieldValue(instance.environment, field, value)); }; - const removeEnvironmentField = (field: ProviderEnvironmentFieldDefinition) => { + const removeEnvironmentField = (field: ProviderEnvironmentField) => { updateEnvironment(providerEnvironmentWithoutNames(instance.environment, new Set([field.name]))); }; diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index 6edeedb30740..ff1de91addc9 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderDriverKind } from "@t3tools/contracts"; -import { DRIVER_OPTION_BY_VALUE } from "./providerDriverMeta"; +import { providerClients } from "./providerDriverMeta"; import { deriveProviderSettingsFields, nextProviderConfigWithFieldValue, @@ -9,7 +9,7 @@ import { describe("ProviderSettingsForm helpers", () => { it("derives visible provider config fields from the client definition schema", () => { - const codex = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("codex")]; + const codex = providerClients.get(ProviderDriverKind.make("codex")); expect(codex).toBeDefined(); expect(deriveProviderSettingsFields(codex!).map((field) => field.key)).toEqual([ @@ -21,7 +21,7 @@ describe("ProviderSettingsForm helpers", () => { }); it("sources labels and descriptions from schema annotations", () => { - const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; + const opencode = providerClients.get(ProviderDriverKind.make("opencode")); expect(opencode).toBeDefined(); const serverPassword = deriveProviderSettingsFields(opencode!).find( @@ -36,7 +36,7 @@ describe("ProviderSettingsForm helpers", () => { }); it("uses a dedicated environment field instead of legacy Cursor CLI settings", () => { - const cursor = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("cursor")]; + const cursor = providerClients.get(ProviderDriverKind.make("cursor")); expect(cursor).toBeDefined(); expect(deriveProviderSettingsFields(cursor!)).toEqual([]); @@ -52,7 +52,7 @@ describe("ProviderSettingsForm helpers", () => { }); it("exposes ACP Registry as an instance-only configurable driver", () => { - const acpRegistry = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("acpRegistry")]; + const acpRegistry = providerClients.get(ProviderDriverKind.make("acpRegistry")); expect(acpRegistry).toBeDefined(); expect(acpRegistry?.hasDefaultInstance).toBe(false); @@ -65,14 +65,14 @@ describe("ProviderSettingsForm helpers", () => { }); it("shows the local executable without registry identity or authentication fields", () => { - const acpRegistry = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("acpRegistry")]; + const acpRegistry = providerClients.get(ProviderDriverKind.make("acpRegistry")); expect( deriveProviderSettingsFields(acpRegistry!, { source: "local" }).map((field) => field.key), ).toEqual(["source", "commandPath"]); }); it("derives a select control with its choices for the Antigravity sign-in method", () => { - const antigravity = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("antigravity")]; + const antigravity = providerClients.get(ProviderDriverKind.make("antigravity")); expect(antigravity).toBeDefined(); const fields = deriveProviderSettingsFields(antigravity!); @@ -95,7 +95,7 @@ describe("ProviderSettingsForm helpers", () => { }); it("shows the auto-compaction threshold for Claude providers", () => { - const claude = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("claudeAgent")]; + const claude = providerClients.get(ProviderDriverKind.make("claudeAgent")); expect(claude).toBeDefined(); expect(deriveProviderSettingsFields(claude!).map((field) => field.key)).toEqual([ @@ -107,7 +107,7 @@ describe("ProviderSettingsForm helpers", () => { }); it("preserves unknown config keys while omitting empty configurable fields", () => { - const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; + const opencode = providerClients.get(ProviderDriverKind.make("opencode")); expect(opencode).toBeDefined(); const serverUrl = deriveProviderSettingsFields(opencode!).find( diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index dfb35facbd60..d772585c74f3 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -18,7 +18,7 @@ import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; -import type { ProviderClientDefinition } from "./providerDriverMeta"; +import type { ProviderClientDefinition } from "@t3tools/provider-core/client"; import { SettingsRow } from "./settingsLayout"; export interface ProviderSettingsFieldModel { @@ -81,7 +81,8 @@ export function deriveProviderSettingsFields( value?: unknown, ): ReadonlyArray { const isLocalAcp = - definition.value === "acpRegistry" && readProviderConfigString(value, "source") === "local"; + definition.driverKind === "acpRegistry" && + readProviderConfigString(value, "source") === "local"; const schemaAnnotation = readProviderSettingsFormSchemaAnnotation(definition); const orderedKeys = new Map( (schemaAnnotation.order ?? []).map((key, index) => [key, index] as const), @@ -537,7 +538,8 @@ export function ProviderSettingsForm({ [definition, value], ); const isLocalAcp = - definition.value === "acpRegistry" && readProviderConfigString(value, "source") === "local"; + definition.driverKind === "acpRegistry" && + readProviderConfigString(value, "source") === "local"; if (fields.length === 0) { return null; diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 37b986217d29..dd471b4d14e8 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -95,7 +95,7 @@ import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetup import { ProviderAuthenticationSection } from "./ProviderAuthenticationSection"; import { CodexSetupSection, CodexManagedRuntimeFields } from "./CodexSetupSection"; import { readCodexSetupMode } from "./CodexSetupSection.logic"; -import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; +import { providerClients } from "./providerDriverMeta"; import { searchableSetting } from "./settingsSearch"; import { backgroundActivityOverrideSettings, @@ -145,8 +145,8 @@ function providerConfigString(config: unknown, key: string): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } -const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ - provider: definition.value, +const PROVIDER_SETTINGS = providerClients.definitions.map((definition) => ({ + provider: definition.driverKind, })); function configuredBinaryPath(config: unknown): string { @@ -975,7 +975,7 @@ export function EnvironmentProviderSettings({ }; const renderProviderInstance = (row: InstanceRow, mode: "list" | "editor") => { - const driverOption = getDriverOption(row.driver); + const driverOption = providerClients.get(row.driver); const liveProvider = serverProviders.find( (candidate) => candidate.instanceId === row.instanceId, ); diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index c576082fd025..60d2d346d13b 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -10,56 +10,22 @@ import { MuseSettings, ProviderDriverKind, } from "@t3tools/contracts"; -import type * as Schema from "effect/Schema"; +import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; -type ProviderSettingsSchema = { - readonly fields: Readonly>; -} & Schema.Top; - -/** - * Browser-safe provider definition. This is deliberately shaped like the - * future provider package client export: the core web app gets a schema with - * field annotations plus provider-level presentation metadata, then renders - * settings generically. - */ -export interface ProviderClientDefinition { - readonly value: ProviderDriverKind; - readonly label: string; - readonly settingsSchema: ProviderSettingsSchema; - readonly environmentFields?: readonly ProviderEnvironmentFieldDefinition[]; - /** Whether this driver has a built-in default instance backed by legacy settings. */ - readonly hasDefaultInstance?: boolean; - /** - * Optional short label rendered as a `variant="warning"` badge next to - * the instance title. Used to flag drivers that still ship under an - * early-access or preview gate — the flag is a property of the driver - * kind (not a specific instance), so every instance of that driver — - * built-in default or custom — advertises the same marker. - */ - readonly badgeLabel?: string; -} - -export interface ProviderEnvironmentFieldDefinition { - readonly name: string; - readonly label: string; - readonly description?: string; - readonly placeholder?: string; - readonly sensitive?: boolean; -} - -const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ +/** The provider client definitions this web build ships, in presentation order. */ +export const providerClients = makeProviderClientRegistry([ { - value: ProviderDriverKind.make("codex"), + driverKind: ProviderDriverKind.make("codex"), label: "Codex", settingsSchema: CodexSettings, }, { - value: ProviderDriverKind.make("claudeAgent"), + driverKind: ProviderDriverKind.make("claudeAgent"), label: "Claude", settingsSchema: ClaudeSettings, }, { - value: ProviderDriverKind.make("cursor"), + driverKind: ProviderDriverKind.make("cursor"), label: "Cursor", settingsSchema: CursorSettings, environmentFields: [ @@ -73,55 +39,35 @@ const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ ], }, { - value: ProviderDriverKind.make("grok"), + driverKind: ProviderDriverKind.make("grok"), label: "Grok", settingsSchema: GrokSettings, }, { - value: ProviderDriverKind.make("opencode"), + driverKind: ProviderDriverKind.make("opencode"), label: "OpenCode", settingsSchema: OpenCodeSettings, }, { - value: ProviderDriverKind.make("antigravity"), + driverKind: ProviderDriverKind.make("antigravity"), label: "Antigravity", settingsSchema: AntigravitySettings, }, { - value: ProviderDriverKind.make("muse"), + driverKind: ProviderDriverKind.make("muse"), label: "Muse Code", settingsSchema: MuseSettings, badgeLabel: "Beta", }, { - value: ProviderDriverKind.make("pi"), + driverKind: ProviderDriverKind.make("pi"), label: "Pi", settingsSchema: PiSettings, }, { - value: ProviderDriverKind.make("acpRegistry"), + driverKind: ProviderDriverKind.make("acpRegistry"), label: "ACP Registry", settingsSchema: AcpRegistrySettings, hasDefaultInstance: false, }, -]; - -const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< - Record -> = Object.fromEntries( - PROVIDER_CLIENT_DEFINITIONS.map((definition) => [definition.value, definition]), -); - -export const DRIVER_OPTIONS = PROVIDER_CLIENT_DEFINITIONS; -export const DRIVER_OPTION_BY_VALUE = PROVIDER_CLIENT_DEFINITION_BY_VALUE; -export type DriverOption = ProviderClientDefinition; - -/** - * Look up the driver metadata for an instance's `driver` field. Accepts - * Returns `undefined` for fork / unknown drivers so callers can decide how - * to render them — typically by falling back to a generic card. - */ -export function getDriverOption(driver: ProviderDriverKind | undefined): DriverOption | undefined { - if (driver === undefined) return undefined; - return PROVIDER_CLIENT_DEFINITION_BY_VALUE[driver]; -} +]); diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 9ebe499179b0..be48577555aa 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -21,7 +21,7 @@ import { usePrimarySettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { formatUpcomingTimestamp } from "../../timestampFormat"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; -import { getDriverOption } from "../settings/providerDriverMeta"; +import { providerClients } from "../settings/providerDriverMeta"; import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; import { Button } from "../ui/button"; import { OpenAI } from "../Icons"; @@ -81,7 +81,9 @@ function AccountAvatar({ - {getDriverOption(account.driver)?.label ?? String(account.driver)} + {providerClients.get(account.driver)?.label ?? String(account.driver)} ); } @@ -166,7 +168,7 @@ function SegmentPopover({ - {account.displayName ?? getDriverOption(account.driver)?.label ?? account.driver} + {account.displayName ?? providerClients.get(account.driver)?.label ?? account.driver} {account.email ? ( @@ -531,7 +533,7 @@ function PoolWindowCard({ function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: number }) { const color = barColor(pool.driver); - const label = getDriverOption(pool.driver)?.label ?? String(pool.driver); + const label = providerClients.get(pool.driver)?.label ?? String(pool.driver); const windows = displayLimitWindows(pool); return (
diff --git a/apps/web/src/components/usage/UsagePage.refresh.test.tsx b/apps/web/src/components/usage/UsagePage.refresh.test.tsx index bbe53e36936a..ff19e20fc84d 100644 --- a/apps/web/src/components/usage/UsagePage.refresh.test.tsx +++ b/apps/web/src/components/usage/UsagePage.refresh.test.tsx @@ -91,7 +91,9 @@ vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null })); vi.mock("../chat/ProviderInstanceIcon", () => ({ ProviderInstanceIcon: () => null })); vi.mock("../settings/RedactedSensitiveText", () => ({ RedactedSensitiveText: "span" })); -vi.mock("../settings/providerDriverMeta", () => ({ getDriverOption: () => ({ label: "Codex" }) })); +vi.mock("../settings/providerDriverMeta", () => ({ + providerClients: { get: () => ({ label: "Codex" }) }, +})); import { UsagePage } from "./UsagePage"; diff --git a/package.json b/package.json index 559801c550c7..d98839d3c2b7 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/provider-core/package.json b/packages/provider-core/package.json new file mode 100644 index 000000000000..35b0b32568ed --- /dev/null +++ b/packages/provider-core/package.json @@ -0,0 +1,22 @@ +{ + "name": "@t3tools/provider-core", + "private": true, + "type": "module", + "exports": { + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "vite-plus": "catalog:" + } +} diff --git a/packages/provider-core/src/client.test.ts b/packages/provider-core/src/client.test.ts new file mode 100644 index 000000000000..25fa59325de1 --- /dev/null +++ b/packages/provider-core/src/client.test.ts @@ -0,0 +1,34 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { defineProviderClient, makeProviderClientRegistry } from "./client.ts"; + +const settingsSchema = Schema.Struct({ binaryPath: Schema.String }); + +describe("makeProviderClientRegistry", () => { + it("looks definitions up by driver kind and keeps unknown drivers undefined", () => { + const codex = defineProviderClient({ + driverKind: ProviderDriverKind.make("codex"), + label: "Codex", + settingsSchema, + }); + const registry = makeProviderClientRegistry([codex]); + + expect(registry.get(ProviderDriverKind.make("codex"))).toBe(codex); + expect(registry.get(ProviderDriverKind.make("forkDriver"))).toBeUndefined(); + expect(registry.get(undefined)).toBeUndefined(); + }); + + it("rejects two definitions for one driver kind", () => { + const definition = defineProviderClient({ + driverKind: ProviderDriverKind.make("codex"), + label: "Codex", + settingsSchema, + }); + + expect(() => makeProviderClientRegistry([definition, definition])).toThrow( + "Provider driver 'codex' is defined more than once.", + ); + }); +}); diff --git a/packages/provider-core/src/client.ts b/packages/provider-core/src/client.ts new file mode 100644 index 000000000000..18c64cede595 --- /dev/null +++ b/packages/provider-core/src/client.ts @@ -0,0 +1,77 @@ +/** + * The client half of a provider package. + * + * Every provider package exports a `ProviderClientDefinition` from its + * `./client` entry point. Clients render settings, labels, and badges from + * these definitions plus the server's `ServerProvider` snapshots, so a new + * provider needs no per-driver branches in web or mobile. + * + * Definitions are browser- and React Native-safe: plain data and Effect + * schemas, no platform UI imports. + * + * @module provider-core/client + */ +import type { ProviderDriverKind } from "@t3tools/contracts"; +import type * as Schema from "effect/Schema"; + +/** + * A provider's instance settings schema. Fields carry `providerSettingsForm` + * annotations, which the settings form renders generically. + */ +export type ProviderSettingsSchema = { + readonly fields: Readonly>; +} & Schema.Top; + +/** An environment variable the settings UI offers for every instance of a driver. */ +export interface ProviderEnvironmentField { + readonly name: string; + readonly label: string; + readonly description?: string; + readonly placeholder?: string; + readonly sensitive?: boolean; +} + +export interface ProviderClientDefinition { + readonly driverKind: ProviderDriverKind; + readonly label: string; + readonly settingsSchema: ProviderSettingsSchema; + readonly environmentFields?: ReadonlyArray; + /** Whether the driver has a default instance backed by legacy `providers.` settings. */ + readonly hasDefaultInstance?: boolean; + /** + * Short warning badge beside every instance of the driver, such as "Beta". + * It marks the driver kind, so built-in and custom instances show it alike. + */ + readonly badgeLabel?: string; +} + +export function defineProviderClient( + definition: Definition, +): Definition { + return definition; +} + +/** The provider definitions a client loaded, in presentation order. */ +export interface ProviderClientRegistry { + readonly definitions: ReadonlyArray; + /** `undefined` for drivers this client does not ship, such as a fork's driver. */ + readonly get: ( + driverKind: ProviderDriverKind | undefined, + ) => ProviderClientDefinition | undefined; +} + +export function makeProviderClientRegistry( + definitions: ReadonlyArray, +): ProviderClientRegistry { + const byDriverKind = new Map(); + for (const definition of definitions) { + if (byDriverKind.has(definition.driverKind)) { + throw new Error(`Provider driver '${definition.driverKind}' is defined more than once.`); + } + byDriverKind.set(definition.driverKind, definition); + } + return { + definitions, + get: (driverKind) => (driverKind === undefined ? undefined : byDriverKind.get(driverKind)), + }; +} diff --git a/packages/provider-core/tsconfig.json b/packages/provider-core/tsconfig.json new file mode 100644 index 000000000000..564a5990051f --- /dev/null +++ b/packages/provider-core/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eed53ec3b655..50aecf1c9659 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -715,6 +715,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../../packages/provider-core '@t3tools/shared': specifier: workspace:* version: link:../../packages/shared @@ -1053,6 +1056,19 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-core: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + effect: + specifier: 4.0.1 + version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) + devDependencies: + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/shared: dependencies: '@noble/curves': diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 963232dbbbd2..60ffe8e635fe 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -28,6 +28,7 @@ const workspaceFiles = [ "packages/shared/package.json", "packages/ssh/package.json", "packages/tailscale/package.json", + "packages/provider-core/package.json", "packages/effect-acp/package.json", "packages/effect-codex-app-server/package.json", "scripts/package.json",