authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-03 03:09:29-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 03:09:29-07:00
log1e051873094c0c75cd35fef89c90461c22cce76b
treeb86ad8f352a81632840f27439d28eb4bf1ee7b88
parent19c97ea56d30b3a2de31a060f8f47d6b7404b78f
signature Signed by PGP key B5690EEEBB952194

fix(antigravity): refresh the model manifest so older Gemini models fold as legacy (#9397)

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

5 files changed, 81 insertions(+), 3 deletions(-)

apps/server/src/provider/Drivers/AntigravityDriver.ts+4
......@@ -248,7 +248,11 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
248248 usesBrowser: antigravityAuthUsesBrowser(auth.authMethod),
249249 });
250250
251 // Kick the TTL-gated manifest refresh alongside the health check, as
252 // Codex and Claude do. Without it an environment that only runs
253 // Antigravity would keep classifying against a stale disk cache.
251254 const probe = Effect.gen(function* () {
255 yield* modelManifest.refreshInBackground;
252256 const processScope = yield* Scope.make();
253257 yield* Effect.addFinalizer((exit) => Scope.close(processScope, exit));
254258 return yield* authFlow
apps/server/src/provider/ModelManifest.test.ts+50
......@@ -2,7 +2,9 @@ import { assert, describe, it } from "@effect/vitest";
22import * as NodeServices from "@effect/platform-node/NodeServices";
33import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts";
44import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
56import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
68import * as TestClock from "effect/testing/TestClock";
79import { HttpClient, HttpClientResponse } from "effect/unstable/http";
810
......@@ -15,6 +17,8 @@ import {
1517 make,
1618 resolveProviderCatalog,
1719 type ModelManifestData,
20 manifestUpdatedAtMs,
21 encodeManifestCache,
1822} from "./ModelManifest.ts";
1923
2024/**
......@@ -186,8 +190,12 @@ describe("resolveProviderCatalog", () => {
186190 });
187191});
188192
193// Remote fixtures date after the bundle so a fetch still outranks it.
194const REMOTE_UPDATED_AT = "2099-01-01T00:00:00Z";
195
189196const REMOTE_MANIFEST: ModelManifestData = {
190197 version: 1,
198 updatedAt: REMOTE_UPDATED_AT,
191199 currentModels: {
192200 codex: ["remote-model"],
193201 claudeAgent: ["remote-agent-model"],
......@@ -196,6 +204,7 @@ const REMOTE_MANIFEST: ModelManifestData = {
196204
197205const REMOTE_CLAUDE_MANIFEST: ModelManifestData = {
198206 version: 1,
207 updatedAt: REMOTE_UPDATED_AT,
199208 currentModels: {},
200209 providers: {
201210 claudeAgent: {
......@@ -368,6 +377,47 @@ describe("ModelManifest service", () => {
368377 );
369378 });
370379
380 it.live("drops a disk cache of a manifest older than the bundled one", () =>
381 Effect.gen(function* () {
382 const fs = yield* FileSystem.FileSystem;
383 const path = yield* Path.Path;
384 const config = yield* ServerConfig.ServerConfig;
385 assert.isAbove(manifestUpdatedAtMs(BUNDLED_MODEL_MANIFEST), 0);
386 const cachePath = path.join(config.stateDir, "model-manifest.json");
387 // A cache of the manifest as it was before the release edited it. The
388 // fetch time is irrelevant: the remote may be unreachable now, so
389 // `current` must already prefer the bundle.
390 const { updatedAt: _undated, ...undatedManifest } = REMOTE_MANIFEST;
391 for (const stale of [
392 undatedManifest,
393 { ...REMOTE_MANIFEST, updatedAt: "2000-01-01T00:00:00Z" },
394 ]) {
395 yield* fs.writeFileString(
396 cachePath,
397 yield* encodeManifestCache({ fetchedAtMs: 0, manifest: stale }),
398 );
399 const service = yield* make;
400 assert.deepStrictEqual(yield* service.current, BUNDLED_MODEL_MANIFEST);
401 }
402
403 // A cache of a newer edit still outranks the bundle.
404 yield* fs.writeFileString(
405 cachePath,
406 yield* encodeManifestCache({ fetchedAtMs: 0, manifest: REMOTE_MANIFEST }),
407 );
408 const later = yield* make;
409 assert.deepStrictEqual(yield* later.current, REMOTE_MANIFEST);
410 }).pipe(
411 Effect.scoped,
412 Effect.provide(
413 serviceLayers({
414 prefix: "model-manifest-newer-bundle-test",
415 response: () => Response.json(REMOTE_MANIFEST),
416 }),
417 ),
418 ),
419 );
420
371421 it.live("does not fetch when provider update checks are disabled", () =>
372422 Effect.gen(function* () {
373423 let fetchCount = 0;
apps/server/src/provider/ModelManifest.ts+23-2
......@@ -82,6 +82,12 @@ const ManifestProviderCatalog = Schema.Struct({
8282 */
8383const ModelManifestEnvelopeSchema = Schema.Struct({
8484 version: Schema.Literal(1),
85 /**
86 * ISO date of the last edit. A release bundles its manifest, and a disk
87 * cache of an older edit must not outrank it. Optional so older remote
88 * files still decode; they count as older than any dated bundle.
89 */
90 updatedAt: Schema.optional(Schema.String),
8591 currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)),
8692 providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)),
8793});
......@@ -131,6 +137,13 @@ const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema);
131137export const BUNDLED_MODEL_MANIFEST: ModelManifestData =
132138 Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson);
133139
140/** Epoch millis of the manifest's `updatedAt`, or 0 when absent or unparsable. */
141export function manifestUpdatedAtMs(manifest: ModelManifestData): number {
142 if (manifest.updatedAt === undefined) return 0;
143 const parsed = Date.parse(manifest.updatedAt);
144 return Number.isNaN(parsed) ? 0 : parsed;
145}
146
134147/** Resolve provider-neutral model presentation and capability data. */
135148export function resolveProviderCatalog(
136149 manifest: ModelManifestData,
......@@ -186,7 +199,8 @@ const decodeManifestCache = Schema.decodeUnknownEffect(
186199 ManifestCacheFile as unknown as Schema.Codec<typeof ManifestCacheFile.Type>,
187200 ),
188201);
189const encodeManifestCache = Schema.encodeEffect(
202/** Exported for tests that seed the disk cache. */
203export const encodeManifestCache = Schema.encodeEffect(
190204 Schema.fromJsonString(
191205 ManifestCacheFile as unknown as Schema.Codec<typeof ManifestCacheFile.Type>,
192206 ),
......@@ -330,7 +344,14 @@ export const make = Effect.gen(function* () {
330344 );
331345 if (fromDisk === null) return;
332346 // The disk copy is the last-seen remote manifest, so it outranks the
333 // bundle even when stale: it is refreshed on the next successful fetch.
347 // bundle even when stale, unless the bundle's own edit date is newer
348 // than the cached manifest's. Then the release carries data the cache
349 // has not seen and the cache is dropped so the next refresh replaces
350 // it. Comparing edit dates, not fetch time, keeps this independent of
351 // when the cache was written relative to the release.
352 if (manifestUpdatedAtMs(BUNDLED_MODEL_MANIFEST) > manifestUpdatedAtMs(fromDisk.manifest)) {
353 return;
354 }
334355 manifest = fromDisk.manifest;
335356 fetchedAtMs = fromDisk.fetchedAtMs;
336357 }),
apps/server/src/provider/model-manifest.json+1
......@@ -1,5 +1,6 @@
11{
22 "version": 1,
3 "updatedAt": "2026-09-03T09:58:00Z",
34 "currentModels": {
45 "codex": [
56 "gpt-5.6-luna",
docs/internals/model-manifest.md+3-1
......@@ -3,7 +3,9 @@
33`apps/server/src/provider/model-manifest.json` is bundled for offline startup and fetched from
44`main` at runtime. A remote fetch replaces the in-memory and on-disk cache only after generic
55catalog references and provider-owned adapter data validate. A failed or invalid fetch keeps the
6last successful remote manifest. The bundle is used only when no valid remote cache exists.
6last successful remote manifest. The bundle is used when no valid remote cache exists, or when
7the bundle's `updatedAt` is later than the cached manifest's, so a release that edits the manifest
8takes effect before the next successful fetch. Bump `updatedAt` whenever you edit the file.
79
810The top-level provider catalog is generic: models contain presentation metadata, aliases, status,
911an optional badge, and a reusable capability profile. The profile and model `adapter` fields are