@rynfar/meridian 1.69.0 → 1.71.0

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.
@@ -16,11 +16,16 @@
16
16
  */
17
17
 
18
18
  import * as Plugin from "@opencode-ai/plugin/promise/plugin"
19
+ import { Model } from "@opencode-ai/schema/model"
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"
19
23
  import {
20
24
  PRIORITY_ATTESTATION_HEADER,
21
25
  createPriorityAttestation,
22
26
  deleteHeader,
23
27
  getHeader,
28
+ meridianConfigDirectory,
24
29
  setHeader,
25
30
  type MutableHeaders,
26
31
  } from "./priority-attestation"
@@ -29,8 +34,37 @@ import {
29
34
  export const SUPPORTED_OPENCODE_V2_VERSION = "0.0.0-beta-18314"
30
35
 
31
36
  const MERIDIAN_PROVIDERS = new Set(["anthropic", "meridian"])
37
+ const MERIDIAN_CATALOG_PROVIDER = "meridian"
32
38
  const PARENT_SESSION_ONE_SHOTS = new Set(["title", "summary"])
33
39
  const ATTACHED_COMPACTION_AGENT = "compaction"
40
+ const MODEL_DISCOVERY_TIMEOUT_MS = 3_000
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
61
+ // The plugin tree does not import from src/, so this mirrors VALID_EFFORTS in
62
+ // src/proxy/effort.ts. A test asserts the two stay identical. Filtering in this
63
+ // order also gives every model its variants low -> max regardless of the order
64
+ // the proxy happens to serialise its capability object in.
65
+ const MERIDIAN_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const
66
+
67
+ export const MERIDIAN_V2_EFFORTS: readonly string[] = MERIDIAN_EFFORTS
34
68
 
35
69
  const SESSION_AFFINITY_HEADERS = [
36
70
  "x-opencode-session",
@@ -131,6 +165,356 @@ function isRecord(value: unknown): value is Record<string, unknown> {
131
165
  return typeof value === "object" && value !== null && !Array.isArray(value)
132
166
  }
133
167
 
168
+ export interface MeridianModel {
169
+ id: string
170
+ name: string
171
+ contextWindow: number
172
+ efforts: string[]
173
+ }
174
+
175
+ export interface MeridianProviderModels {
176
+ providerID: string
177
+ /** The URL these models came from, so a cached seed is never applied to another. */
178
+ baseURL?: string
179
+ models: MeridianModel[]
180
+ }
181
+
182
+ type MeridianCatalogClient = {
183
+ provider: {
184
+ get(input: { providerID: string }): Promise<unknown>
185
+ }
186
+ reload(): Promise<void>
187
+ }
188
+
189
+ type ModelFetcher = (input: string | URL | Request, init?: RequestInit) => Promise<Response>
190
+
191
+ export function meridianModelsURL(baseURL: unknown): string | undefined {
192
+ if (typeof baseURL !== "string" || baseURL.length === 0) return undefined
193
+ try {
194
+ const url = new URL(baseURL)
195
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined
196
+ url.search = ""
197
+ url.hash = ""
198
+ if (!url.pathname.endsWith("/")) url.pathname += "/"
199
+ // OpenCode's Anthropic provider carries the API version in the base URL
200
+ // (`http://127.0.0.1:3456/v1`), which is also the shape the V2 package gate
201
+ // configures. Appending `v1/models` there requests `/v1/v1/models`, a 404
202
+ // that silently disables discovery, so resolve `models` against an existing
203
+ // version segment instead.
204
+ const endpoint = /(?:^|\/)v1\/$/.test(url.pathname) ? "models" : "v1/models"
205
+ return new URL(endpoint, url).toString()
206
+ } catch {
207
+ return undefined
208
+ }
209
+ }
210
+
211
+ export function parseMeridianModels(value: unknown): MeridianModel[] | undefined {
212
+ if (!isRecord(value) || !Array.isArray(value.data)) return undefined
213
+
214
+ const models: MeridianModel[] = []
215
+ const ids = new Set<string>()
216
+ for (const item of value.data) {
217
+ if (!isRecord(item)) return undefined
218
+ const id = item.id
219
+ const name = item.display_name
220
+ const contextWindow = item.context_window
221
+ if (
222
+ typeof id !== "string"
223
+ || id.length === 0
224
+ || id.length > 256
225
+ || /[^\x21-\x7E]/.test(id)
226
+ || ids.has(id)
227
+ || typeof name !== "string"
228
+ || name.trim().length === 0
229
+ || name.length > 256
230
+ || typeof contextWindow !== "number"
231
+ || !Number.isSafeInteger(contextWindow)
232
+ || contextWindow <= 0
233
+ ) {
234
+ return undefined
235
+ }
236
+ const capabilities = isRecord(item.capabilities) ? item.capabilities : undefined
237
+ const effort = capabilities && isRecord(capabilities.effort) ? capabilities.effort : undefined
238
+ const efforts = MERIDIAN_EFFORTS.filter((level) => isRecord(effort?.[level]) && effort[level].supported === true)
239
+ ids.add(id)
240
+ models.push({ id, name, contextWindow, efforts })
241
+ }
242
+ return models
243
+ }
244
+
245
+ export async function fetchMeridianModels(
246
+ baseURL: unknown,
247
+ signal: AbortSignal,
248
+ fetcher: ModelFetcher = globalThis.fetch,
249
+ ): Promise<MeridianModel[] | undefined> {
250
+ const url = meridianModelsURL(baseURL)
251
+ if (!url || signal.aborted) return undefined
252
+
253
+ const controller = new AbortController()
254
+ const abort = () => controller.abort()
255
+ const timeout = setTimeout(abort, MODEL_DISCOVERY_TIMEOUT_MS)
256
+ timeout.unref?.()
257
+ signal.addEventListener("abort", abort, { once: true })
258
+ try {
259
+ const response = await fetcher(url, { signal: controller.signal })
260
+ if (!response.ok) return undefined
261
+ return parseMeridianModels(await response.json())
262
+ } catch {
263
+ return undefined
264
+ } finally {
265
+ clearTimeout(timeout)
266
+ signal.removeEventListener("abort", abort)
267
+ }
268
+ }
269
+
270
+ function meridianProviderBaseURL(value: unknown): unknown {
271
+ if (!isRecord(value)) return undefined
272
+ const provider = isRecord(value.data) ? value.data : value
273
+ if (!isRecord(provider.settings)) return undefined
274
+ return provider.settings.baseURL
275
+ }
276
+
277
+ function isLegacyMeridianBaseURL(baseURL: unknown): boolean {
278
+ if (typeof baseURL !== "string") return false
279
+ try {
280
+ const { hostname } = new URL(baseURL)
281
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"
282
+ } catch {
283
+ return false
284
+ }
285
+ }
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
+
293
+ export async function loadMeridianModels(
294
+ catalog: MeridianCatalogClient,
295
+ signal: AbortSignal,
296
+ fetcher: ModelFetcher = globalThis.fetch,
297
+ ): Promise<MeridianCatalogLoad> {
298
+ const deadline = Date.now() + MODEL_DISCOVERY_TIMEOUT_MS
299
+ while (!signal.aborted) {
300
+ const providers = await Promise.all([...MERIDIAN_PROVIDERS].map(async (providerID) => {
301
+ try {
302
+ const baseURL = meridianProviderBaseURL(await catalog.provider.get({ providerID }))
303
+ if (providerID === MERIDIAN_CATALOG_PROVIDER && meridianModelsURL(baseURL) === undefined) return undefined
304
+ if (providerID !== MERIDIAN_CATALOG_PROVIDER && !isLegacyMeridianBaseURL(baseURL)) return undefined
305
+ return { providerID, baseURL }
306
+ } catch {
307
+ return undefined
308
+ }
309
+ }))
310
+ const configured = providers.flatMap(provider => provider ? [provider] : [])
311
+ if (configured.length > 0) {
312
+ const discovered = await Promise.all(configured.map(async ({ providerID, baseURL }) => {
313
+ const models = await fetchMeridianModels(baseURL, signal, fetcher)
314
+ return models ? { providerID, baseURL: typeof baseURL === "string" ? baseURL : undefined, models } : undefined
315
+ }))
316
+ return {
317
+ configured: configured.map(provider => provider.providerID),
318
+ discovered: discovered.flatMap(result => result ? [result] : []),
319
+ }
320
+ }
321
+ if (Date.now() >= deadline) return { configured: [], discovered: [] }
322
+ await new Promise<void>((resolve) => setTimeout(resolve, PROVIDER_READY_POLL_MS))
323
+ }
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
484
+ }
485
+
486
+ /**
487
+ * Write Meridian's advertised models over the assembled V2 catalog.
488
+ *
489
+ * Meridian is authoritative for what it will actually serve: the context window
490
+ * follows the signed-in subscription (Sonnet stays 200k so a 1M turn is not
491
+ * billed as Extra Usage), and the effort levels are the ones the proxy accepts.
492
+ * OpenCode's built-in models.dev entries disagree — beta-18866 advertises a 1M
493
+ * Sonnet — so an entry that already exists must be corrected, not skipped.
494
+ *
495
+ * This does not overwrite user configuration. V2 layers `providers.<id>.models`
496
+ * on top of plugin transforms, verified live on beta-18866: a configured
497
+ * `claude-opus-5` override survived a transform that wrote a different name and
498
+ * context to the same model.
499
+ */
500
+ export function applyMeridianModels(
501
+ catalog: CatalogDraft,
502
+ providerID: string,
503
+ models: readonly MeridianModel[],
504
+ ): void {
505
+ for (const model of models) {
506
+ catalog.model.update(providerID, model.id, (entry) => {
507
+ entry.name = model.name
508
+ entry.limit.context = model.contextWindow
509
+ entry.variants = model.efforts.map((effort) => ({
510
+ id: Model.VariantID.make(effort),
511
+ headers: {},
512
+ body: { effort },
513
+ }))
514
+ })
515
+ }
516
+ }
517
+
134
518
  async function withTimeout<T>(pending: Promise<T>, timeoutMs: number): Promise<T | undefined> {
135
519
  let timer: ReturnType<typeof setTimeout> | undefined
136
520
  const timedOut = new Promise<undefined>((resolve) => {
@@ -196,6 +580,55 @@ const MeridianV2Plugin = Plugin.define({
196
580
  pending: Promise<ResolvedAgentMetadata>
197
581
  }>()
198
582
  const registered: Array<{ dispose: () => Promise<void> }> = []
583
+ const modelDiscoveryController = new AbortController()
584
+ let discoveredModels: MeridianProviderModels[] = []
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())
590
+
591
+ registered.push(await context.catalog.transform((catalog) => {
592
+ for (const entry of resolveCatalogModels(catalog, discoveredModels, cachedModels)) {
593
+ applyMeridianModels(catalog, entry.providerID, entry.models)
594
+ }
595
+ }))
596
+
597
+ const discoverModels = async () => {
598
+ if (discoveryStarted || 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
616
+ discoveryStarted = true
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())
621
+ await context.catalog.reload()
622
+ return true
623
+ }
624
+
625
+ const catalogEvents = context.event.subscribe({ signal: modelDiscoveryController.signal })
626
+ void (async () => {
627
+ for await (const event of catalogEvents) {
628
+ if (event.type !== "catalog.updated") continue
629
+ if (await discoverModels()) return
630
+ }
631
+ })().catch(() => {})
199
632
 
200
633
  const resolveAgentTraits = (agent: string, refresh = false): Promise<ResolvedAgentMetadata> => {
201
634
  const cached = traitsByAgent.get(agent)
@@ -320,11 +753,15 @@ const MeridianV2Plugin = Plugin.define({
320
753
  }, { providerID }))
321
754
  }
322
755
  } catch (error) {
756
+ // Setup never returns its cleanup on this path, so the discovery
757
+ // subscription and its poll loop would otherwise outlive the failure.
758
+ modelDiscoveryController.abort()
323
759
  await Promise.allSettled(registered.map(({ dispose }) => dispose()))
324
760
  throw error
325
761
  }
326
762
 
327
763
  return async () => {
764
+ modelDiscoveryController.abort()
328
765
  const results = await Promise.allSettled(registered.map(({ dispose }) => dispose()))
329
766
  const failures = results.flatMap(result => result.status === "rejected" ? [result.reason] : [])
330
767
  if (failures.length > 0) throw new AggregateError(failures, "Failed to dispose Meridian V2 hooks")
@@ -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
  }