dsh-model-capability-sync 0.0.0-stage → 0.1.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/src/match.ts ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Matching one configured gateway model id against the models.dev catalog.
3
+ *
4
+ * A gateway aggregates arbitrary upstreams and invents its own ids, so the
5
+ * match walks from strict to loose and reports which rule fired — the UI shows
6
+ * it, and an explicit alias always wins.
7
+ * @module dsh-model-capability-sync/match
8
+ */
9
+ import type { Catalog, CatalogEntry } from './catalog.ts'
10
+ import type { MatchInfo, MatchVia } from './types.ts'
11
+
12
+ /**
13
+ * Variant suffixes a gateway appends to one upstream model. Stripping them
14
+ * turns `gemini-3.8-flash-high` back into the catalog's `gemini-3.8-flash`.
15
+ */
16
+ const VARIANT_SUFFIX = /-(high|low|medium|minimal|max|xhigh|thinking|preview|latest|chat)$/
17
+
18
+ /**
19
+ * First-party vendor provider ids in the models.dev catalog. The same model
20
+ * appears under dozens of aggregators whose capacity numbers disagree, so a
21
+ * vendor entry outranks every reseller when both exist.
22
+ */
23
+ const FIRST_PARTY = new Set([
24
+ 'openai', 'anthropic', 'google', 'deepseek', 'xai', 'meta', 'mistral', 'cohere',
25
+ 'moonshotai', 'moonshotai-cn', 'zai', 'zhipuai', 'alibaba', 'alibaba-cn', 'qwen',
26
+ 'sensenova', 'minimax', 'stepfun', 'bytedance', 'baidu', 'nvidia', 'microsoft',
27
+ 'amazon', 'ai21', 'perplexity', 'upstage', '01-ai', 'inclusionai', 'tencent', 'kuaishou',
28
+ ])
29
+
30
+ /** How complete an entry's facts are; the most complete wins a tie. */
31
+ function score(entry: CatalogEntry): number {
32
+ const model = entry.model
33
+ let value = 0
34
+ if (FIRST_PARTY.has(entry.provider)) value += 5
35
+ if (model.limit?.context !== undefined || model.limit?.output !== undefined) value += 2
36
+ if (model.modalities?.input !== undefined) value += 2
37
+ if (model.reasoning_options !== undefined) value += 1
38
+ if (model.name !== undefined) value += 1
39
+ return value
40
+ }
41
+
42
+ /**
43
+ * Pick the best of several provider entries for the same id: the one carrying
44
+ * the most facts, then the alphabetically first provider so repeated runs are
45
+ * stable.
46
+ */
47
+ function pickBest(entries: readonly CatalogEntry[]): CatalogEntry | undefined {
48
+ let best: CatalogEntry | undefined
49
+ let bestScore = -1
50
+ for (const entry of entries) {
51
+ const value = score(entry)
52
+ if (value > bestScore || value === bestScore && best !== undefined && entry.provider < best.provider) {
53
+ best = entry
54
+ bestScore = value
55
+ }
56
+ }
57
+ return best
58
+ }
59
+
60
+ /** The id without its vendor path prefix (`qoder-gj/DeepSeek-Flash` → `DeepSeek-Flash`). */
61
+ function basename(id: string): string {
62
+ const slash = id.lastIndexOf('/')
63
+ return slash === -1 ? id : id.slice(slash + 1)
64
+ }
65
+
66
+ /** Look one key up exactly, then case-insensitively. */
67
+ function lookup(catalog: Catalog, key: string): { entries: readonly CatalogEntry[]; via: MatchVia } | undefined {
68
+ const exact = catalog.byId.get(key)
69
+ if (exact !== undefined) return { entries: exact, via: 'exact' }
70
+ const folded = catalog.byFolded.get(key.toLowerCase())
71
+ if (folded !== undefined) return { entries: folded, via: 'folded' }
72
+ return undefined
73
+ }
74
+
75
+ /** Build the public match record for one chosen entry. */
76
+ function describe(entry: CatalogEntry, via: MatchVia): MatchInfo {
77
+ return {
78
+ provider: entry.provider,
79
+ modelId: entry.id,
80
+ via,
81
+ firstParty: FIRST_PARTY.has(entry.provider),
82
+ ...entry.model.name === undefined ? {} : { name: entry.model.name },
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Resolve one gateway model id.
88
+ * @param catalog - the indexed catalog.
89
+ * @param id - the gateway model id exactly as configured.
90
+ * @param aliases - explicit gateway id → catalog id overrides.
91
+ * @returns the match, or `undefined` when nothing fits.
92
+ */
93
+ export function matchModel(
94
+ catalog: Catalog,
95
+ id: string,
96
+ aliases: Readonly<Record<string, string>> = {},
97
+ ): MatchInfo | undefined {
98
+ const alias = aliases[id]
99
+ if (alias !== undefined && alias.length > 0) {
100
+ const hit = lookup(catalog, alias)
101
+ const entry = hit === undefined ? undefined : pickBest(hit.entries)
102
+ if (entry !== undefined) return describe(entry, 'alias')
103
+ }
104
+
105
+ const exact = lookup(catalog, id)
106
+ if (exact !== undefined) {
107
+ const entry = pickBest(exact.entries)
108
+ if (entry !== undefined) return describe(entry, exact.via)
109
+ }
110
+
111
+ const base = basename(id)
112
+ if (base !== id) {
113
+ const hit = lookup(catalog, base)
114
+ if (hit !== undefined) {
115
+ const entry = pickBest(hit.entries)
116
+ if (entry !== undefined) return describe(entry, 'basename')
117
+ }
118
+ }
119
+
120
+ const stripped = base.replace(VARIANT_SUFFIX, '')
121
+ if (stripped !== base && stripped.length > 0) {
122
+ const hit = lookup(catalog, stripped)
123
+ if (hit !== undefined) {
124
+ const entry = pickBest(hit.entries)
125
+ if (entry !== undefined) return describe(entry, 'suffix')
126
+ }
127
+ }
128
+
129
+ return undefined
130
+ }
package/src/sort.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Ordering for one provider's model list.
3
+ *
4
+ * A gateway id carries its vendor as a path prefix (`qoder-gj/Qwen3.8-Flash`),
5
+ * so sorting on the whole id would group by nothing useful: the model family
6
+ * is the part after the slash, and the vendor is what a reader scans for. The
7
+ * order is therefore: vendor prefix first, then the model name inside it. An
8
+ * id without a prefix has only its name to sort on.
9
+ *
10
+ * Comparison is case-insensitive, with the raw id as the final tie-break, so
11
+ * two runs on the same configuration always produce the same array.
12
+ * @module dsh-model-capability-sync/sort
13
+ */
14
+
15
+ /** The two ordering keys of one id: the vendor group, then the model name. */
16
+ export interface ModelSortKey {
17
+ /** The prefix before the last slash, or the whole id when there is none. */
18
+ group: string
19
+ /** The part after the last slash, or the whole id when there is none. */
20
+ name: string
21
+ }
22
+
23
+ /**
24
+ * Split one id into its ordering keys.
25
+ * @param id - the gateway model id exactly as configured.
26
+ * @returns folded keys; the caller compares them, never displays them.
27
+ */
28
+ export function modelSortKey(id: string): ModelSortKey {
29
+ const slash = id.lastIndexOf('/')
30
+ if (slash === -1) {
31
+ const folded = id.toLowerCase()
32
+ return { group: folded, name: folded }
33
+ }
34
+ return { group: id.slice(0, slash).toLowerCase(), name: id.slice(slash + 1).toLowerCase() }
35
+ }
36
+
37
+ /**
38
+ * Compare two ids in display order.
39
+ * @param left - first id.
40
+ * @param right - second id.
41
+ * @returns negative when `left` sorts first, positive when `right` does.
42
+ */
43
+ export function compareModelIds(left: string, right: string): number {
44
+ const a = modelSortKey(left)
45
+ const b = modelSortKey(right)
46
+ if (a.group !== b.group) return a.group < b.group ? -1 : 1
47
+ if (a.name !== b.name) return a.name < b.name ? -1 : 1
48
+ // Same folded pair: keep the raw spelling as the tie-break so the result is
49
+ // deterministic instead of depending on the input order.
50
+ if (left === right) return 0
51
+ return left < right ? -1 : 1
52
+ }
53
+
54
+ /**
55
+ * Return a sorted copy of a model list.
56
+ * @param items - entries in any order; the input array is never mutated.
57
+ * @param idOf - reads the model id out of one entry.
58
+ * @returns the entries in vendor-then-name order.
59
+ */
60
+ export function sortByModelId<T>(items: readonly T[], idOf: (item: T) => string): T[] {
61
+ return [...items].sort((left, right) => compareModelIds(idOf(left), idOf(right)))
62
+ }
package/src/types.ts ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Shared vocabulary between the host half (catalog + matching) and the browser
3
+ * half (preview + write). Types only — nothing here runs.
4
+ * @module dsh-model-capability-sync/types
5
+ */
6
+
7
+ /** Input modalities the DeepSeek Harness LLM seam understands. */
8
+ export type Modality = 'text' | 'image'
9
+
10
+ /**
11
+ * The capability fields this plugin can fill into one provider model entry.
12
+ * The names are the provider's own field names (pi-ai uses these exact ones),
13
+ * so a filled entry is written verbatim into the provider configuration.
14
+ *
15
+ * `name` is deliberately absent: this plugin fills facts, it never renames a
16
+ * model. A label is the user's (or the gateway's) choice, and rewriting it
17
+ * would be a surprise no capability edit justifies.
18
+ */
19
+ export interface CapabilityFields {
20
+ /** Total request+response context capacity in tokens. */
21
+ contextWindow?: number
22
+ /** Per-request output cap in tokens. */
23
+ maxTokens?: number
24
+ /** Accepted input modalities, always including `text`. */
25
+ input?: Modality[]
26
+ /**
27
+ * Reasoning-effort map for providers that take a dict (`{ low: 'low' }`),
28
+ * or `false` when the catalog states the model cannot reason at all.
29
+ */
30
+ reasoningEfforts?: Record<string, string> | false
31
+ }
32
+
33
+ /** How a gateway id was matched against the catalog. */
34
+ export type MatchVia = 'alias' | 'exact' | 'folded' | 'basename' | 'suffix'
35
+
36
+ /** The catalog entry a gateway id resolved to. */
37
+ export interface MatchInfo {
38
+ /** models.dev provider id the entry lives under (e.g. `openai`). */
39
+ provider: string
40
+ /** models.dev model id inside that provider. */
41
+ modelId: string
42
+ /** Which rule produced this match. */
43
+ via: MatchVia
44
+ /** True when the entry belongs to the model's first-party vendor, not a reseller. */
45
+ firstParty?: boolean
46
+ /** Human name from the catalog, when present. */
47
+ name?: string
48
+ }
49
+
50
+ /** One preview row: a configured model and what the catalog says about it. */
51
+ export interface PreviewRow {
52
+ /** The gateway model id exactly as configured. */
53
+ id: string
54
+ match?: MatchInfo
55
+ fields?: CapabilityFields
56
+ /** Why no capability could be resolved, when `fields` is absent. */
57
+ reason?: string
58
+ }
59
+
60
+ /** Request payload of the `preview` RPC endpoint. */
61
+ export interface PreviewRequest {
62
+ /** Gateway model ids to resolve, in display order. */
63
+ ids: string[]
64
+ /** Explicit gateway id → models.dev model id overrides, applied first. */
65
+ aliases?: Record<string, string>
66
+ /** Forward-proxy URL for the catalog download. */
67
+ proxyUrl?: string
68
+ /** Bypass the in-memory catalog cache. */
69
+ refresh?: boolean
70
+ }
71
+
72
+ /** Response payload of the `preview` RPC endpoint. */
73
+ export interface PreviewResponse {
74
+ /** Epoch milliseconds of the catalog fetch backing this answer. */
75
+ fetchedAt: number
76
+ /** Number of models in the downloaded catalog. */
77
+ catalogModels: number
78
+ /** True when the catalog came from the local copy because the download failed. */
79
+ stale?: boolean
80
+ /** One row per requested id, in request order. */
81
+ rows: PreviewRow[]
82
+ }
83
+
84
+ /** Envelope every RPC endpoint answers with. */
85
+ export type RpcEnvelope<T> =
86
+ | { ok: true; value: T }
87
+ | { ok: false; error: { code: string; message: string; details?: Record<string, unknown> } }
88
+
89
+ /**
90
+ * What the harness resolves for one model id right now, read from the adapter
91
+ * that actually owns the route. This is the truth a chat request sees, which
92
+ * is not necessarily the configuration this plugin writes: two plugins may
93
+ * claim the same provider route, and only one of them serves it.
94
+ */
95
+ export interface LiveRow {
96
+ /** The model id as configured. */
97
+ id: string
98
+ /** Input modalities the owning adapter reports, when it reports any. */
99
+ input?: string[]
100
+ /** Context window the owning adapter reports. */
101
+ contextWindow?: number
102
+ /** Default output cap the owning adapter reports. */
103
+ maxTokens?: number
104
+ /** Why the live lookup failed, when it did. */
105
+ error?: string
106
+ }
107
+
108
+ /** Request payload of the `live` RPC endpoint. */
109
+ export interface LiveRequest {
110
+ /** Provider route to inspect (the key inside `providers`). */
111
+ provider: string
112
+ /** Model ids to resolve through the owning adapter. */
113
+ ids: string[]
114
+ }
115
+
116
+ /** Response payload of the `live` RPC endpoint. */
117
+ export interface LiveResponse {
118
+ /** One row per requested id, in request order. */
119
+ rows: LiveRow[]
120
+ }