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...@@ -248,7 +248,11 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
248 usesBrowser: antigravityAuthUsesBrowser(auth.authMethod),248 usesBrowser: antigravityAuthUsesBrowser(auth.authMethod),
249 });249 });
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.
251 const probe = Effect.gen(function* () {254 const probe = Effect.gen(function* () {
255 yield* modelManifest.refreshInBackground;
252 const processScope = yield* Scope.make();256 const processScope = yield* Scope.make();
253 yield* Effect.addFinalizer((exit) => Scope.close(processScope, exit));257 yield* Effect.addFinalizer((exit) => Scope.close(processScope, exit));
254 return yield* authFlow258 return yield* authFlow
apps/server/src/provider/ModelManifest.test.ts+50
...@@ -2,7 +2,9 @@ import { assert, describe, it } from "@effect/vitest";...@@ -2,7 +2,9 @@ import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts";3import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
6import * as TestClock from "effect/testing/TestClock";8import * as TestClock from "effect/testing/TestClock";
7import { HttpClient, HttpClientResponse } from "effect/unstable/http";9import { HttpClient, HttpClientResponse } from "effect/unstable/http";
810
...@@ -15,6 +17,8 @@ import {...@@ -15,6 +17,8 @@ import {
15 make,17 make,
16 resolveProviderCatalog,18 resolveProviderCatalog,
17 type ModelManifestData,19 type ModelManifestData,
20 manifestUpdatedAtMs,
21 encodeManifestCache,
18} from "./ModelManifest.ts";22} from "./ModelManifest.ts";
1923
20/**24/**
...@@ -186,8 +190,12 @@ describe("resolveProviderCatalog", () => {...@@ -186,8 +190,12 @@ describe("resolveProviderCatalog", () => {
186 });190 });
187});191});
188192
193// Remote fixtures date after the bundle so a fetch still outranks it.
194const REMOTE_UPDATED_AT = "2099-01-01T00:00:00Z";
195
189const REMOTE_MANIFEST: ModelManifestData = {196const REMOTE_MANIFEST: ModelManifestData = {
190 version: 1,197 version: 1,
198 updatedAt: REMOTE_UPDATED_AT,
191 currentModels: {199 currentModels: {
192 codex: ["remote-model"],200 codex: ["remote-model"],
193 claudeAgent: ["remote-agent-model"],201 claudeAgent: ["remote-agent-model"],
...@@ -196,6 +204,7 @@ const REMOTE_MANIFEST: ModelManifestData = {...@@ -196,6 +204,7 @@ const REMOTE_MANIFEST: ModelManifestData = {
196204
197const REMOTE_CLAUDE_MANIFEST: ModelManifestData = {205const REMOTE_CLAUDE_MANIFEST: ModelManifestData = {
198 version: 1,206 version: 1,
207 updatedAt: REMOTE_UPDATED_AT,
199 currentModels: {},208 currentModels: {},
200 providers: {209 providers: {
201 claudeAgent: {210 claudeAgent: {
...@@ -368,6 +377,47 @@ describe("ModelManifest service", () => {...@@ -368,6 +377,47 @@ describe("ModelManifest service", () => {
368 );377 );
369 });378 });
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
371 it.live("does not fetch when provider update checks are disabled", () =>421 it.live("does not fetch when provider update checks are disabled", () =>
372 Effect.gen(function* () {422 Effect.gen(function* () {
373 let fetchCount = 0;423 let fetchCount = 0;
apps/server/src/provider/ModelManifest.ts+23-2
...@@ -82,6 +82,12 @@ const ManifestProviderCatalog = Schema.Struct({...@@ -82,6 +82,12 @@ const ManifestProviderCatalog = Schema.Struct({
82 */82 */
83const ModelManifestEnvelopeSchema = Schema.Struct({83const ModelManifestEnvelopeSchema = Schema.Struct({
84 version: Schema.Literal(1),84 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),
85 currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)),91 currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)),
86 providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)),92 providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)),
87});93});
...@@ -131,6 +137,13 @@ const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema);...@@ -131,6 +137,13 @@ const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema);
131export const BUNDLED_MODEL_MANIFEST: ModelManifestData =137export const BUNDLED_MODEL_MANIFEST: ModelManifestData =
132 Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson);138 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
134/** Resolve provider-neutral model presentation and capability data. */147/** Resolve provider-neutral model presentation and capability data. */
135export function resolveProviderCatalog(148export function resolveProviderCatalog(
136 manifest: ModelManifestData,149 manifest: ModelManifestData,
...@@ -186,7 +199,8 @@ const decodeManifestCache = Schema.decodeUnknownEffect(...@@ -186,7 +199,8 @@ const decodeManifestCache = Schema.decodeUnknownEffect(
186 ManifestCacheFile as unknown as Schema.Codec<typeof ManifestCacheFile.Type>,199 ManifestCacheFile as unknown as Schema.Codec<typeof ManifestCacheFile.Type>,
187 ),200 ),
188);201);
189const encodeManifestCache = Schema.encodeEffect(202/** Exported for tests that seed the disk cache. */
203export const encodeManifestCache = Schema.encodeEffect(
190 Schema.fromJsonString(204 Schema.fromJsonString(
191 ManifestCacheFile as unknown as Schema.Codec<typeof ManifestCacheFile.Type>,205 ManifestCacheFile as unknown as Schema.Codec<typeof ManifestCacheFile.Type>,
192 ),206 ),
...@@ -330,7 +344,14 @@ export const make = Effect.gen(function* () {...@@ -330,7 +344,14 @@ export const make = Effect.gen(function* () {
330 );344 );
331 if (fromDisk === null) return;345 if (fromDisk === null) return;
332 // The disk copy is the last-seen remote manifest, so it outranks the346 // 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 }
334 manifest = fromDisk.manifest;355 manifest = fromDisk.manifest;
335 fetchedAtMs = fromDisk.fetchedAtMs;356 fetchedAtMs = fromDisk.fetchedAtMs;
336 }),357 }),
apps/server/src/provider/model-manifest.json+1
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1{1{
2 "version": 1,2 "version": 1,
3 "updatedAt": "2026-09-03T09:58:00Z",
3 "currentModels": {4 "currentModels": {
4 "codex": [5 "codex": [
5 "gpt-5.6-luna",6 "gpt-5.6-luna",
docs/internals/model-manifest.md+3-1
...@@ -3,7 +3,9 @@...@@ -3,7 +3,9 @@
3`apps/server/src/provider/model-manifest.json` is bundled for offline startup and fetched from3`apps/server/src/provider/model-manifest.json` is bundled for offline startup and fetched from
4`main` at runtime. A remote fetch replaces the in-memory and on-disk cache only after generic4`main` at runtime. A remote fetch replaces the in-memory and on-disk cache only after generic
5catalog references and provider-owned adapter data validate. A failed or invalid fetch keeps the5catalog 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
8The top-level provider catalog is generic: models contain presentation metadata, aliases, status,10The top-level provider catalog is generic: models contain presentation metadata, aliases, status,
9an optional badge, and a reusable capability profile. The profile and model `adapter` fields are11an optional badge, and a reusable capability profile. The profile and model `adapter` fields are