| 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,7 +248,11 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD |
| 248 | usesBrowser: antigravityAuthUsesBrowser(auth.authMethod), | 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 | 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* authFlow | 258 | 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"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts"; | 3 | import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts"; |
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Path from "effect/Path"; | ||
| 6 | import * as TestClock from "effect/testing/TestClock"; | 8 | import * as TestClock from "effect/testing/TestClock"; |
| 7 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | 9 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 8 | 10 | ||
| ... | @@ -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"; |
| 19 | 23 | ||
| 20 | /** | 24 | /** |
| ... | @@ -186,8 +190,12 @@ describe("resolveProviderCatalog", () => { | ... | @@ -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 | const REMOTE_MANIFEST: ModelManifestData = { | 196 | const 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 = { |
| 196 | 204 | ||
| 197 | const REMOTE_CLAUDE_MANIFEST: ModelManifestData = { | 205 | const 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 | }); |
| 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 | 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 | */ |
| 83 | const ModelManifestEnvelopeSchema = Schema.Struct({ | 83 | const 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); |
| 131 | export const BUNDLED_MODEL_MANIFEST: ModelManifestData = | 137 | export const BUNDLED_MODEL_MANIFEST: ModelManifestData = |
| 132 | Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson); | 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 | /** Resolve provider-neutral model presentation and capability data. */ | 147 | /** Resolve provider-neutral model presentation and capability data. */ |
| 135 | export function resolveProviderCatalog( | 148 | export 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 | ); |
| 189 | const encodeManifestCache = Schema.encodeEffect( | 202 | /** Exported for tests that seed the disk cache. */ |
| 203 | export 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 the | 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 | 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 from | 3 | `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 generic | 4 | `main` at runtime. A remote fetch replaces the in-memory and on-disk cache only after generic |
| 5 | catalog references and provider-owned adapter data validate. A failed or invalid fetch keeps the | 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 | The top-level provider catalog is generic: models contain presentation metadata, aliases, status, | 10 | The top-level provider catalog is generic: models contain presentation metadata, aliases, status, |
| 9 | an optional badge, and a reusable capability profile. The profile and model `adapter` fields are | 11 | an optional badge, and a reusable capability profile. The profile and model `adapter` fields are |