From 3dd05670e1ecdf261b8fc02a9563c13f542d8d28 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 11 May 2026 03:31:40 -0700 Subject: [PATCH] Add remote provider compatibility map --- .../src/provider/Drivers/ClaudeDriver.ts | 2 + .../src/provider/Drivers/CodexDriver.ts | 2 + .../src/provider/Drivers/OpenCodeDriver.ts | 2 + .../src/provider/Layers/CursorProvider.ts | 2 + .../provider/providerCompatibility.test.ts | 124 ++++++ .../src/provider/providerCompatibility.ts | 372 ++++++++++++++++++ .../src/provider/providerSnapshot.test.ts | 55 ++- apps/server/src/provider/providerSnapshot.ts | 10 +- .../settings/ProviderInstanceCard.tsx | 44 +++ .../settings/providerStatus.test.ts | 35 ++ .../src/components/settings/providerStatus.ts | 32 +- packages/contracts/src/server.test.ts | 35 ++ packages/contracts/src/server.ts | 33 ++ provider-compatibility.v1.json | 23 ++ 14 files changed, 768 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/provider/providerCompatibility.test.ts create mode 100644 apps/server/src/provider/providerCompatibility.ts create mode 100644 apps/web/src/components/settings/providerStatus.test.ts create mode 100644 provider-compatibility.v1.json diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e3f15d865c9..835e2e03817 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -47,6 +47,7 @@ import { normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; +import { enrichProviderSnapshotWithCompatibilityAdvisory } from "../providerCompatibility.ts"; import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); @@ -171,6 +172,7 @@ export const ClaudeDriver: ProviderDriver = { checkProvider, enrichSnapshot: ({ snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( + Effect.flatMap(enrichProviderSnapshotWithCompatibilityAdvisory), Effect.provideService(HttpClient.HttpClient, httpClient), Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), ), diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 48bc19e5612..51db08f227d 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -46,6 +46,7 @@ import { makePackageManagedProviderMaintenanceResolver, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; +import { enrichProviderSnapshotWithCompatibilityAdvisory } from "../providerCompatibility.ts"; import { codexContinuationIdentity, materializeCodexShadowHome, @@ -171,6 +172,7 @@ export const CodexDriver: ProviderDriver = { checkProvider, enrichSnapshot: ({ snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( + Effect.flatMap(enrichProviderSnapshotWithCompatibilityAdvisory), Effect.provideService(HttpClient.HttpClient, httpClient), Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), ), diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 816e8b70f55..9b6e6331544 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -46,6 +46,7 @@ import { normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; +import { enrichProviderSnapshotWithCompatibilityAdvisory } from "../providerCompatibility.ts"; const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); const DRIVER_KIND = ProviderDriverKind.make("opencode"); @@ -150,6 +151,7 @@ export const OpenCodeDriver: ProviderDriver checkProvider, enrichSnapshot: ({ snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( + Effect.flatMap(enrichProviderSnapshotWithCompatibilityAdvisory), Effect.provideService(HttpClient.HttpClient, httpClient), Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), ), diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 035c08437a9..0979f027044 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -41,6 +41,7 @@ import { enrichProviderSnapshotWithVersionAdvisory, type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; +import { enrichProviderSnapshotWithCompatibilityAdvisory } from "../providerCompatibility.ts"; import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; const PROVIDER = ProviderDriverKind.make("cursor"); @@ -1242,6 +1243,7 @@ export const enrichCursorSnapshot = (input: { snapshot, input.maintenanceCapabilities, ).pipe( + Effect.flatMap(enrichProviderSnapshotWithCompatibilityAdvisory), Effect.provideService(HttpClient.HttpClient, input.httpClient), Effect.flatMap((enrichedSnapshot) => publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)), diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts new file mode 100644 index 00000000000..febb83eafbc --- /dev/null +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { + clearProviderCompatibilityCacheForTests, + createProviderCompatibilityAdvisory, + enrichProviderSnapshotWithCompatibilityAdvisory, + type ProviderCompatibilityDocument, +} from "./providerCompatibility.ts"; + +const codexDriver = ProviderDriverKind.make("codex"); + +const baseProvider: ServerProvider = { + instanceId: ProviderInstanceId.make("codex"), + driver: codexDriver, + displayName: "Codex", + enabled: true, + installed: true, + version: "0.130.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], +}; + +function jsonHttpClient(payload: unknown, status = 200): HttpClient.HttpClient { + return HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(payload), { + status, + headers: { "content-type": "application/json" }, + }), + ), + ), + ); +} + +afterEach(() => { + clearProviderCompatibilityCacheForTests(); +}); + +describe("provider compatibility", () => { + it("selects policies by T3 Code version range", () => { + const document: ProviderCompatibilityDocument = { + version: 1, + policies: [ + { + t3CodeRange: "<0.1.0", + driver: codexDriver, + recommendedRange: "<0.130.0", + recommendedVersion: "0.129.0", + ranges: [{ status: "broken", range: ">=0.130.0" }], + }, + { + t3CodeRange: ">=0.1.0", + driver: codexDriver, + recommendedRange: ">=0.130.0", + recommendedVersion: "0.130.0", + ranges: [{ status: "supported", range: ">=0.130.0" }], + }, + ], + }; + + expect( + createProviderCompatibilityAdvisory({ + driver: codexDriver, + currentVersion: "0.130.0", + document, + t3CodeVersion: "0.0.22", + }), + ).toMatchObject({ + status: "broken", + recommendedVersion: "0.129.0", + }); + }); + + it("enriches snapshots from the remote compatibility map when available", async () => { + const remoteDocument = { + version: 1, + policies: [ + { + t3CodeRange: ">=0.0.0", + driver: "codex", + recommendedRange: "<0.130.0", + recommendedVersion: "0.129.0", + ranges: [{ status: "broken", range: ">=0.130.0" }], + }, + ], + }; + + const enriched = await Effect.runPromise( + enrichProviderSnapshotWithCompatibilityAdvisory(baseProvider).pipe( + Effect.provideService(HttpClient.HttpClient, jsonHttpClient(remoteDocument)), + ), + ); + + expect(enriched.status).toBe("error"); + expect(enriched.compatibilityAdvisory).toMatchObject({ + status: "broken", + recommendedVersion: "0.129.0", + }); + }); + + it("falls back to the bundled map when the remote compatibility fetch fails", async () => { + const enriched = await Effect.runPromise( + enrichProviderSnapshotWithCompatibilityAdvisory({ + ...baseProvider, + version: "0.128.0", + }).pipe(Effect.provideService(HttpClient.HttpClient, jsonHttpClient({}, 404))), + ); + + expect(enriched.status).toBe("error"); + expect(enriched.compatibilityAdvisory).toMatchObject({ + status: "broken", + recommendedVersion: "0.129.0", + }); + }); +}); diff --git a/apps/server/src/provider/providerCompatibility.ts b/apps/server/src/provider/providerCompatibility.ts new file mode 100644 index 00000000000..ec55861a5d2 --- /dev/null +++ b/apps/server/src/provider/providerCompatibility.ts @@ -0,0 +1,372 @@ +import { + ProviderDriverKind, + TrimmedNonEmptyString, + type ServerProvider, + type ServerProviderCompatibilityAdvisory, + type ServerProviderCompatibilityRange, +} from "@t3tools/contracts"; +import { satisfiesSemverRange } from "@t3tools/shared/semver"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; + +import packageJson from "../../package.json" with { type: "json" }; + +export interface ProviderCompatibilityPolicy { + readonly t3CodeRange: string; + readonly driver: ProviderDriverKind; + readonly recommendedRange: string | null; + readonly recommendedVersion?: string | null; + readonly ranges: ReadonlyArray; +} + +export interface ProviderCompatibilityDocument { + readonly version: 1; + readonly policies: ReadonlyArray; +} + +interface RemoteCompatibilityCacheEntry { + readonly expiresAt: number; + readonly document: ProviderCompatibilityDocument | null; +} + +type ProviderCompatibilitySnapshot = Pick & { + readonly compatibilityAdvisory?: ServerProviderCompatibilityAdvisory | undefined; +}; + +const CODEX_DRIVER = ProviderDriverKind.make("codex"); +const T3_CODE_VERSION = packageJson.version; +const REMOTE_COMPATIBILITY_CACHE_TTL_MS = 15 * 60 * 1_000; +const REMOTE_COMPATIBILITY_TIMEOUT_MS = 2_500; + +export const DEFAULT_PROVIDER_COMPATIBILITY_MAP_URL = + "https://raw.githubusercontent.com/pingdotgg/t3code/main/provider-compatibility.v1.json"; + +const remoteCompatibilityCache = new Map(); + +const RemoteCompatibilityRange = Schema.Struct({ + status: Schema.Literals(["unknown", "supported", "graceful", "unsupported", "broken"]), + range: TrimmedNonEmptyString, + label: Schema.optional(TrimmedNonEmptyString), +}); + +const RemoteCompatibilityPolicy = Schema.Struct({ + t3CodeRange: TrimmedNonEmptyString, + driver: TrimmedNonEmptyString, + recommendedRange: Schema.NullOr(TrimmedNonEmptyString), + recommendedVersion: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), + ranges: Schema.Array(RemoteCompatibilityRange), +}); + +const RemoteCompatibilityDocument = Schema.Struct({ + version: Schema.Literal(1), + policies: Schema.Array(RemoteCompatibilityPolicy), +}); + +const decodeRemoteCompatibilityDocument = Schema.decodeUnknownEffect(RemoteCompatibilityDocument); + +/** + * Bundled fallback harness compatibility map. + * + * The hosted JSON document at `DEFAULT_PROVIDER_COMPATIBILITY_MAP_URL` is the + * maintainer-overridable source. Keep this bundled map conservative so old + * installs still have useful behavior when offline or when GitHub is down. + */ +export const BUNDLED_PROVIDER_COMPATIBILITY_DOCUMENT: ProviderCompatibilityDocument = { + version: 1, + policies: [ + { + t3CodeRange: ">=0.0.0", + driver: CODEX_DRIVER, + recommendedRange: ">=0.129.0", + recommendedVersion: "0.129.0", + ranges: [ + { + status: "supported", + range: ">=0.129.0", + label: "Known working Codex app-server harness", + }, + { + status: "broken", + range: "<0.129.0", + label: "Known incompatible Codex app-server harness", + }, + ], + }, + ], +}; + +export function clearProviderCompatibilityCacheForTests(): void { + remoteCompatibilityCache.clear(); +} + +function remoteCompatibilityMapUrl(): string { + return ( + process.env.T3_PROVIDER_COMPATIBILITY_MAP_URL?.trim() || DEFAULT_PROVIDER_COMPATIBILITY_MAP_URL + ); +} + +function normalizeRemoteDocument( + document: typeof RemoteCompatibilityDocument.Type, +): ProviderCompatibilityDocument { + return { + version: document.version, + policies: document.policies.map((policy) => ({ + t3CodeRange: policy.t3CodeRange, + driver: ProviderDriverKind.make(policy.driver), + recommendedRange: policy.recommendedRange, + ...(policy.recommendedVersion !== undefined + ? { recommendedVersion: policy.recommendedVersion } + : {}), + ranges: policy.ranges.map((range) => ({ + status: range.status, + range: range.range, + ...(range.label !== undefined ? { label: range.label } : {}), + })), + })), + }; +} + +function policyMatches(input: { + readonly policy: ProviderCompatibilityPolicy; + readonly driver: ProviderDriverKind; + readonly t3CodeVersion: string; +}): boolean { + return ( + input.policy.driver === input.driver && + satisfiesSemverRange(input.t3CodeVersion, input.policy.t3CodeRange) + ); +} + +function compatibilityPolicyForDriver(input: { + readonly document: ProviderCompatibilityDocument; + readonly driver: ProviderDriverKind; + readonly t3CodeVersion?: string; +}): ProviderCompatibilityPolicy | null { + const t3CodeVersion = input.t3CodeVersion ?? T3_CODE_VERSION; + return ( + input.document.policies.find((policy) => + policyMatches({ policy, driver: input.driver, t3CodeVersion }), + ) ?? null + ); +} + +function severityForStatus( + status: ServerProviderCompatibilityAdvisory["status"], +): ServerProviderCompatibilityAdvisory["severity"] { + switch (status) { + case "broken": + return "error"; + case "unsupported": + case "graceful": + return "warning"; + case "supported": + case "unknown": + return "info"; + } +} + +function messageForStatus(input: { + readonly status: ServerProviderCompatibilityAdvisory["status"]; + readonly currentVersion: string | null; + readonly recommendedRange: string | null; + readonly recommendedVersion: string | null; +}) { + const current = input.currentVersion ? ` ${input.currentVersion}` : ""; + const recommendedTarget = input.recommendedVersion ?? input.recommendedRange; + const recommended = recommendedTarget ? ` Use ${recommendedTarget}.` : ""; + switch (input.status) { + case "broken": + return `This provider harness version${current} is known to be incompatible with this T3 Code release.${recommended}`; + case "unsupported": + return `This provider harness version${current} is outside the compatibility range for this T3 Code release.${recommended}`; + case "graceful": + return `This provider harness version${current} should still work, but updating is recommended.${recommended}`; + case "unknown": + return `T3 Code could not determine whether this provider harness version is compatible.${recommended}`; + case "supported": + return null; + } +} + +function createProviderCompatibilityAdvisoryFromDocument(input: { + readonly document: ProviderCompatibilityDocument; + readonly driver: ProviderDriverKind; + readonly currentVersion: string | null; + readonly t3CodeVersion?: string; +}): ServerProviderCompatibilityAdvisory | undefined { + const policy = compatibilityPolicyForDriver({ + document: input.document, + driver: input.driver, + ...(input.t3CodeVersion ? { t3CodeVersion: input.t3CodeVersion } : {}), + }); + if (!policy) { + return undefined; + } + + const currentVersion = input.currentVersion; + const matchedRange = + currentVersion === null + ? undefined + : policy.ranges.find((range) => satisfiesSemverRange(currentVersion, range.range)); + const status = matchedRange?.status ?? (currentVersion === null ? "unknown" : "unsupported"); + const recommendedVersion = policy.recommendedVersion ?? null; + + return { + status, + severity: severityForStatus(status), + currentVersion: input.currentVersion, + message: messageForStatus({ + status, + currentVersion: input.currentVersion, + recommendedRange: policy.recommendedRange, + recommendedVersion, + }), + recommendedRange: policy.recommendedRange, + recommendedVersion, + ranges: [...policy.ranges], + }; +} + +export function createProviderCompatibilityAdvisory(input: { + readonly driver: ProviderDriverKind; + readonly currentVersion: string | null; + readonly document?: ProviderCompatibilityDocument; + readonly t3CodeVersion?: string; +}): ServerProviderCompatibilityAdvisory | undefined { + return createProviderCompatibilityAdvisoryFromDocument({ + document: input.document ?? BUNDLED_PROVIDER_COMPATIBILITY_DOCUMENT, + driver: input.driver, + currentVersion: input.currentVersion, + ...(input.t3CodeVersion ? { t3CodeVersion: input.t3CodeVersion } : {}), + }); +} + +function fetchRemoteCompatibilityDocument( + url: string, +): Effect.Effect { + return Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client + .execute( + HttpClientRequest.get(url).pipe( + HttpClientRequest.setHeader("accept", "application/json"), + HttpClientRequest.setHeader("user-agent", `t3code/${T3_CODE_VERSION}`), + ), + ) + .pipe(Effect.timeoutOption(REMOTE_COMPATIBILITY_TIMEOUT_MS)); + + if (Option.isNone(response)) { + return null; + } + + const httpResponse = response.value; + if (httpResponse.status < 200 || httpResponse.status >= 300) { + return null; + } + + const payload = yield* httpResponse.json.pipe( + Effect.flatMap(decodeRemoteCompatibilityDocument), + Effect.map(normalizeRemoteDocument), + Effect.catch(() => Effect.succeed(null)), + ); + return payload; + }).pipe( + Effect.tapError((cause) => + Effect.logWarning("provider compatibility map fetch failed", { + cause, + url, + }), + ), + Effect.catch(() => Effect.succeed(null)), + ); +} + +export const resolveRemoteProviderCompatibilityDocument = Effect.fn( + "resolveRemoteProviderCompatibilityDocument", +)(function* () { + const url = remoteCompatibilityMapUrl(); + const now = DateTime.toEpochMillis(yield* DateTime.now); + const cached = remoteCompatibilityCache.get(url); + if (cached && cached.expiresAt > now) { + return cached.document; + } + + const document = yield* fetchRemoteCompatibilityDocument(url); + remoteCompatibilityCache.set(url, { + expiresAt: now + REMOTE_COMPATIBILITY_CACHE_TTL_MS, + document, + }); + return document; +}); + +function applyCompatibilityAdvisory( + snapshot: Snapshot, + compatibilityAdvisory: ServerProviderCompatibilityAdvisory | undefined, +): Snapshot { + if (!compatibilityAdvisory) { + const { compatibilityAdvisory: _omit, ...snapshotWithoutAdvisory } = snapshot; + return snapshotWithoutAdvisory as Snapshot; + } + + const compatibilityMessage = + compatibilityAdvisory.severity !== "info" + ? (compatibilityAdvisory.message ?? undefined) + : undefined; + const status = + snapshot.enabled && compatibilityAdvisory.severity === "error" + ? "error" + : snapshot.enabled && + compatibilityAdvisory.severity === "warning" && + snapshot.status === "ready" + ? "warning" + : snapshot.status; + + return { + ...snapshot, + status, + ...(compatibilityMessage || snapshot.message + ? { message: compatibilityMessage ?? snapshot.message } + : {}), + compatibilityAdvisory, + } as Snapshot; +} + +export function applyBundledProviderCompatibilityAdvisory< + Snapshot extends ProviderCompatibilitySnapshot, +>(input: { + readonly snapshot: Snapshot; + readonly driver: ProviderDriverKind; + readonly currentVersion: string | null; +}): Snapshot { + return applyCompatibilityAdvisory( + input.snapshot, + createProviderCompatibilityAdvisory({ + driver: input.driver, + currentVersion: input.currentVersion, + }), + ); +} + +export const enrichProviderSnapshotWithCompatibilityAdvisory = Effect.fn( + "enrichProviderSnapshotWithCompatibilityAdvisory", +)(function* (snapshot: ServerProvider) { + const remoteDocument = yield* resolveRemoteProviderCompatibilityDocument(); + const remoteAdvisory = remoteDocument + ? createProviderCompatibilityAdvisory({ + driver: snapshot.driver, + currentVersion: snapshot.version, + document: remoteDocument, + }) + : undefined; + const advisory = + remoteAdvisory ?? + createProviderCompatibilityAdvisory({ + driver: snapshot.driver, + currentVersion: snapshot.version, + }); + + return applyCompatibilityAdvisory(snapshot, advisory); +}); diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index 449dca8fc5a..a3890b18ae2 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; -import { providerModelsFromSettings } from "./providerSnapshot.ts"; +import { buildServerProvider, providerModelsFromSettings } from "./providerSnapshot.ts"; const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [ @@ -42,3 +42,56 @@ describe("providerModelsFromSettings", () => { ]); }); }); + +describe("buildServerProvider", () => { + it("marks known incompatible provider harness versions as errors", () => { + const provider = buildServerProvider({ + driver: ProviderDriverKind.make("codex"), + presentation: { displayName: "Codex" }, + enabled: true, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + probe: { + installed: true, + version: "0.128.0", + status: "ready", + auth: { status: "authenticated" }, + }, + }); + + expect(provider.status).toBe("error"); + expect(provider.compatibilityAdvisory).toMatchObject({ + status: "broken", + severity: "error", + currentVersion: "0.128.0", + recommendedRange: ">=0.129.0", + recommendedVersion: "0.129.0", + }); + expect(provider.message).toContain("known to be incompatible"); + }); + + it("keeps known supported provider harness versions ready", () => { + const provider = buildServerProvider({ + driver: ProviderDriverKind.make("codex"), + presentation: { displayName: "Codex" }, + enabled: true, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + probe: { + installed: true, + version: "0.129.0", + status: "ready", + auth: { status: "authenticated" }, + }, + }); + + expect(provider.status).toBe("ready"); + expect(provider.compatibilityAdvisory).toMatchObject({ + status: "supported", + severity: "info", + currentVersion: "0.129.0", + recommendedVersion: "0.129.0", + }); + expect(provider.message).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index c40903e1b45..f6290df8fec 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -14,6 +14,7 @@ import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { isWindowsCommandNotFound } from "../processRunner.ts"; +import { applyBundledProviderCompatibilityAdvisory } from "./providerCompatibility.ts"; import { createProviderVersionAdvisory } from "./providerMaintenance.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; @@ -208,7 +209,7 @@ export function buildServerProvider(input: { checkedAt: input.checkedAt, }) : undefined; - return { + const snapshot: ServerProviderDraft = { displayName: input.presentation.displayName, ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}), ...(typeof input.presentation.showInteractionModeToggle === "boolean" @@ -226,6 +227,13 @@ export function buildServerProvider(input: { skills: [...(input.skills ?? [])], ...(versionAdvisory ? { versionAdvisory } : {}), }; + return input.driver + ? applyBundledProviderCompatibilityAdvisory({ + snapshot, + driver: input.driver, + currentVersion: input.probe.version, + }) + : snapshot; } export const collectStreamAsString = ( diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 9a00ccb75ae..67515ce65a3 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -1,6 +1,7 @@ "use client"; import { + AlertTriangleIcon, ArrowUpCircleIcon, ChevronDownIcon, CopyIcon, @@ -40,6 +41,7 @@ import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { getProviderVersionAdvisoryPresentation, PROVIDER_STATUS_STYLES, + getProviderCompatibilityAdvisoryPresentation, getProviderSummary, getProviderVersionLabel, type ProviderStatusKey, @@ -481,6 +483,9 @@ export function ProviderInstanceCard({ : null; const summary = rawSummary; const versionLabel = getProviderVersionLabel(liveProvider?.version); + const compatibilityAdvisory = getProviderCompatibilityAdvisoryPresentation( + liveProvider?.compatibilityAdvisory, + ); const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); const updateCommand = versionAdvisory?.updateCommand ?? null; const FallbackIconComponent = driverOption?.icon; @@ -677,6 +682,45 @@ export function ProviderInstanceCard({
{titleHeadNode} {versionCodeNode} + {compatibilityAdvisory ? ( + + + + + } + /> + +
+

+ {compatibilityAdvisory.title} +

+

+ {compatibilityAdvisory.detail} +

+
+
+
+ ) : null} {versionAdvisory ? ( { + it("hides supported compatibility advisories", () => { + expect( + getProviderCompatibilityAdvisoryPresentation({ + status: "supported", + severity: "info", + currentVersion: "0.129.0", + message: null, + recommendedRange: ">=0.129.0", + ranges: [], + }), + ).toBeNull(); + }); + + it("presents broken compatibility advisories strongly", () => { + expect( + getProviderCompatibilityAdvisoryPresentation({ + status: "broken", + severity: "error", + currentVersion: "0.128.0", + message: "Known incompatible.", + recommendedRange: ">=0.129.0", + ranges: [], + }), + ).toEqual({ + title: "Incompatible provider version", + detail: "Known incompatible.", + emphasis: "strong", + }); + }); +}); diff --git a/apps/web/src/components/settings/providerStatus.ts b/apps/web/src/components/settings/providerStatus.ts index 06622a761b7..915cb702e35 100644 --- a/apps/web/src/components/settings/providerStatus.ts +++ b/apps/web/src/components/settings/providerStatus.ts @@ -1,4 +1,8 @@ -import type { ServerProvider, ServerProviderVersionAdvisory } from "@t3tools/contracts"; +import type { + ServerProvider, + ServerProviderCompatibilityAdvisory, + ServerProviderVersionAdvisory, +} from "@t3tools/contracts"; /** * Visual treatment for each server-reported provider status. Centralized so @@ -115,3 +119,29 @@ export function getProviderVersionAdvisoryPresentation( emphasis: "normal" as const, }; } + +export function getProviderCompatibilityAdvisoryPresentation( + advisory: ServerProviderCompatibilityAdvisory | undefined, +): { + readonly title: string; + readonly detail: string; + readonly emphasis: "normal" | "strong"; +} | null { + if (!advisory || advisory.status === "supported") { + return null; + } + + const recommendedTarget = advisory.recommendedVersion ?? advisory.recommendedRange; + const recommended = recommendedTarget ? ` Recommended: ${recommendedTarget}.` : ""; + const fallback = + advisory.status === "unknown" + ? `Compatibility unknown.${recommended}` + : `This provider harness is outside the supported range.${recommended}`; + + return { + title: + advisory.status === "broken" ? "Incompatible provider version" : "Provider version warning", + detail: advisory.message ?? fallback, + emphasis: advisory.severity === "error" ? "strong" : "normal", + }; +} diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index a2ad0cbb383..d9aa5fa5d6b 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -71,4 +71,39 @@ describe("ServerProvider", () => { expect(parsed.continuation?.groupKey).toBe("codex:home:/Users/julius/.codex"); }); + + it("decodes provider compatibility advisories", () => { + const parsed = decodeServerProvider({ + instanceId: "codex", + driver: "codex", + enabled: true, + installed: true, + version: "0.128.0", + status: "error", + auth: { + status: "authenticated", + }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + compatibilityAdvisory: { + status: "broken", + severity: "error", + currentVersion: "0.128.0", + message: "Known incompatible.", + recommendedRange: ">=0.129.0", + recommendedVersion: "0.129.0", + ranges: [ + { + status: "supported", + range: ">=0.129.0", + label: "Known working", + }, + ], + }, + }); + + expect(parsed.compatibilityAdvisory?.status).toBe("broken"); + expect(parsed.compatibilityAdvisory?.recommendedVersion).toBe("0.129.0"); + expect(parsed.compatibilityAdvisory?.ranges).toHaveLength(1); + }); }); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 0081c00ac7e..93e96305622 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -134,6 +134,38 @@ export const ServerProviderVersionAdvisory = Schema.Struct({ }); export type ServerProviderVersionAdvisory = typeof ServerProviderVersionAdvisory.Type; +export const ServerProviderCompatibilityStatus = Schema.Literals([ + "unknown", + "supported", + "graceful", + "unsupported", + "broken", +]); +export type ServerProviderCompatibilityStatus = typeof ServerProviderCompatibilityStatus.Type; + +export const ServerProviderCompatibilitySeverity = Schema.Literals(["info", "warning", "error"]); +export type ServerProviderCompatibilitySeverity = typeof ServerProviderCompatibilitySeverity.Type; + +export const ServerProviderCompatibilityRange = Schema.Struct({ + status: ServerProviderCompatibilityStatus, + range: TrimmedNonEmptyString, + label: Schema.optional(TrimmedNonEmptyString), +}); +export type ServerProviderCompatibilityRange = typeof ServerProviderCompatibilityRange.Type; + +export const ServerProviderCompatibilityAdvisory = Schema.Struct({ + status: ServerProviderCompatibilityStatus, + severity: ServerProviderCompatibilitySeverity, + currentVersion: Schema.NullOr(TrimmedNonEmptyString), + message: Schema.NullOr(TrimmedNonEmptyString), + recommendedRange: Schema.NullOr(TrimmedNonEmptyString), + recommendedVersion: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), + ranges: Schema.Array(ServerProviderCompatibilityRange).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), +}); +export type ServerProviderCompatibilityAdvisory = typeof ServerProviderCompatibilityAdvisory.Type; + export const ServerProviderUpdateStatus = Schema.Literals([ "idle", "queued", @@ -187,6 +219,7 @@ export const ServerProvider = Schema.Struct({ ), skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), + compatibilityAdvisory: Schema.optionalKey(ServerProviderCompatibilityAdvisory), updateState: Schema.optionalKey(ServerProviderUpdateState), }); export type ServerProvider = typeof ServerProvider.Type; diff --git a/provider-compatibility.v1.json b/provider-compatibility.v1.json new file mode 100644 index 00000000000..ad51dcb2ad1 --- /dev/null +++ b/provider-compatibility.v1.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "policies": [ + { + "t3CodeRange": ">=0.0.0", + "driver": "codex", + "recommendedRange": ">=0.129.0", + "recommendedVersion": "0.129.0", + "ranges": [ + { + "status": "supported", + "range": ">=0.129.0", + "label": "Known working Codex app-server harness" + }, + { + "status": "broken", + "range": "<0.129.0", + "label": "Known incompatible Codex app-server harness" + } + ] + } + ] +}