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