@rynfar/meridian 1.70.0 → 1.71.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,11 +18,14 @@
18
18
  import * as Plugin from "@opencode-ai/plugin/promise/plugin"
19
19
  import { Model } from "@opencode-ai/schema/model"
20
20
  import type { CatalogDraft } from "@opencode-ai/plugin/promise/catalog"
21
+ import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
22
+ import { join } from "node:path"
21
23
  import {
22
24
  PRIORITY_ATTESTATION_HEADER,
23
25
  createPriorityAttestation,
24
26
  deleteHeader,
25
27
  getHeader,
28
+ meridianConfigDirectory,
26
29
  setHeader,
27
30
  type MutableHeaders,
28
31
  } from "./priority-attestation"
@@ -36,6 +39,25 @@ const PARENT_SESSION_ONE_SHOTS = new Set(["title", "summary"])
36
39
  const ATTACHED_COMPACTION_AGENT = "compaction"
37
40
  const MODEL_DISCOVERY_TIMEOUT_MS = 3_000
38
41
  const PROVIDER_READY_POLL_MS = 25
42
+
43
+ /**
44
+ * Catalog cache, for the cold-start gap (#1008).
45
+ *
46
+ * Discovery cannot begin until OpenCode has finished assembling the catalog, so
47
+ * the first request against a freshly started server used to see only
48
+ * OpenCode's built-in models.dev entries and rejected a Meridian-only variant
49
+ * with `provider.no-route`. Awaiting the catalog inside `setup` deadlocks the
50
+ * server, so the seed has to come from somewhere that is not the catalog: a
51
+ * plain file, read synchronously before the first transform runs.
52
+ *
53
+ * The entry records the base URL it was discovered from and is only applied to a
54
+ * provider still pointed at that URL, so repointing OpenCode at a different
55
+ * Meridian cannot apply another one's models.
56
+ */
57
+ const CATALOG_CACHE_FILE = "opencode-v2-catalog.json"
58
+ const CATALOG_CACHE_VERSION = 1
59
+ /** Bounds how stale a seed can be if discovery never succeeds again. */
60
+ const CATALOG_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1_000
39
61
  // The plugin tree does not import from src/, so this mirrors VALID_EFFORTS in
40
62
  // src/proxy/effort.ts. A test asserts the two stay identical. Filtering in this
41
63
  // order also gives every model its variants low -> max regardless of the order
@@ -152,6 +174,8 @@ export interface MeridianModel {
152
174
 
153
175
  export interface MeridianProviderModels {
154
176
  providerID: string
177
+ /** The URL these models came from, so a cached seed is never applied to another. */
178
+ baseURL?: string
155
179
  models: MeridianModel[]
156
180
  }
157
181
 
@@ -260,11 +284,17 @@ function isLegacyMeridianBaseURL(baseURL: unknown): boolean {
260
284
  }
261
285
  }
262
286
 
287
+ export interface MeridianCatalogLoad {
288
+ /** Providers whose base URL still looks like Meridian, reachable or not. */
289
+ readonly configured: string[]
290
+ readonly discovered: MeridianProviderModels[]
291
+ }
292
+
263
293
  export async function loadMeridianModels(
264
294
  catalog: MeridianCatalogClient,
265
295
  signal: AbortSignal,
266
296
  fetcher: ModelFetcher = globalThis.fetch,
267
- ): Promise<MeridianProviderModels[]> {
297
+ ): Promise<MeridianCatalogLoad> {
268
298
  const deadline = Date.now() + MODEL_DISCOVERY_TIMEOUT_MS
269
299
  while (!signal.aborted) {
270
300
  const providers = await Promise.all([...MERIDIAN_PROVIDERS].map(async (providerID) => {
@@ -281,14 +311,176 @@ export async function loadMeridianModels(
281
311
  if (configured.length > 0) {
282
312
  const discovered = await Promise.all(configured.map(async ({ providerID, baseURL }) => {
283
313
  const models = await fetchMeridianModels(baseURL, signal, fetcher)
284
- return models ? { providerID, models } : undefined
314
+ return models ? { providerID, baseURL: typeof baseURL === "string" ? baseURL : undefined, models } : undefined
285
315
  }))
286
- return discovered.flatMap(result => result ? [result] : [])
316
+ return {
317
+ configured: configured.map(provider => provider.providerID),
318
+ discovered: discovered.flatMap(result => result ? [result] : []),
319
+ }
287
320
  }
288
- if (Date.now() >= deadline) return []
321
+ if (Date.now() >= deadline) return { configured: [], discovered: [] }
289
322
  await new Promise<void>((resolve) => setTimeout(resolve, PROVIDER_READY_POLL_MS))
290
323
  }
291
- return []
324
+ return { configured: [], discovered: [] }
325
+ }
326
+
327
+ export interface CachedProviderCatalog {
328
+ readonly baseURL: string
329
+ readonly models: readonly MeridianModel[]
330
+ }
331
+
332
+ export function catalogCachePath(): string {
333
+ return join(meridianConfigDirectory(), CATALOG_CACHE_FILE)
334
+ }
335
+
336
+ /**
337
+ * Validate a cache document as strictly as a discovery response.
338
+ *
339
+ * A corrupt or hand-edited file must seed nothing rather than write junk into
340
+ * the catalog, so every failure returns an empty map.
341
+ */
342
+ export function parseCatalogCache(value: unknown, now: number): Map<string, CachedProviderCatalog> {
343
+ const entries = new Map<string, CachedProviderCatalog>()
344
+ if (!isRecord(value) || value.version !== CATALOG_CACHE_VERSION) return entries
345
+ if (!isRecord(value.providers)) return entries
346
+ for (const [providerID, entry] of Object.entries(value.providers)) {
347
+ if (!MERIDIAN_PROVIDERS.has(providerID) || !isRecord(entry)) continue
348
+ const { baseURL, fetchedAt } = entry
349
+ if (typeof baseURL !== "string" || meridianModelsURL(baseURL) === undefined) continue
350
+ if (typeof fetchedAt !== "number" || !Number.isSafeInteger(fetchedAt) || fetchedAt <= 0) continue
351
+ if (fetchedAt > now || now - fetchedAt > CATALOG_CACHE_TTL_MS) continue
352
+ const models = parseCachedModels(entry.models)
353
+ if (!models || models.length === 0) continue
354
+ entries.set(providerID, { baseURL, models })
355
+ }
356
+ return entries
357
+ }
358
+
359
+ function parseCachedModels(value: unknown): MeridianModel[] | undefined {
360
+ if (!Array.isArray(value)) return undefined
361
+ const models: MeridianModel[] = []
362
+ const ids = new Set<string>()
363
+ for (const item of value) {
364
+ if (!isRecord(item)) return undefined
365
+ const { id, name, contextWindow, efforts } = item
366
+ if (
367
+ typeof id !== "string"
368
+ || id.length === 0
369
+ || id.length > 256
370
+ || /[^\x21-\x7E]/.test(id)
371
+ || ids.has(id)
372
+ || typeof name !== "string"
373
+ || name.trim().length === 0
374
+ || name.length > 256
375
+ || typeof contextWindow !== "number"
376
+ || !Number.isSafeInteger(contextWindow)
377
+ || contextWindow <= 0
378
+ || !Array.isArray(efforts)
379
+ || efforts.some(effort => typeof effort !== "string" || !MERIDIAN_EFFORTS.includes(effort as never))
380
+ ) {
381
+ return undefined
382
+ }
383
+ ids.add(id)
384
+ models.push({ id, name, contextWindow, efforts: efforts as string[] })
385
+ }
386
+ return models
387
+ }
388
+
389
+ export function serializeCatalogCache(
390
+ discovered: readonly MeridianProviderModels[],
391
+ now: number,
392
+ ): string {
393
+ const providers: Record<string, unknown> = {}
394
+ for (const entry of discovered) {
395
+ if (typeof entry.baseURL !== "string" || entry.models.length === 0) continue
396
+ providers[entry.providerID] = { baseURL: entry.baseURL, fetchedAt: now, models: entry.models }
397
+ }
398
+ return `${JSON.stringify({ version: CATALOG_CACHE_VERSION, providers }, null, 2)}\n`
399
+ }
400
+
401
+ /**
402
+ * Drop the seed. Called when no Meridian-shaped provider is configured any
403
+ * more, so a later cold start cannot describe a provider this catalog no longer
404
+ * points at. Best effort, like the write.
405
+ */
406
+ export function removeCatalogCache(remove: (path: string) => void = path => rmSync(path, { force: true })): void {
407
+ try {
408
+ remove(catalogCachePath())
409
+ } catch {
410
+ // Ignored on purpose: a stale seed is corrected by the next discovery.
411
+ }
412
+ }
413
+
414
+ /** Read the seed. Any failure — missing, unreadable, corrupt — seeds nothing. */
415
+ export function readCatalogCache(
416
+ now: number,
417
+ read: (path: string) => string = path => readFileSync(path, "utf-8"),
418
+ ): Map<string, CachedProviderCatalog> {
419
+ try {
420
+ return parseCatalogCache(JSON.parse(read(catalogCachePath())), now)
421
+ } catch {
422
+ return new Map()
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Persist the seed for the next cold start. Best effort: a cache we cannot write
428
+ * costs a deferred catalog on the next start, which is the old behaviour, so it
429
+ * must never interrupt a working session.
430
+ */
431
+ export function writeCatalogCache(
432
+ discovered: readonly MeridianProviderModels[],
433
+ now: number,
434
+ write: (path: string, contents: string) => void = (path, contents) => {
435
+ // Rename onto the target so a concurrent reader never sees a partial file.
436
+ const temporary = `${path}.${process.pid}.tmp`
437
+ writeFileSync(temporary, contents, { encoding: "utf-8", mode: 0o600 })
438
+ renameSync(temporary, path)
439
+ },
440
+ ): void {
441
+ try {
442
+ const contents = serializeCatalogCache(discovered, now)
443
+ if (contents.includes('"providers": {}')) return
444
+ write(catalogCachePath(), contents)
445
+ } catch {
446
+ // Ignored on purpose: see above.
447
+ }
448
+ }
449
+
450
+ /**
451
+ * The only thing choosing a seed needs from the draft: whether a provider is
452
+ * still in the catalog. Narrower than `CatalogDraft` on purpose, so the decision
453
+ * stays unit-testable without standing up a whole branded provider record.
454
+ */
455
+ export interface CatalogProviderProbe {
456
+ readonly provider: { get(providerID: string): unknown }
457
+ }
458
+
459
+ /**
460
+ * Choose what the transform should write: live discovery when it has landed,
461
+ * otherwise the cached seed.
462
+ *
463
+ * The seed cannot be validated against the provider's configured URL here. A
464
+ * draft `Provider.Info` exposes only `id`, `name`, `activation`, `package`,
465
+ * `integrationID` and `headers` — verified on beta-18866, where the whole
466
+ * record contains no URL at all. So the seed is applied optimistically to any
467
+ * Meridian provider that still exists in the catalog, and `discoverModels`
468
+ * corrects or drops it once it can read the real URL.
469
+ */
470
+ export function resolveCatalogModels(
471
+ catalog: CatalogProviderProbe,
472
+ discovered: readonly MeridianProviderModels[],
473
+ cached: ReadonlyMap<string, CachedProviderCatalog>,
474
+ ): MeridianProviderModels[] {
475
+ if (discovered.length > 0) return [...discovered]
476
+ const seeded: MeridianProviderModels[] = []
477
+ for (const providerID of MERIDIAN_PROVIDERS) {
478
+ const entry = cached.get(providerID)
479
+ if (!entry) continue
480
+ if (!catalog.provider.get(providerID)) continue
481
+ seeded.push({ providerID, baseURL: entry.baseURL, models: [...entry.models] })
482
+ }
483
+ return seeded
292
484
  }
293
485
 
294
486
  /**
@@ -391,19 +583,41 @@ const MeridianV2Plugin = Plugin.define({
391
583
  const modelDiscoveryController = new AbortController()
392
584
  let discoveredModels: MeridianProviderModels[] = []
393
585
  let discoveryStarted = false
586
+ // Read synchronously, before the first transform can run. Awaiting the
587
+ // catalog here would deadlock the server, which is why the seed is a file
588
+ // and not a catalog read (#1008).
589
+ const cachedModels = readCatalogCache(Date.now())
394
590
 
395
591
  registered.push(await context.catalog.transform((catalog) => {
396
- for (const discovered of discoveredModels) {
397
- applyMeridianModels(catalog, discovered.providerID, discovered.models)
592
+ for (const entry of resolveCatalogModels(catalog, discoveredModels, cachedModels)) {
593
+ applyMeridianModels(catalog, entry.providerID, entry.models)
398
594
  }
399
595
  }))
400
596
 
401
597
  const discoverModels = async () => {
402
598
  if (discoveryStarted || modelDiscoveryController.signal.aborted) return false
403
- const models = await loadMeridianModels(context.catalog, modelDiscoveryController.signal).catch(() => [])
404
- if (models.length === 0 || modelDiscoveryController.signal.aborted) return false
599
+ const loaded = await loadMeridianModels(context.catalog, modelDiscoveryController.signal)
600
+ .catch((): MeridianCatalogLoad => ({ configured: [], discovered: [] }))
601
+ if (modelDiscoveryController.signal.aborted) return false
602
+ if (loaded.configured.length === 0) {
603
+ // Nothing here points at Meridian any more — the user repointed the
604
+ // provider. A seed from a previous run would describe a different
605
+ // endpoint, so drop it and rebuild the catalog without it.
606
+ if (cachedModels.size > 0) {
607
+ cachedModels.clear()
608
+ removeCatalogCache()
609
+ await context.catalog.reload()
610
+ }
611
+ return false
612
+ }
613
+ // Configured but unreachable: keep the seed. It is the last thing Meridian
614
+ // actually served, which beats OpenCode's models.dev entries.
615
+ if (loaded.discovered.length === 0) return false
405
616
  discoveryStarted = true
406
- discoveredModels = models
617
+ discoveredModels = loaded.discovered
618
+ // Seed the next cold start before the reload, so a crash mid-reload still
619
+ // leaves the catalog available to the following process.
620
+ writeCatalogCache(loaded.discovered, Date.now())
407
621
  await context.catalog.reload()
408
622
  return true
409
623
  }
@@ -32,10 +32,20 @@ export interface PriorityAttestationSignInput {
32
32
  readonly issuedAt: number
33
33
  }
34
34
 
35
- function configDirectory(): string {
35
+ /**
36
+ * Meridian's own configuration directory, as the plugin sees it.
37
+ *
38
+ * Exported because the V2 plugin also keeps its catalog cache here: the plugin
39
+ * tree must not import from src/, so this is the one place the resolution lives.
40
+ */
41
+ export function meridianConfigDirectory(): string {
36
42
  return process.env.MERIDIAN_CONFIG_DIR ?? join(homedir(), ".config", "meridian")
37
43
  }
38
44
 
45
+ function configDirectory(): string {
46
+ return meridianConfigDirectory()
47
+ }
48
+
39
49
  export function priorityAttestationKeyPath(): string {
40
50
  return join(configDirectory(), PRIORITY_ATTESTATION_KEY_FILE)
41
51
  }