Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1067,7 +1067,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
retainedModels,
);
}).pipe(
Effect.provide(ProviderRegistryLive.pipe(Layer.provide(instanceRegistryLayer))),
Effect.provide(
ProviderRegistryLive.pipe(
Layer.provide(instanceRegistryLayer),
Layer.provide(ModelManifest.layerTest),
),
),
Effect.scoped,
);
}
Expand Down Expand Up @@ -1412,6 +1417,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
const runtimeServices = yield* Layer.build(
ProviderRegistryLive.pipe(
Layer.provideMerge(instanceRegistryLayer),
Layer.provideMerge(ModelManifest.layerTest),
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), {
prefix: "t3-provider-registry-background-refresh-",
Expand Down Expand Up @@ -1531,6 +1537,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
const runtimeServices = yield* Layer.build(
ProviderRegistryLive.pipe(
Layer.provideMerge(instanceRegistryLayer),
Layer.provideMerge(ModelManifest.layerTest),
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), {
prefix: "t3-provider-registry-workspace-snapshot-",
Expand Down Expand Up @@ -1727,6 +1734,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
const runtimeServices = yield* Layer.build(
ProviderRegistryLive.pipe(
Layer.provideMerge(instanceRegistryLayer),
Layer.provideMerge(ModelManifest.layerTest),
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), {
prefix: "t3-provider-registry-reconnect-refresh-",
Expand Down Expand Up @@ -1853,6 +1861,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
const runtimeServices = yield* Layer.build(
ProviderRegistryLive.pipe(
Layer.provideMerge(instanceRegistryLayer),
Layer.provideMerge(ModelManifest.layerTest),
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), {
prefix: "t3-provider-registry-merged-persist-",
Expand Down Expand Up @@ -1978,6 +1987,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
const runtimeServices = yield* Layer.build(
ProviderRegistryLive.pipe(
Layer.provideMerge(instanceRegistryLayer),
Layer.provideMerge(ModelManifest.layerTest),
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), {
prefix: "t3-provider-registry-opencode-authoritative-persist-",
Expand Down Expand Up @@ -2081,6 +2091,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
const runtimeServices = yield* Layer.build(
ProviderRegistryLive.pipe(
Layer.provideMerge(instanceRegistryLayer),
Layer.provideMerge(ModelManifest.layerTest),
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), {
prefix: "t3-provider-registry-refresh-failure-",
Expand Down Expand Up @@ -2193,6 +2204,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
const runtimeServices = yield* Layer.build(
ProviderRegistryLive.pipe(
Layer.provideMerge(instanceRegistryLayer),
Layer.provideMerge(ModelManifest.layerTest),
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), {
prefix: "t3-provider-registry-sync-failure-",
Expand Down
8 changes: 7 additions & 1 deletion apps/server/src/provider/Layers/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ import {
} from "../providerStatusCache.ts";
import type { ProviderInstance } from "../ProviderDriver.ts";
import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
import * as ModelManifest from "../ModelManifest.ts";
import { withProviderCompatibility } from "../providerCompatibility.ts";
import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts";

const loadProviders = (
Expand Down Expand Up @@ -281,6 +283,7 @@ export const ProviderRegistryLive = Layer.effect(
const config = yield* ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const modelManifest = yield* ModelManifest.ModelManifest;

// Aggregator PubSub — consumers (WS gateway, etc.) subscribe here for
// coalesced updates across every instance.
Expand Down Expand Up @@ -424,9 +427,12 @@ export const ProviderRegistryLive = Layer.effect(
readonly replace?: boolean;
},
) {
// Compatibility is judged here rather than in each driver so every
// provider gets it, and so it always matches the snapshot's version.
const { compatibility } = yield* modelManifest.current;
const nextProvidersWithUpdateState = yield* Effect.forEach(
nextProviders,
applyProviderUpdateState,
(provider) => applyProviderUpdateState(withProviderCompatibility(provider, compatibility)),
{
concurrency: "unbounded",
},
Expand Down
25 changes: 24 additions & 1 deletion apps/server/src/provider/ModelManifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,8 @@ describe("resolveProviderCatalog", () => {
});
});

// Remote fixtures date after the bundle so a fetch still outranks it.
// Remote fixtures date after the bundle so a fetch still outranks it, and
// carry an explicit empty `compatibility` so the bundled ranges stay out.
const REMOTE_UPDATED_AT = "2099-01-01T00:00:00Z";

const REMOTE_MANIFEST: ModelManifestData = {
Expand All @@ -228,12 +229,14 @@ const REMOTE_MANIFEST: ModelManifestData = {
codex: ["remote-model"],
claudeAgent: ["remote-agent-model"],
},
compatibility: {},
};

const REMOTE_CLAUDE_MANIFEST: ModelManifestData = {
version: 1,
updatedAt: REMOTE_UPDATED_AT,
currentModels: {},
compatibility: {},
providers: {
claudeAgent: {
profiles: {
Expand Down Expand Up @@ -523,6 +526,26 @@ describe("ModelManifest service", () => {
),
);

it.live("keeps the bundled compatibility ranges when the remote file has none", () =>
Effect.gen(function* () {
const service = yield* make;
const refreshed = yield* service.refresh;
assert.deepStrictEqual(refreshed.currentModels, REMOTE_MANIFEST.currentModels);
assert.deepStrictEqual(refreshed.compatibility, BUNDLED_MODEL_MANIFEST.compatibility);
}).pipe(
Effect.scoped,
Effect.provide(
serviceLayers({
prefix: "model-manifest-compatibility-fallback-test",
response: () => {
const { compatibility: _omitted, ...withoutCompatibility } = REMOTE_MANIFEST;
return Response.json(withoutCompatibility);
},
}),
),
),
);

it.live("does not fetch when provider update checks are disabled", () =>
Effect.gen(function* () {
let fetchCount = 0;
Expand Down
21 changes: 19 additions & 2 deletions apps/server/src/provider/ModelManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
* successful on-disk copy, then the bundle. A failed fetch never fails a
* provider check.
*
* The same file carries provider version compatibility ranges, so a provider
* release that breaks T3 Code can be flagged without a T3 Code release.
*
* Providers with authoritative discovery can use only the classification
* overlay. Providers with static catalogs can resolve presentation and
* capabilities from `providers`, then decode their own allowlisted adapter
Expand All @@ -34,6 +37,7 @@ import { ServerConfig } from "../config.ts";
import * as ServerSettings from "../serverSettings.ts";
import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts";
import bundledManifestJson from "./model-manifest.json" with { type: "json" };
import { ManifestCompatibility } from "./providerCompatibility.ts";
import type { ServerProviderDraft } from "./providerSnapshot.ts";

const MODEL_MANIFEST_URL =
Expand Down Expand Up @@ -91,6 +95,8 @@ const ModelManifestEnvelopeSchema = Schema.Struct({
updatedAt: Schema.optional(Schema.String),
currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)),
providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)),
/** Installed provider version ranges per driver; see `providerCompatibility.ts`. */
compatibility: Schema.optional(ManifestCompatibility),
});

const hasValidProviderCatalogReferences = (
Expand Down Expand Up @@ -138,6 +144,17 @@ const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema);
export const BUNDLED_MODEL_MANIFEST: ModelManifestData =
Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson);

/**
* A remote file without `compatibility` (one that predates the section, or was
* edited without it) keeps the bundled ranges rather than silently dropping
* them. The remote clears them with an explicit `{}`.
*/
function withBundledCompatibility(manifest: ModelManifestData): ModelManifestData {
return manifest.compatibility !== undefined || BUNDLED_MODEL_MANIFEST.compatibility === undefined
? manifest
: { ...manifest, compatibility: BUNDLED_MODEL_MANIFEST.compatibility };
}

/** Epoch millis of the manifest's `updatedAt`, or 0 when absent or unparsable. */
function manifestUpdatedAtMs(manifest: ModelManifestData): number {
if (manifest.updatedAt === undefined) return 0;
Expand Down Expand Up @@ -367,7 +384,7 @@ export const make = Effect.gen(function* () {
if (manifestUpdatedAtMs(BUNDLED_MODEL_MANIFEST) > manifestUpdatedAtMs(fromDisk.manifest)) {
return;
}
manifest = fromDisk.manifest;
manifest = withBundledCompatibility(fromDisk.manifest);
fetchedAtMs = fromDisk.fetchedAtMs;
}),
);
Expand Down Expand Up @@ -402,7 +419,7 @@ export const make = Effect.gen(function* () {
);
if (fetched === null) return manifest;

manifest = fetched;
manifest = withBundledCompatibility(fetched);
fetchedAtMs = now;
yield* encodeManifestCache({ fetchedAtMs: now, manifest: fetched }).pipe(
Effect.flatMap((serialized) => fileSystem.writeFileString(cachePath, serialized)),
Expand Down
19 changes: 19 additions & 0 deletions apps/server/src/provider/model-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,25 @@
"claudeAgent": ["claude-fable-5-1", "claude-opus-5-5", "claude-sonnet-5"],
"antigravity": ["gemini-3.8-flash-high", "gemini-3.8-flash-medium", "gemini-3.8-flash-low"]
},
"compatibility": {
"claudeAgent": [
{
"ranges": [
{ "range": ">=2.1.280", "status": "supported" },
{
"range": ">=2.1.111",
"status": "graceful",
"message": "This Claude Code version works, but newer Claude models stay hidden until you update."
},
{
"range": "<2.1.111",
"status": "unsupported",
"message": "This Claude Code version cannot run any current Claude model. Update Claude Code."
}
]
}
]
},
"providers": {
"claudeAgent": {
"defaults": {
Expand Down
142 changes: 142 additions & 0 deletions apps/server/src/provider/providerCompatibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { describe, expect, it } from "@effect/vitest";
import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
import * as Schema from "effect/Schema";

import bundledManifest from "./model-manifest.json" with { type: "json" };
import {
evaluateProviderCompatibility,
ManifestCompatibility,
withProviderCompatibility,
} from "./providerCompatibility.ts";

const decode = Schema.decodeUnknownSync(ManifestCompatibility);

const compatibility = decode({
codex: [
{
t3Code: ">=0.1.0",
ranges: [
{ range: "=0.200.0", status: "broken", message: "0.200.0 drops every turn." },
{ range: ">=0.141.0", status: "supported" },
{ range: "<0.141.0", status: "unsupported" },
],
recommendedVersion: "0.199.0",
},
{
ranges: [{ range: ">=0.100.0", status: "supported" }],
},
],
});

const evaluate = (version: string | null, t3CodeVersion = "0.1.0") =>
evaluateProviderCompatibility({ compatibility, driver: "codex", version, t3CodeVersion });

describe("evaluateProviderCompatibility", () => {
it("uses the first matching range, so a known-bad release beats a broad supported range", () => {
expect(evaluate("0.200.0")).toEqual({
status: "broken",
message: "0.200.0 drops every turn.",
recommendedVersion: "0.199.0",
});
expect(evaluate("0.201.0")).toEqual({
status: "supported",
message: null,
recommendedVersion: null,
});
});

it("falls back to a default message and never recommends the installed version", () => {
expect(evaluate("0.140.2")).toEqual({
status: "unsupported",
message: "This version is older than T3 Code supports. Update to avoid errors.",
recommendedVersion: "0.199.0",
});
expect(
evaluateProviderCompatibility({
compatibility: decode({
codex: [
{ ranges: [{ range: ">=1.0.0", status: "graceful" }], recommendedVersion: "1.0.0" },
],
}),
driver: "codex",
version: "1.0.0",
})?.recommendedVersion,
).toBeNull();
});

it("picks the policy for the running T3 Code version", () => {
// Older builds fall through to the unconstrained policy.
expect(evaluate("0.120.0", "0.0.9")?.status).toBe("supported");
expect(evaluate("0.120.0", "0.1.0")?.status).toBe("unsupported");
});

it("gives no verdict without a usable version, a policy, or a matching range", () => {
expect(evaluate(null)).toBeUndefined();
// Source builds report 0.0.0.
expect(evaluate("0.0.0")).toBeUndefined();
expect(evaluate("0.50.0", "0.0.9")).toBeUndefined();
expect(
evaluateProviderCompatibility({ compatibility, driver: "cursor", version: "1.0.0" }),
).toBeUndefined();
expect(
evaluateProviderCompatibility({
compatibility: undefined,
driver: "codex",
version: "1.0.0",
}),
).toBeUndefined();
});

it("rejects range syntax the matcher cannot evaluate", () => {
// `~` and `x` ranges would silently never match.
expect(() =>
decode({ codex: [{ ranges: [{ range: "~1.2.0", status: "supported" }] }] }),
).toThrow();
expect(() =>
decode({ codex: [{ ranges: [{ range: "1.x", status: "supported" }] }] }),
).toThrow();
expect(() =>
decode({
codex: [
{ ranges: [{ range: ">=1.0.0", status: "supported" }], recommendedVersion: "latest" },
],
}),
).toThrow();
});

it("classifies current releases with the bundled manifest", () => {
const bundled = decode(bundledManifest.compatibility);
const check = (driver: string, version: string) =>
evaluateProviderCompatibility({ compatibility: bundled, driver, version })?.status;
expect(check("claudeAgent", "2.1.280")).toBe("supported");
expect(check("claudeAgent", "2.1.257")).toBe("graceful");
expect(check("codex", "0.100.0")).toBeUndefined();
expect(check("claudeAgent", "2.1.100")).toBe("unsupported");
});
});

describe("withProviderCompatibility", () => {
const provider: ServerProvider = {
instanceId: ProviderInstanceId.make("codex"),
driver: ProviderDriverKind.make("codex"),
enabled: true,
installed: true,
version: "0.140.0",
status: "ready",
auth: { status: "authenticated" },
checkedAt: "2026-09-22T00:00:00.000Z",
models: [],
slashCommands: [],
skills: [],
};

it("replaces a stale verdict after the provider changes version", () => {
const apply = (next: ServerProvider) => withProviderCompatibility(next, compatibility, "0.1.0");
const outdated = apply(provider);
expect(outdated.compatibility?.status).toBe("unsupported");

expect(apply({ ...outdated, version: "0.200.0" }).compatibility?.status).toBe("broken");
expect(apply({ ...outdated, version: "0.201.0" })).not.toHaveProperty("compatibility");
expect(apply({ ...outdated, installed: false })).not.toHaveProperty("compatibility");
});
});
Loading
Loading