authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-25 19:32:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-25 19:32:19-07:00
logbadae6a5cc8325dcd5a145bea6f7b8ac692818a1
tree993d911666ab0532319e1a8d6ff8be971fe9d326
parent504177797676048bf70f64ce56c21949d0b8a018
signature Signed by PGP key B5690EEEBB952194

feat(server): fetch legacy model classification from a hosted manifest (#8227)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

14 files changed, 503 insertions(+), 76 deletions(-)

apps/server/src/provider/Drivers/ClaudeDriver.ts+27-8
......@@ -36,6 +36,7 @@ import {
3636} from "../Layers/ClaudeProvider.ts";
3737import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
3838import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
39import * as ModelManifest from "../ModelManifest.ts";
3940import {
4041 defaultProviderContinuationIdentity,
4142 type ProviderDriver,
......@@ -87,6 +88,7 @@ export type ClaudeDriverEnv =
8788 | Crypto.Crypto
8889 | FileSystem.FileSystem
8990 | HttpClient.HttpClient
91 | ModelManifest.ModelManifest
9092 | Path.Path
9193 | ProviderEventLoggers
9294 | ServerConfig
......@@ -125,6 +127,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
125127 const httpClient = yield* HttpClient.HttpClient;
126128 const serverSettings = yield* ServerSettingsService;
127129 const eventLoggers = yield* ProviderEventLoggers;
130 const modelManifest = yield* ModelManifest.ModelManifest;
128131 const processEnv = mergeProviderInstanceEnvironment(environment);
129132 const fallbackContinuationIdentity = defaultProviderContinuationIdentity({
130133 driverKind: DRIVER_KIND,
......@@ -163,13 +166,24 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
163166 });
164167 const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd);
165168
166 const checkProvider = checkClaudeProviderStatus(
167 effectiveConfig,
168 () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey),
169 processEnv,
170 cwd,
171 ).pipe(
172 Effect.map(stampIdentity),
169 // Kick the TTL-gated manifest refresh in the background and classify
170 // with the in-memory manifest, so a slow or hung fetch never delays the
171 // provider check. A refresh that lands mid-probe applies on the next one.
172 const checkProvider = modelManifest.refreshInBackground.pipe(
173 Effect.andThen(
174 Effect.zipWith(
175 checkClaudeProviderStatus(
176 effectiveConfig,
177 () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey),
178 processEnv,
179 cwd,
180 ),
181 modelManifest.current,
182 (draft, manifest) =>
183 stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)),
184 { concurrent: true },
185 ),
186 ),
173187 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
174188 Effect.provideService(FileSystem.FileSystem, fileSystem),
175189 Effect.provideService(Path.Path, path),
......@@ -182,7 +196,12 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
182196 streamSettings: snapshotSettings.streamSettings,
183197 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
184198 initialSnapshot: (settings) =>
185 makePendingClaudeProvider(settings.provider).pipe(Effect.map(stampIdentity)),
199 Effect.zipWith(
200 makePendingClaudeProvider(settings.provider),
201 modelManifest.current,
202 (draft, manifest) =>
203 stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)),
204 ),
186205 checkProvider,
187206 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>
188207 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {
apps/server/src/provider/Drivers/CodexDriver.ts+22-3
......@@ -39,6 +39,7 @@ import { makeCodexAdapter } from "../Layers/CodexAdapter.ts";
3939import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts";
4040import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
4141import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
42import * as ModelManifest from "../ModelManifest.ts";
4243import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts";
4344import type { ServerProviderDraft } from "../providerSnapshot.ts";
4445import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
......@@ -78,6 +79,7 @@ export type CodexDriverEnv =
7879 | Crypto.Crypto
7980 | FileSystem.FileSystem
8081 | HttpClient.HttpClient
82 | ModelManifest.ModelManifest
8183 | Path.Path
8284 | ProviderEventLoggers
8385 | ServerConfig
......@@ -119,6 +121,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
119121 const httpClient = yield* HttpClient.HttpClient;
120122 const serverSettings = yield* ServerSettingsService;
121123 const eventLoggers = yield* ProviderEventLoggers;
124 const modelManifest = yield* ModelManifest.ModelManifest;
122125 const processEnv = mergeProviderInstanceEnvironment(environment);
123126 const homeLayout = yield* resolveCodexHomeLayout(config);
124127 const continuationIdentity = codexContinuationIdentity(homeLayout);
......@@ -166,8 +169,19 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
166169 // in as instance rebuilds from the registry rather than in-place
167170 // updates. Pre-provide `ChildProcessSpawner` so the check fits
168171 // `makeManagedServerProvider.checkProvider`'s `R = never`.
169 const checkProvider = checkCodexProviderStatus(effectiveConfig, undefined, processEnv).pipe(
170 Effect.map(stampIdentity),
172 // Kick the TTL-gated manifest refresh in the background and classify
173 // with the in-memory manifest, so a slow or hung fetch never delays the
174 // provider check. A refresh that lands mid-probe applies on the next one.
175 const checkProvider = modelManifest.refreshInBackground.pipe(
176 Effect.andThen(
177 Effect.zipWith(
178 checkCodexProviderStatus(effectiveConfig, undefined, processEnv),
179 modelManifest.current,
180 (draft, manifest) =>
181 stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)),
182 { concurrent: true },
183 ),
184 ),
171185 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
172186 );
173187 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
......@@ -177,7 +191,12 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
177191 streamSettings: snapshotSettings.streamSettings,
178192 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
179193 initialSnapshot: (settings) =>
180 makePendingCodexProvider(settings.provider).pipe(Effect.map(stampIdentity)),
194 Effect.zipWith(
195 makePendingCodexProvider(settings.provider),
196 modelManifest.current,
197 (draft, manifest) =>
198 stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)),
199 ),
181200 checkProvider,
182201 enrichSnapshot: ({ settings, snapshot, publishSnapshot }) =>
183202 enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {
apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts-16
......@@ -9,27 +9,11 @@ import * as Schema from "effect/Schema";
99import {
1010 buildClaudeCapabilitiesProbeQueryOptions,
1111 CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES,
12 isLegacyClaudeModel,
1312 probeClaudeCapabilities,
1413} from "./ClaudeProvider.ts";
1514
1615const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
1716
18it("keeps only the Claude 5 family out of legacy models", () => {
19 assert.deepStrictEqual(
20 ["claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8"].map((model) => [
21 model,
22 isLegacyClaudeModel(model),
23 ]),
24 [
25 ["claude-fable-5", false],
26 ["claude-opus-5", false],
27 ["claude-sonnet-5", false],
28 ["claude-opus-4-8", true],
29 ],
30 );
31});
32
3317it("isolates Claude capability probes without dropping workspace setting sources", () => {
3418 const abortController = new AbortController();
3519 const options = buildClaudeCapabilitiesProbeQueryOptions({
apps/server/src/provider/Layers/ClaudeProvider.ts+3-9
......@@ -56,12 +56,6 @@ const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169";
5656const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154";
5757const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111";
5858
59const CURRENT_CLAUDE_MODELS = new Set(["claude-fable-5", "claude-opus-5", "claude-sonnet-5"]);
60
61export function isLegacyClaudeModel(model: string): boolean {
62 return !CURRENT_CLAUDE_MODELS.has(model);
63}
64
6559const CLAUDE_MODEL_CATALOG: ReadonlyArray<ServerProviderModel> = [
6660 {
6761 slug: "claude-fable-5",
......@@ -327,9 +321,9 @@ const CLAUDE_MODEL_CATALOG: ReadonlyArray<ServerProviderModel> = [
327321 },
328322];
329323
330const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = CLAUDE_MODEL_CATALOG.map((model) =>
331 isLegacyClaudeModel(model.slug) ? { ...model, isLegacy: true } : model,
332);
324// Legacy classification happens at the driver boundary via `applyModelManifest`,
325// so the catalog itself carries no `isLegacy` flags.
326const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = CLAUDE_MODEL_CATALOG;
333327
334328function supportsClaudeOpus5(version: string | null | undefined): boolean {
335329 return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false;
apps/server/src/provider/Layers/CodexProvider.test.ts+1-26
......@@ -1,31 +1,6 @@
11import { assert, it } from "@effect/vitest";
22
3import {
4 applyPreferredCodexDefaultModel,
5 isLegacyCodexModel,
6 mapCodexModelCapabilities,
7} from "./CodexProvider.ts";
8
9it("keeps current Codex models out of legacy models", () => {
10 assert.deepStrictEqual(
11 [
12 "gpt-5.6-luna",
13 "gpt-5.6-terra",
14 "gpt-5.6-sol",
15 "gpt-daybreak-blue-latest",
16 "gpt-daybreak-red-latest",
17 "gpt-5.4",
18 ].map((model) => [model, isLegacyCodexModel(model)]),
19 [
20 ["gpt-5.6-luna", false],
21 ["gpt-5.6-terra", false],
22 ["gpt-5.6-sol", false],
23 ["gpt-daybreak-blue-latest", false],
24 ["gpt-daybreak-red-latest", false],
25 ["gpt-5.4", true],
26 ],
27 );
28});
3import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts";
294
305it("maps current Codex model capability fields", () => {
316 const capabilities = mapCodexModelCapabilities({
apps/server/src/provider/Layers/CodexProvider.ts-12
......@@ -62,17 +62,6 @@ const REASONING_EFFORT_LABELS: Readonly<Record<string, string>> = {
6262};
6363
6464const DEFAULT_SERVICE_TIER_ID = "default";
65const CURRENT_CODEX_MODELS = new Set([
66 "gpt-5.6-luna",
67 "gpt-5.6-terra",
68 "gpt-5.6-sol",
69 "gpt-daybreak-blue-latest",
70 "gpt-daybreak-red-latest",
71]);
72
73export function isLegacyCodexModel(model: string): boolean {
74 return !CURRENT_CODEX_MODELS.has(model);
75}
7665
7766function reasoningEffortLabel(reasoningEffort: string): string {
7867 return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort;
......@@ -201,7 +190,6 @@ function parseCodexModelListResponse(
201190 name: toDisplayName(model),
202191 isCustom: false,
203192 ...(model.isDefault ? { isDefault: true } : {}),
204 ...(isLegacyCodexModel(model.model) ? { isLegacy: true } : {}),
205193 capabilities: mapCodexModelCapabilities(model),
206194 }));
207195}
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+5-1
......@@ -41,6 +41,7 @@ import * as Stream from "effect/Stream";
4141import { HttpClient, HttpClientResponse } from "effect/unstable/http";
4242
4343import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
44import type { BuiltInDriversEnv } from "../builtInDrivers.ts";
4445import { ServerConfig } from "../../config.ts";
4546import { ServerSettingsService } from "../../serverSettings.ts";
4647import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts";
......@@ -48,6 +49,7 @@ import { CodexDriver } from "../Drivers/CodexDriver.ts";
4849import { CursorDriver } from "../Drivers/CursorDriver.ts";
4950import { GrokDriver } from "../Drivers/GrokDriver.ts";
5051import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts";
52import * as ModelManifest from "../ModelManifest.ts";
5153import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts";
5254import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts";
5355import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts";
......@@ -148,6 +150,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => {
148150 Layer.provideMerge(ServerSettingsService.layerTest()),
149151 Layer.provideMerge(TestHttpClientLive),
150152 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
153 Layer.provideMerge(ModelManifest.layerTest),
151154 );
152155
153156 it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () =>
......@@ -313,6 +316,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
313316 Layer.provideMerge(ServerSettingsService.layerTest()),
314317 Layer.provideMerge(TestHttpClientLive),
315318 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
319 Layer.provideMerge(ModelManifest.layerTest),
316320 );
317321
318322 it.live("boots one instance of every shipped driver from a single config map", () =>
......@@ -365,7 +369,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
365369 },
366370 };
367371
368 const { registry } = yield* makeProviderInstanceRegistry({
372 const { registry } = yield* makeProviderInstanceRegistry<BuiltInDriversEnv>({
369373 drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver],
370374 configMap,
371375 });
apps/server/src/provider/Layers/ProviderRegistry.test.ts+5
......@@ -34,6 +34,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
3434import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts";
3535import { checkClaudeProviderStatus } from "./ClaudeProvider.ts";
3636import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
37import * as ModelManifest from "../ModelManifest.ts";
3738import * as OpenCodeRuntime from "../opencodeRuntime.ts";
3839import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
3940import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts";
......@@ -1423,6 +1424,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
14231424 ProviderEventLoggers.NoOpProviderEventLoggers,
14241425 ),
14251426 ),
1427 Layer.provideMerge(ModelManifest.layerTest),
14261428 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
14271429 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
14281430 // NO spawner mock — `ChildProcessSpawner` is supplied by the
......@@ -1516,6 +1518,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
15161518 ProviderEventLoggers.NoOpProviderEventLoggers,
15171519 ),
15181520 ),
1521 Layer.provideMerge(ModelManifest.layerTest),
15191522 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
15201523 Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) =>
15211524 ChildProcessSpawner.make((command) => {
......@@ -1638,6 +1641,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
16381641 ProviderEventLoggers.NoOpProviderEventLoggers,
16391642 ),
16401643 ),
1644 Layer.provideMerge(ModelManifest.layerTest),
16411645 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
16421646 Layer.provideMerge(NodeServices.layer),
16431647 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
......@@ -1697,6 +1701,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
16971701 ProviderEventLoggers.NoOpProviderEventLoggers,
16981702 ),
16991703 ),
1704 Layer.provideMerge(ModelManifest.layerTest),
17001705 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
17011706 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
17021707 Layer.provideMerge(
apps/server/src/provider/ModelManifest.test.ts created+185
......@@ -0,0 +1,185 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { HttpClient, HttpClientResponse } from "effect/unstable/http";
7
8import * as ServerConfig from "../config.ts";
9import * as ServerSettings from "../serverSettings.ts";
10import {
11 BUNDLED_MODEL_MANIFEST,
12 classifyModels,
13 isLegacyModel,
14 make,
15 type ModelManifestData,
16} from "./ModelManifest.ts";
17
18const CODEX = ProviderDriverKind.make("codex");
19const CLAUDE = ProviderDriverKind.make("claudeAgent");
20const CURSOR = ProviderDriverKind.make("cursor");
21
22describe("isLegacyModel (bundled manifest)", () => {
23 it("keeps current Codex models out of legacy models", () => {
24 assert.deepStrictEqual(
25 [
26 "gpt-5.6-luna",
27 "gpt-5.6-terra",
28 "gpt-5.6-sol",
29 "gpt-daybreak-blue-latest",
30 "gpt-daybreak-red-latest",
31 "gpt-5.4",
32 ].map((model) => [model, isLegacyModel(BUNDLED_MODEL_MANIFEST, CODEX, model)]),
33 [
34 ["gpt-5.6-luna", false],
35 ["gpt-5.6-terra", false],
36 ["gpt-5.6-sol", false],
37 ["gpt-daybreak-blue-latest", false],
38 ["gpt-daybreak-red-latest", false],
39 ["gpt-5.4", true],
40 ],
41 );
42 });
43
44 it("keeps only the Claude 5 family out of legacy models", () => {
45 assert.deepStrictEqual(
46 ["claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8"].map((model) => [
47 model,
48 isLegacyModel(BUNDLED_MODEL_MANIFEST, CLAUDE, model),
49 ]),
50 [
51 ["claude-fable-5", false],
52 ["claude-opus-5", false],
53 ["claude-sonnet-5", false],
54 ["claude-opus-4-8", true],
55 ],
56 );
57 });
58
59 it("leaves driver kinds without a manifest entry unflagged", () => {
60 assert.isFalse(isLegacyModel(BUNDLED_MODEL_MANIFEST, CURSOR, "composer-1.5"));
61 });
62});
63
64const model = (overrides: Partial<ServerProviderModel>): ServerProviderModel => ({
65 slug: "gpt-test",
66 name: "GPT Test",
67 isCustom: false,
68 capabilities: null,
69 ...overrides,
70});
71
72describe("classifyModels", () => {
73 it("flags non-current models, clears stale flags, and skips custom models", () => {
74 const models = [
75 model({ slug: "gpt-5.6-sol" }),
76 // Stale flag from a previous classification pass must be cleared.
77 model({ slug: "gpt-5.6-luna", isLegacy: true }),
78 model({ slug: "gpt-5.4" }),
79 // Custom models are user-defined and never reclassified.
80 model({ slug: "my-own-model", isCustom: true }),
81 ];
82 assert.deepStrictEqual(
83 classifyModels(models, BUNDLED_MODEL_MANIFEST, CODEX).map((entry) => [
84 entry.slug,
85 entry.isLegacy ?? false,
86 ]),
87 [
88 ["gpt-5.6-sol", false],
89 ["gpt-5.6-luna", false],
90 ["gpt-5.4", true],
91 ["my-own-model", false],
92 ],
93 );
94 });
95});
96
97const REMOTE_MANIFEST: ModelManifestData = {
98 version: 1,
99 currentModels: {
100 codex: ["gpt-5.4"],
101 claudeAgent: ["claude-fable-5"],
102 },
103};
104
105const httpClientLayer = (handler: () => Response) =>
106 Layer.succeed(
107 HttpClient.HttpClient,
108 HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler()))),
109 );
110
111const serviceLayers = (input: {
112 readonly prefix: string;
113 readonly response: () => Response;
114 readonly settings?: Parameters<typeof ServerSettings.layerTest>[0];
115}) =>
116 ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe(
117 Layer.provideMerge(NodeServices.layer),
118 Layer.provideMerge(ServerSettings.layerTest(input.settings ?? {})),
119 Layer.provideMerge(httpClientLayer(input.response)),
120 );
121
122describe("ModelManifest service", () => {
123 it.live("prefers a fetched manifest over the bundle and caches it to disk", () =>
124 Effect.gen(function* () {
125 const service = yield* make;
126 const refreshed = yield* service.refresh;
127 assert.deepStrictEqual(refreshed, REMOTE_MANIFEST);
128 assert.isTrue(isLegacyModel(refreshed, CODEX, "gpt-5.6-sol"));
129 assert.isFalse(isLegacyModel(refreshed, CODEX, "gpt-5.4"));
130
131 // A fresh service instance sees the disk cache without another fetch:
132 // its HTTP layer is still stubbed, but `current` never fetches at all.
133 const rebooted = yield* make;
134 assert.deepStrictEqual(yield* rebooted.current, REMOTE_MANIFEST);
135 }).pipe(
136 Effect.scoped,
137 Effect.provide(
138 serviceLayers({
139 prefix: "model-manifest-fetch-test",
140 response: () => Response.json(REMOTE_MANIFEST),
141 }),
142 ),
143 ),
144 );
145
146 it.live("keeps the bundled manifest when the remote payload is malformed", () =>
147 Effect.gen(function* () {
148 const service = yield* make;
149 assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST);
150 }).pipe(
151 Effect.scoped,
152 Effect.provide(
153 serviceLayers({
154 prefix: "model-manifest-malformed-test",
155 response: () => Response.json({ version: 999, nonsense: true }),
156 }),
157 ),
158 ),
159 );
160
161 it.live("does not fetch when provider update checks are disabled", () =>
162 Effect.gen(function* () {
163 let fetchCount = 0;
164 const service = yield* make.pipe(
165 Effect.provide(
166 httpClientLayer(() => {
167 fetchCount += 1;
168 return Response.json(REMOTE_MANIFEST);
169 }),
170 ),
171 );
172 assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST);
173 assert.strictEqual(fetchCount, 0);
174 }).pipe(
175 Effect.scoped,
176 Effect.provide(
177 serviceLayers({
178 prefix: "model-manifest-optout-test",
179 response: () => Response.json(REMOTE_MANIFEST),
180 settings: { enableProviderUpdateChecks: false },
181 }),
182 ),
183 ),
184 );
185});
apps/server/src/provider/ModelManifest.ts created+221
......@@ -0,0 +1,221 @@
1/**
2 * ModelManifest — decides which provider models are current and which belong
3 * in the model picker's legacy section.
4 *
5 * The classification data (current slugs per driver kind) lives in
6 * `model-manifest.json` next to this file. The bundled copy ships with every
7 * release. At runtime the service refreshes it from the same file on `main`
8 * via raw.githubusercontent.com, so a new model can leave the legacy section
9 * with a commit to `main` instead of a release. Preference order is remote,
10 * then the on-disk copy of the last successful fetch, then the bundle. A
11 * failed fetch never fails a provider check.
12 *
13 * Drivers apply the manifest to snapshot drafts with `applyModelManifest`
14 * before publishing, so every path that produces models (pending, probe,
15 * error fallbacks) is classified the same way.
16 */
17import type { ProviderDriverKind, ServerProviderModel } from "@t3tools/contracts";
18import * as Clock from "effect/Clock";
19import * as Context from "effect/Context";
20import * as Effect from "effect/Effect";
21import * as FileSystem from "effect/FileSystem";
22import * as Layer from "effect/Layer";
23import * as Path from "effect/Path";
24import * as Schema from "effect/Schema";
25import * as Semaphore from "effect/Semaphore";
26import { HttpClient, HttpClientResponse } from "effect/unstable/http";
27
28import { ServerConfig } from "../config.ts";
29import * as ServerSettings from "../serverSettings.ts";
30import bundledManifestJson from "./model-manifest.json" with { type: "json" };
31import type { ServerProviderDraft } from "./providerSnapshot.ts";
32
33const MODEL_MANIFEST_URL =
34 "https://raw.githubusercontent.com/pingdotgg/t3code/main/apps/server/src/provider/model-manifest.json";
35
36/** How long a fetched manifest stays fresh before the next probe re-fetches. */
37const MANIFEST_TTL_MS = 60 * 60 * 1000;
38
39/** Minimum gap between fetch attempts after a failure, so an offline server
40 * does not pay a network timeout on every provider check. */
41const MANIFEST_RETRY_MS = 5 * 60 * 1000;
42
43const FETCH_TIMEOUT_MS = 10_000;
44
45/**
46 * `version` gates breaking schema changes: a build only accepts remote
47 * manifests whose version it understands, and keeps its bundled copy
48 * otherwise. `currentModels` is keyed by driver kind; kinds absent from the
49 * map have no legacy concept and their models are left unflagged.
50 */
51const ModelManifestSchema = Schema.Struct({
52 version: Schema.Literal(1),
53 currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)),
54});
55export type ModelManifestData = typeof ModelManifestSchema.Type;
56
57const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema);
58
59export const BUNDLED_MODEL_MANIFEST: ModelManifestData =
60 Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson);
61
62/** On-disk shape of the last successfully fetched manifest. */
63const ManifestCacheFile = Schema.Struct({
64 fetchedAtMs: Schema.Number,
65 manifest: ModelManifestSchema,
66});
67const decodeManifestCache = Schema.decodeUnknownEffect(
68 Schema.fromJsonString(
69 ManifestCacheFile as unknown as Schema.Codec<typeof ManifestCacheFile.Type>,
70 ),
71);
72const encodeManifestCache = Schema.encodeEffect(
73 Schema.fromJsonString(
74 ManifestCacheFile as unknown as Schema.Codec<typeof ManifestCacheFile.Type>,
75 ),
76);
77
78/** True when the manifest classifies `slug` as legacy for `driverKind`. */
79export function isLegacyModel(
80 manifest: ModelManifestData,
81 driverKind: ProviderDriverKind,
82 slug: string,
83): boolean {
84 const currentModels = manifest.currentModels[driverKind];
85 if (!currentModels) return false;
86 return !currentModels.includes(slug);
87}
88
89/**
90 * Reclassifies every built-in model on a snapshot draft against the manifest.
91 * Custom models are user-defined and never reclassified.
92 */
93export function applyModelManifest(
94 draft: ServerProviderDraft,
95 manifest: ModelManifestData,
96 driverKind: ProviderDriverKind,
97): ServerProviderDraft {
98 return { ...draft, models: classifyModels(draft.models, manifest, driverKind) };
99}
100
101/** Model-level half of `applyModelManifest`, exported for focused tests. */
102export function classifyModels(
103 models: ReadonlyArray<ServerProviderModel>,
104 manifest: ModelManifestData,
105 driverKind: ProviderDriverKind,
106): ReadonlyArray<ServerProviderModel> {
107 return models.map((model) => {
108 if (model.isCustom) return model;
109 if (isLegacyModel(manifest, driverKind, model.slug)) {
110 return model.isLegacy ? model : { ...model, isLegacy: true };
111 }
112 if (!model.isLegacy) return model;
113 const { isLegacy: _isLegacy, ...rest } = model;
114 return rest;
115 });
116}
117
118export class ModelManifest extends Context.Service<
119 ModelManifest,
120 {
121 /** Manifest already in memory (disk cache or bundle); never fetches.
122 * Snapshot classification reads this, so it never waits on the network. */
123 readonly current: Effect.Effect<ModelManifestData>;
124 /** Manifest after a TTL-gated remote refresh; never fails. */
125 readonly refresh: Effect.Effect<ModelManifestData>;
126 /** Forks `refresh` into the service's own scope. Drivers call this from
127 * provider checks: the fetch is process-shared state, so it must survive
128 * the teardown of whichever instance happened to trigger it. */
129 readonly refreshInBackground: Effect.Effect<void>;
130 }
131>()("t3/provider/ModelManifest") {}
132
133/** Constant service for tests and callers that only need the bundled data. */
134export const BundledOnlyModelManifest: ModelManifest["Service"] = {
135 current: Effect.succeed(BUNDLED_MODEL_MANIFEST),
136 refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST),
137 refreshInBackground: Effect.void,
138};
139
140export const layerTest = Layer.succeed(ModelManifest, BundledOnlyModelManifest);
141
142export const make = Effect.gen(function* () {
143 const fileSystem = yield* FileSystem.FileSystem;
144 const path = yield* Path.Path;
145 const config = yield* ServerConfig;
146 const settingsService = yield* ServerSettings.ServerSettingsService;
147 const httpClient = yield* HttpClient.HttpClient;
148 const serviceScope = yield* Effect.scope;
149
150 const cachePath = path.join(config.stateDir, "model-manifest.json");
151 let manifest = BUNDLED_MODEL_MANIFEST;
152 let fetchedAtMs: number | null = null;
153 let lastAttemptMs: number | null = null;
154 const refreshSemaphore = yield* Semaphore.make(1);
155
156 // `Effect.cached` makes concurrent first readers await the same disk load
157 // rather than racing a "loaded" flag. Only `refreshed` takes the fetch
158 // semaphore; `current` must never wait behind an in-flight network refresh.
159 const ensureDiskCacheLoaded = yield* Effect.cached(
160 Effect.gen(function* () {
161 const fromDisk = yield* fileSystem.readFileString(cachePath).pipe(
162 Effect.flatMap((raw) => decodeManifestCache(raw)),
163 Effect.catchCause(() => Effect.succeed(null)),
164 );
165 if (fromDisk === null) return;
166 // The disk copy is the last-seen remote manifest, so it outranks the
167 // bundle even when stale: it is refreshed on the next successful fetch.
168 manifest = fromDisk.manifest;
169 fetchedAtMs = fromDisk.fetchedAtMs;
170 }),
171 );
172
173 const refresh = Effect.fn("ModelManifest.refresh")(function* () {
174 yield* ensureDiskCacheLoaded;
175 const now = yield* Clock.currentTimeMillis;
176 // A timestamp in the future means the wall clock moved backwards (the
177 // disk cache crosses restarts, so monotonic time cannot cover it). Treat
178 // it as expired: the refetch rewrites both timestamps and self-heals.
179 const isWithin = (sinceMs: number | null, windowMs: number) =>
180 sinceMs !== null && now >= sinceMs && now - sinceMs < windowMs;
181 if (isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest;
182 if (isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest;
183
184 // The same switch that gates provider CLI update checks. It stops network
185 // fetches only: a manifest already cached on disk from an earlier fetch
186 // stays in effect, since the setting is about phoning home, not about
187 // discarding data the server already holds.
188 const settings = yield* settingsService.getSettings.pipe(
189 Effect.catchCause(() => Effect.succeed(null)),
190 );
191 if (settings !== null && !settings.enableProviderUpdateChecks) return manifest;
192
193 lastAttemptMs = now;
194 const fetched = yield* httpClient.get(MODEL_MANIFEST_URL).pipe(
195 Effect.flatMap(HttpClientResponse.filterStatusOk),
196 Effect.flatMap((response) => response.json),
197 Effect.flatMap((json) => decodeManifest(json)),
198 Effect.timeout(FETCH_TIMEOUT_MS),
199 Effect.catchCause(() => Effect.succeed(null)),
200 );
201 if (fetched === null) return manifest;
202
203 manifest = fetched;
204 fetchedAtMs = now;
205 yield* encodeManifestCache({ fetchedAtMs: now, manifest: fetched }).pipe(
206 Effect.flatMap((serialized) => fileSystem.writeFileString(cachePath, serialized)),
207 Effect.catchCause(() => Effect.void),
208 );
209 return manifest;
210 });
211
212 const guardedRefresh = refreshSemaphore.withPermits(1)(refresh());
213
214 return ModelManifest.of({
215 current: ensureDiskCacheLoaded.pipe(Effect.map(() => manifest)),
216 refresh: guardedRefresh,
217 refreshInBackground: Effect.forkIn(guardedRefresh, serviceScope).pipe(Effect.asVoid),
218 });
219});
220
221export const layer = Layer.effect(ModelManifest, make);
apps/server/src/provider/model-manifest.json created+13
......@@ -0,0 +1,13 @@
1{
2 "version": 1,
3 "currentModels": {
4 "codex": [
5 "gpt-5.6-luna",
6 "gpt-5.6-terra",
7 "gpt-5.6-sol",
8 "gpt-daybreak-blue-latest",
9 "gpt-daybreak-red-latest"
10 ],
11 "claudeAgent": ["claude-fable-5", "claude-opus-5", "claude-sonnet-5"]
12 }
13}
apps/server/src/server.ts+5-1
......@@ -32,6 +32,7 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
3232import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts";
3333import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts";
3434import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts";
35import * as ModelManifest from "./provider/ModelManifest.ts";
3536import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts";
3637import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts";
3738import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts";
......@@ -395,7 +396,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
395396 // `ProviderService` (canonical stream, written after event normalization).
396397 // Provided once at the runtime level so every consumer sees the same
397398 // logger instances.
398 Layer.provideMerge(ProviderEventLoggers.layer),
399 // `ModelManifest.layer` is the legacy-model classification data, refreshed
400 // from the repo's `model-manifest.json` on `main` and applied by the
401 // Codex/Claude drivers.
402 Layer.provideMerge(Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer)),
399403 // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old
400404 // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but
401405 // the rewritten registry reads snapshots off the instance registry and
docs/internals/glossary.md+4
......@@ -116,6 +116,10 @@ Controls how assistant text reaches the thread timeline. In [the contracts][1],
116116
117117A point-in-time view of state. The word is used in multiple layers, including orchestration, provider, and checkpointing. See [ProjectionSnapshotQuery.ts][10], [ProviderAdapter.ts][15], and [CheckpointStore.ts][19].
118118
119#### Model manifest
120
121The per-driver list of current model slugs that decides which models land in the model picker's legacy section. Bundled at `apps/server/src/provider/model-manifest.json` and refreshed at runtime from the same file on `main`, so classification updates ship as commits instead of releases. See the [provider architecture][16] model manifest section.
122
119123### Checkpointing
120124
121125Checkpointing captures workspace state over time so the app can diff turns and restore earlier points. The main pieces are [CheckpointStore.ts][19], [CheckpointDiffQuery.ts][20], and [CheckpointReactor.ts][6].
docs/internals/providers.md+12
......@@ -39,6 +39,18 @@ directory to route session and turn operations for a thread, so callers name a t
3939Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No
4040orchestration, contract, or client change is required for the common case.
4141
42## Model manifest
43
44The model picker's legacy section is driven by `apps/server/src/provider/model-manifest.json`, which
45lists the current (non-legacy) model slugs per driver kind. The `ModelManifest` service
46(`apps/server/src/provider/ModelManifest.ts`) refreshes that data from the same file on `main` via
47raw.githubusercontent.com, so moving a model in or out of the legacy section is a commit, not a
48release. Preference order is remote fetch, then the on-disk copy of the last successful fetch (in
49the state directory), then the bundled copy. Fetches are TTL-gated, run concurrently with provider
50probes, respect the `enableProviderUpdateChecks` setting, and never fail a provider check. The
51Codex and Claude drivers apply the classification to every snapshot with `applyModelManifest`;
52driver kinds absent from the manifest have no legacy concept.
53
4254## How provider work is requested
4355
4456Clients never call a provider directly. They dispatch orchestration commands over the RPC method