@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.
- package/README.md +1 -0
- package/dist/{cli-x1sv3bqf.js → cli-6c6dj69q.js} +309 -35
- package/dist/cli.js +1 -1
- package/dist/meridian/index.js +4 -1
- package/dist/meridian-v2/index.js +20500 -41
- package/dist/meridian-v2.js +20505 -45
- package/dist/proxy/adapters/detect.d.ts.map +1 -1
- package/dist/proxy/adapters/polytoken.d.ts +36 -0
- package/dist/proxy/adapters/polytoken.d.ts.map +1 -0
- 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/sdkFeatures.d.ts.map +1 -1
- package/dist/proxy/server.d.ts.map +1 -1
- package/dist/proxy/transforms/polytoken.d.ts +18 -0
- package/dist/proxy/transforms/polytoken.d.ts.map +1 -0
- package/dist/proxy/transforms/registry.d.ts.map +1 -1
- package/dist/server.js +1 -1
- package/package.json +1 -1
- package/plugin/meridian-v2.ts +437 -0
- package/plugin/priority-attestation.ts +11 -1
package/plugin/meridian-v2.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|