@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.
- package/dist/{cli-zdbv40d5.js → cli-hxxy0m1z.js} +8 -5
- package/dist/{cli-198xnjcn.js → cli-ryt69ryf.js} +182 -17
- package/dist/cli.js +4 -4
- package/dist/meridian/index.js +4 -1
- package/dist/meridian-v2/index.js +126 -10
- package/dist/meridian-v2.js +133 -10
- package/dist/proxy/errors.d.ts +55 -0
- package/dist/proxy/errors.d.ts.map +1 -1
- package/dist/proxy/requestAbort.d.ts +36 -0
- package/dist/proxy/requestAbort.d.ts.map +1 -1
- package/dist/proxy/server.d.ts.map +1 -1
- package/dist/proxy/setup.d.ts +13 -0
- package/dist/proxy/setup.d.ts.map +1 -1
- package/dist/server.js +2 -2
- package/dist/{setup-b3ymd9z8.js → setup-ndmjpy23.js} +3 -1
- package/package.json +1 -1
- package/plugin/meridian-v2.ts +224 -10
- package/plugin/priority-attestation.ts +11 -1
package/plugin/meridian-v2.ts
CHANGED
|
@@ -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<
|
|
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
|
|
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
|
|
397
|
-
applyMeridianModels(catalog,
|
|
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
|
|
404
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|