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.
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Browser half: one settings section plus the host RPC call it needs.
3
+ *
4
+ * The section is a `settings.section` slot of its own, so no shell edit is
5
+ * required. Configuration reads and writes go through the official settings
6
+ * remote (`remote.settings`), which is the same surface the shell's own Models
7
+ * page uses; the only custom channel is the catalog lookup, which must run
8
+ * host-side to avoid a cross-origin download.
9
+ * @module dsh-model-capability-sync/client
10
+ */
11
+ import type { RpcEnvelope } from '../types.ts'
12
+ import type { LiveResponse, PreviewRequest, PreviewResponse } from '../types.ts'
13
+ import { SyncSection } from './SyncSection.tsx'
14
+ import type { SettingsDescriptor, SettingsPathOp, SyncSectionInjected } from './SyncSection.tsx'
15
+ import { en, zh } from './locale.ts'
16
+
17
+ /** Copy namespace owned by this plugin. */
18
+ const NS = 'settings.capability-sync'
19
+ /** Own host RPC channel. */
20
+ const CHANNEL = '/model-capability-sync'
21
+
22
+ /** The slice of the browser context this plugin uses. */
23
+ interface ClientContext {
24
+ effect(callback: () => (() => void) | void, label?: string): void
25
+ get(service: string): unknown
26
+ locale: {
27
+ register(ns: string, dictionaries: { zh: unknown; en: unknown }): () => void
28
+ bind(ns: string): (key: string, params?: Record<string, unknown>) => string
29
+ }
30
+ slots: {
31
+ inject(name: string, callback: () => () => void): void
32
+ register(options: Record<string, unknown>, component: unknown): () => void
33
+ }
34
+ }
35
+
36
+ /** The connection face carrying `rpc.call`. */
37
+ interface ConnectionFace {
38
+ rpc: { call(channel: string, endpoint: string, payload: unknown): Promise<unknown> }
39
+ }
40
+
41
+ /** The remote face carrying the settings namespace. */
42
+ interface RemoteFace {
43
+ settings: {
44
+ describe(): Promise<unknown>
45
+ mutate(ns: string, ops: SettingsPathOp[], expectedRevision?: number): Promise<unknown>
46
+ }
47
+ }
48
+
49
+ /** The document `settings.describe` answers with, once its envelope is opened. */
50
+ interface SettingsDocument {
51
+ writable?: boolean
52
+ namespaces?: SettingsDescriptor[]
53
+ }
54
+
55
+ /** A refusal as the Remote namespace states it. */
56
+ interface Refusal {
57
+ /** Machine-readable reason; `settings/conflict` is the one callers act on. */
58
+ code?: string
59
+ /** Human message for the page. */
60
+ message: string
61
+ }
62
+
63
+ /**
64
+ * Read the refusal out of a Remote answer envelope.
65
+ * @param answer - whatever the Remote namespace resolved with.
66
+ * @returns the refusal when the answer carries one, otherwise undefined.
67
+ */
68
+ function refusalOf(answer: unknown): Refusal | undefined {
69
+ if (typeof answer !== 'object' || answer === null) return undefined
70
+ const envelope = answer as { ok?: unknown; error?: { code?: unknown; message?: unknown } }
71
+ if (envelope.ok !== false) return undefined
72
+ return {
73
+ ...typeof envelope.error?.code === 'string' ? { code: envelope.error.code } : {},
74
+ message: typeof envelope.error?.message === 'string' ? envelope.error.message : 'settings request refused',
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Open a Remote answer envelope.
80
+ *
81
+ * Every `remote.settings` method answers `{ok: true, value}` or
82
+ * `{ok: false, error}`; a few surfaces hand back the bare value instead, so
83
+ * both shapes are accepted rather than guessed at one. A refusal keeps its
84
+ * `code`, so the section can tell a stale revision apart from a real failure.
85
+ * @param answer - whatever the Remote namespace resolved with.
86
+ * @returns the value the answer carries.
87
+ * @throws the refusal, carrying `code` when the answer had one.
88
+ */
89
+ function unwrap(answer: unknown): unknown {
90
+ const refusal = refusalOf(answer)
91
+ if (refusal !== undefined) {
92
+ throw Object.assign(new Error(refusal.message), refusal.code === undefined ? {} : { code: refusal.code })
93
+ }
94
+ if (typeof answer === 'object' && answer !== null && 'ok' in answer && 'value' in answer) {
95
+ return (answer as { value: unknown }).value
96
+ }
97
+ return answer
98
+ }
99
+
100
+ /**
101
+ * The namespace views a describe answer carries.
102
+ * @param answer - the `settings.describe` answer, enveloped or bare.
103
+ * @returns every served namespace view.
104
+ * @throws when the answer holds no namespace list.
105
+ */
106
+ function namespacesOf(answer: unknown): SettingsDescriptor[] {
107
+ const document = unwrap(answer) as SettingsDocument | SettingsDescriptor[] | undefined
108
+ if (Array.isArray(document)) return document
109
+ const namespaces = document?.namespaces
110
+ if (!Array.isArray(namespaces)) throw new Error('settings.describe returned no namespace list')
111
+ return namespaces
112
+ }
113
+
114
+ /** Required services (cordis fiber inject). */
115
+ export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settings']
116
+
117
+ /** Section styles, riding the shell's `--dsw-alias-*` tokens for both themes. */
118
+ const SECTION_CSS = `
119
+ .cs-field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
120
+ .cs-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 12px; }
121
+ .cs-select {
122
+ box-sizing: border-box; padding: 6px 10px; border-radius: 8px;
123
+ border: 1px solid var(--dsw-alias-border-l2);
124
+ background: var(--dsw-alias-bg-layer-1);
125
+ color: var(--dsw-alias-label-primary);
126
+ font: inherit; font-size: 13px;
127
+ }
128
+ .cs-button {
129
+ padding: 6px 12px; border-radius: 6px; font: inherit; font-size: 13px;
130
+ border: 1px solid var(--dsw-alias-border-l2);
131
+ background: transparent; color: var(--dsw-alias-label-primary);
132
+ cursor: pointer;
133
+ }
134
+ .cs-button:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
135
+ .cs-button:disabled { opacity: 0.4; cursor: default; }
136
+ .cs-button--primary {
137
+ border-color: transparent;
138
+ background: var(--dsw-alias-button-primary-fill);
139
+ color: var(--dsw-alias-label-primary-foreground);
140
+ }
141
+ .cs-button--primary:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); }
142
+ .cs-hint { font-size: 12px; color: var(--dsw-alias-label-tertiary); line-height: 18px; }
143
+ .cs-error { color: var(--dsw-alias-state-error-primary); font-size: 12px; }
144
+ .cs-warn { color: var(--dsw-alias-state-warning-primary, var(--dsw-alias-label-secondary)); font-size: 12px; line-height: 18px; }
145
+ .cs-input {
146
+ box-sizing: border-box; padding: 6px 10px; border-radius: 8px; min-width: 200px;
147
+ border: 1px solid var(--dsw-alias-border-l2);
148
+ background: var(--dsw-alias-bg-layer-1);
149
+ color: var(--dsw-alias-label-primary);
150
+ font: inherit; font-size: 13px;
151
+ }
152
+ .cs-target { font-size: 13px; color: var(--dsw-alias-label-primary); line-height: 30px; }
153
+ .cs-table-wrap {
154
+ max-height: 460px; overflow: auto; margin-bottom: 12px;
155
+ border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px;
156
+ }
157
+ .cs-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; }
158
+ .cs-table th {
159
+ position: sticky; top: 0; z-index: 1;
160
+ text-align: left; font-weight: 500; color: var(--dsw-alias-label-secondary);
161
+ background: var(--dsw-alias-bg-layer-1);
162
+ padding: 6px 8px; border-bottom: 1px solid var(--dsw-alias-border-l2); white-space: nowrap;
163
+ }
164
+ .cs-table td {
165
+ padding: 6px 8px; border-bottom: 1px solid var(--dsw-alias-border-l2);
166
+ color: var(--dsw-alias-label-primary); vertical-align: top;
167
+ }
168
+ .cs-table tr:last-child td { border-bottom: none; }
169
+ .cs-row--changed td { background: var(--dsw-alias-bg-layer-2, transparent); }
170
+ .cs-id { font-variant-numeric: tabular-nums; word-break: break-all; }
171
+ .cs-dim { color: var(--dsw-alias-label-tertiary); }
172
+ .cs-change { white-space: nowrap; }
173
+ .cs-from { color: var(--dsw-alias-label-tertiary); text-decoration: line-through; }
174
+ .cs-badge {
175
+ display: inline-block; padding: 0 6px; border-radius: 9px; font-size: 11px; line-height: 18px;
176
+ border: 1px solid var(--dsw-alias-border-l2); color: var(--dsw-alias-label-secondary);
177
+ }
178
+ .cs-badge--image { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); }
179
+ .cs-unmatched { margin: 8px 0 0; padding-left: 18px; color: var(--dsw-alias-label-tertiary); font-size: 12px; }
180
+ `
181
+
182
+ /**
183
+ * Register the copy dictionary, the section styles, and the section itself.
184
+ * @param ctx - browser root context.
185
+ */
186
+ export function apply(ctx: ClientContext): void {
187
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'model-capability-sync: copy dictionaries')
188
+
189
+ if (typeof document !== 'undefined') {
190
+ ctx.effect(() => {
191
+ const element = document.createElement('style')
192
+ element.textContent = SECTION_CSS
193
+ document.head.append(element)
194
+ return () => { element.remove() }
195
+ }, 'model-capability-sync: section styles')
196
+ }
197
+
198
+ const connection = ctx.get('connection') as ConnectionFace
199
+ const remote = ctx.get('remote') as RemoteFace
200
+ const t = ctx.locale.bind(NS)
201
+
202
+ const injected = (): SyncSectionInjected => ({
203
+ preview: request =>
204
+ connection.rpc.call(CHANNEL, 'preview', request) as Promise<RpcEnvelope<PreviewResponse>>,
205
+ live: request =>
206
+ connection.rpc.call(CHANNEL, 'live', request) as Promise<RpcEnvelope<LiveResponse>>,
207
+ describeSettings: async () => namespacesOf(await remote.settings.describe()),
208
+ mutateSettings: async (ns, ops, expectedRevision) => {
209
+ const view = unwrap(await remote.settings.mutate(ns, ops, expectedRevision)) as SettingsDescriptor | undefined
210
+ if (view === undefined) throw new Error('settings.mutate returned no namespace view')
211
+ return view
212
+ },
213
+ })
214
+
215
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
216
+ name: 'settings.section',
217
+ id: 'model-capability-sync',
218
+ order: 16,
219
+ label: () => t('nav'),
220
+ locale: NS,
221
+ inject: injected,
222
+ }, SyncSection))
223
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Browser-half entry: the module loader imports this file and calls its
3
+ * `apply` once the injected services exist.
4
+ * @module dsh-model-capability-sync/client
5
+ */
6
+ export { apply, inject } from './apply.ts'
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Copy dictionaries for the capability-sync section. Chinese first (the dsh
3
+ * web convention); English mirrors it key for key.
4
+ */
5
+
6
+ export const zh = {
7
+ nav: '模型能力同步',
8
+ loading: '正在读取配置…',
9
+ intro: '从 models.dev 查询已配置模型的能力(上下文窗口、输出上限、是否支持图片、推理档位),并写入所选供应商的配置。本页只写配置,不发送任何推理请求。',
10
+ writeTarget: '写入位置',
11
+ sourceNative: '原生配置',
12
+ sourcePlugin: '插件配置',
13
+ nameLlmPiAi: 'pi-ai 适配器',
14
+ nameLlmDeepseek: 'DeepSeek 官方',
15
+ nameLlmDeepseekAccount: 'DeepSeek 账号',
16
+ nameLlmNewapi: 'NewAPI',
17
+ provider: '供应商',
18
+ modelCount: '共 {count} 个模型',
19
+ willSort: '写入时按「前缀分组 + 名称」排序',
20
+ search: '搜索模型 ID 或目录 ID',
21
+ onlyChanged: '只看有变化的',
22
+ changeSummary: '将修改 {rows} 行 · {fields} 个字段',
23
+ noChanges: '没有需要写入的变化(目录值与你现有配置一致)。',
24
+ filteredEmpty: '没有符合筛选条件的模型。',
25
+ fieldEmpty: '未设置',
26
+ conflictReload: '配置已被其他改动更新,已重新读取最新值;请确认后再点一次「应用」。',
27
+ analyze: '分析能力',
28
+ analyzing: '正在匹配…',
29
+ refreshCatalog: '重新下载目录',
30
+ apply: '应用',
31
+ applying: '正在写入…',
32
+ applied: '已写入 {count} 个模型',
33
+ overwrite: '覆盖已有值',
34
+ overwriteHint: '不勾选时只填空白字段,你手填过的值不会被改写。',
35
+ selectAll: '全选',
36
+ colModel: '模型 ID',
37
+ colMatch: '匹配',
38
+ colContext: '上下文',
39
+ colOutput: '输出',
40
+ colImage: '图片',
41
+ colLive: '当前生效',
42
+ colLiveHint: '由真正接管这条路由的适配器上报,也就是聊天请求实际看到的值。若它与「图片」列不一致,说明配置被另一个插件抢占了路由。',
43
+ liveError: '无法读取',
44
+ liveSummary: '当前生效:{image} 个支持图片,{text} 个纯文本。若与「图片」列不一致,说明这条路由由别的插件接管,写入本配置不会生效。',
45
+ colReasoning: '推理档位',
46
+ imageYes: '支持',
47
+ imageNo: '纯文本',
48
+ unmatched: '未匹配',
49
+ unmatchedHint: '这些模型在 models.dev 里找不到,保持原样。可在别名表里手动指定。',
50
+ noModels: '该供应商下还没有模型。',
51
+ noTarget: '没有找到可写的配置条目(需要已启用的 llm-pi-ai 供应商配置)。',
52
+ loadFailed: '加载失败',
53
+ retry: '重试',
54
+ viaAlias: '别名',
55
+ viaExact: '精确',
56
+ viaFolded: '忽略大小写',
57
+ viaBasename: '去前缀',
58
+ viaSuffix: '去变体后缀',
59
+ catalogInfo: '目录:{models} 个模型 · {time}',
60
+ catalogStale: '在线目录下载失败,正在使用本机缓存({time})。数据可能不是最新,字段含义不变。',
61
+ nothingSelected: '没有选中任何模型。',
62
+ done: '完成。',
63
+ } as const
64
+
65
+ export type SyncKey = keyof typeof zh
66
+
67
+ export const en: Record<SyncKey, string> = {
68
+ nav: 'Model capabilities',
69
+ loading: 'Reading configuration…',
70
+ intro: 'Look up capabilities (context window, output cap, image input, reasoning efforts) for the models already configured here, then write them into the selected provider. This page only writes configuration; it never sends a model request.',
71
+ writeTarget: 'Write to',
72
+ sourceNative: 'Built in',
73
+ sourcePlugin: 'Plugin',
74
+ nameLlmPiAi: 'pi-ai adapter',
75
+ nameLlmDeepseek: 'DeepSeek official',
76
+ nameLlmDeepseekAccount: 'DeepSeek account',
77
+ nameLlmNewapi: 'NewAPI',
78
+ provider: 'Provider',
79
+ modelCount: '{count} models',
80
+ willSort: 'Written sorted by vendor prefix, then name',
81
+ search: 'Search model id or catalog id',
82
+ onlyChanged: 'Only rows that change',
83
+ changeSummary: 'Will change {rows} rows · {fields} fields',
84
+ noChanges: 'Nothing to write: the catalog agrees with your configuration.',
85
+ filteredEmpty: 'No model matches the filter.',
86
+ fieldEmpty: 'unset',
87
+ conflictReload: 'The configuration changed elsewhere; the latest values were reloaded. Check them, then press Apply again.',
88
+ analyze: 'Analyze',
89
+ analyzing: 'Matching…',
90
+ refreshCatalog: 'Re-download catalog',
91
+ apply: 'Apply',
92
+ applying: 'Writing…',
93
+ applied: 'Wrote {count} models',
94
+ overwrite: 'Overwrite existing values',
95
+ overwriteHint: 'Unchecked: only blank fields are filled, so values you typed are preserved.',
96
+ selectAll: 'Select all',
97
+ colModel: 'Model id',
98
+ colMatch: 'Match',
99
+ colContext: 'Context',
100
+ colOutput: 'Output',
101
+ colImage: 'Image',
102
+ colLive: 'In effect',
103
+ colLiveHint: 'Reported by the adapter that actually serves this route — what a chat request sees. A value that disagrees with the Image column means another plugin owns the route.',
104
+ liveError: 'unreadable',
105
+ liveSummary: 'In effect: {image} image-capable, {text} text-only. If this disagrees with the Image column, another plugin serves the route and writing this entry changes nothing.',
106
+ colReasoning: 'Reasoning',
107
+ imageYes: 'yes',
108
+ imageNo: 'text only',
109
+ unmatched: 'unmatched',
110
+ unmatchedHint: 'These ids are absent from models.dev and stay untouched. Add an alias to map them by hand.',
111
+ noModels: 'This provider has no models yet.',
112
+ noTarget: 'No writable configuration entry found (an enabled llm-pi-ai provider is required).',
113
+ loadFailed: 'Load failed',
114
+ retry: 'Retry',
115
+ viaAlias: 'alias',
116
+ viaExact: 'exact',
117
+ viaFolded: 'case-folded',
118
+ viaBasename: 'basename',
119
+ viaSuffix: 'suffix',
120
+ catalogInfo: 'Catalog: {models} models · {time}',
121
+ catalogStale: 'The online catalog download failed; using the local copy from {time}. The facts still mean the same thing, they may just be older.',
122
+ nothingSelected: 'Nothing selected.',
123
+ done: 'Done.',
124
+ }
package/src/index.ts ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Host half: one RPC channel that answers "what does the catalog say about
3
+ * these model ids?".
4
+ *
5
+ * The plugin registers no LLM provider route on purpose — it only supplies
6
+ * facts. The browser half decides which provider entry to write and performs
7
+ * the write through the official settings service, so this half never touches
8
+ * configuration.
9
+ * @module dsh-model-capability-sync
10
+ */
11
+ import { loadCatalog } from './catalog.ts'
12
+ import type { Catalog } from './catalog.ts'
13
+ import { capabilitiesOf } from './mapping.ts'
14
+ import { matchModel } from './match.ts'
15
+ import type { LiveRequest, LiveResponse, LiveRow, PreviewRequest, PreviewResponse, PreviewRow, RpcEnvelope } from './types.ts'
16
+
17
+ /** Cordis plugin name; the profile entry id matches. */
18
+ export const name = 'model-capability-sync'
19
+
20
+ /** The channel is served over the web server, so both services are required. */
21
+ export const inject = ['connection', 'webServer', 'llm']
22
+
23
+ /** Own RPC channel; the browser half calls this exact path. */
24
+ const CHANNEL = '/model-capability-sync'
25
+
26
+ /** The slice of the host context this plugin uses. */
27
+ interface HostContext {
28
+ get(service: string): unknown
29
+ effect(callback: () => (() => void | Promise<void>) | void, label?: string): void
30
+ logger: { warn(message: string): void; error(message: unknown): void }
31
+ }
32
+
33
+ type Handler = (endpoint: string, payload: unknown, signal: AbortSignal) => Promise<RpcEnvelope<unknown>>
34
+
35
+ /** The connection service's owner-taking registration (see DESIGN in llm-newapi). */
36
+ interface Registrar {
37
+ register(owner: unknown, channel: string, handler: Handler): () => Promise<void>
38
+ }
39
+
40
+ /** Resolve one id against the catalog into a preview row. */
41
+ function previewOne(catalog: Catalog, id: string, aliases: Readonly<Record<string, string>>): PreviewRow {
42
+ const match = matchModel(catalog, id, aliases)
43
+ if (match === undefined) return { id, reason: 'not in the models.dev catalog' }
44
+ const entry = catalog.byId.get(match.modelId)?.find(candidate => candidate.provider === match.provider)
45
+ if (entry === undefined) return { id, match, reason: 'catalog entry vanished between match and read' }
46
+ const fields = capabilitiesOf(entry)
47
+ if (Object.keys(fields).length === 0) return { id, match, reason: 'catalog entry states no capability facts' }
48
+ return { id, match, fields }
49
+ }
50
+
51
+ /** Serve one `preview` request. */
52
+ async function preview(request: PreviewRequest, signal: AbortSignal): Promise<PreviewResponse> {
53
+ const ids = Array.isArray(request.ids) ? request.ids.filter(id => typeof id === 'string' && id.length > 0) : []
54
+ const catalog = await loadCatalog({ refresh: request.refresh === true, proxyUrl: request.proxyUrl }, signal)
55
+ const aliases = request.aliases ?? {}
56
+ return {
57
+ fetchedAt: catalog.fetchedAt,
58
+ catalogModels: catalog.modelCount,
59
+ ...catalog.stale === true ? { stale: true } : {},
60
+ rows: ids.map(id => previewOne(catalog, id, aliases)),
61
+ }
62
+ }
63
+
64
+ /** One RPC envelope, never a thrown value: the transport would turn that into an opaque 500. */
65
+ function failure(message: string): RpcEnvelope<never> {
66
+ return { ok: false, error: { code: 'internal', message, details: {} } }
67
+ }
68
+
69
+ /** The LLM registry face this half reads live model metadata through. */
70
+ interface LlmFace {
71
+ resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<{
72
+ inputModalities?: readonly string[]
73
+ context?: { contextWindow: number }
74
+ defaultMaxTokens?: number
75
+ }>
76
+ }
77
+
78
+ /**
79
+ * Read what the adapter that owns `provider` reports right now.
80
+ *
81
+ * A configuration entry and the route a chat request takes can disagree: two
82
+ * plugins may claim one provider route, and only the first registration serves
83
+ * it. This answer is what a request would see, so a filled-in `input` that
84
+ * never takes effect becomes visible instead of inferred.
85
+ * @param llm - the LLM registry.
86
+ * @param request - route and model ids to resolve.
87
+ * @param signal - cancellation for adapter-owned lookups.
88
+ * @returns one row per requested id, in request order.
89
+ */
90
+ async function live(llm: LlmFace, request: LiveRequest, signal: AbortSignal): Promise<LiveResponse> {
91
+ const provider = typeof request.provider === 'string' ? request.provider : ''
92
+ const ids = Array.isArray(request.ids) ? request.ids.filter(id => typeof id === 'string' && id.length > 0) : []
93
+ const rows: LiveRow[] = []
94
+ for (const id of ids) {
95
+ try {
96
+ const info = await llm.resolveModelInfo(provider, id, signal)
97
+ rows.push({
98
+ id,
99
+ ...info.inputModalities === undefined ? {} : { input: [...info.inputModalities] },
100
+ ...info.context === undefined ? {} : { contextWindow: info.context.contextWindow },
101
+ ...info.defaultMaxTokens === undefined ? {} : { maxTokens: info.defaultMaxTokens },
102
+ })
103
+ } catch (error) {
104
+ rows.push({ id, error: error instanceof Error ? error.message : String(error) })
105
+ }
106
+ }
107
+ return { rows }
108
+ }
109
+
110
+ /**
111
+ * Build the channel handler bound to one LLM registry.
112
+ * @param llm - the LLM registry the `live` endpoint reads through, when present.
113
+ * @returns the endpoint dispatcher.
114
+ */
115
+ function createHandler(llm: LlmFace | undefined): Handler {
116
+ return async (endpoint, payload, signal) => {
117
+ try {
118
+ if (endpoint === 'preview') {
119
+ return { ok: true, value: await preview(payload as PreviewRequest, signal) }
120
+ }
121
+ if (endpoint === 'live') {
122
+ if (llm === undefined) return failure('model-capability-sync: no llm service to resolve the live route')
123
+ return { ok: true, value: await live(llm, payload as LiveRequest, signal) }
124
+ }
125
+ return failure(`model-capability-sync: unknown endpoint ${JSON.stringify(endpoint)}`)
126
+ } catch (error) {
127
+ return failure(error instanceof Error ? error.message : String(error))
128
+ }
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Register the channel. The registration rides the calling fiber, so a plugin
134
+ * reload removes the route with it.
135
+ * @param ctx - host context carrying `connection`, `webServer` and `llm`.
136
+ */
137
+ export function apply(ctx: HostContext): void {
138
+ const connection = ctx.get('connection')
139
+ const llm = ctx.get('llm') as LlmFace | undefined
140
+ if (connection === undefined) {
141
+ ctx.logger.warn('model-capability-sync: no connection service; the capability channel is unavailable')
142
+ return
143
+ }
144
+ if (llm === undefined) ctx.logger.warn('model-capability-sync: no llm service; the live route check is unavailable')
145
+ const registrar = connection as Registrar
146
+ const handler = createHandler(llm)
147
+ ctx.effect(() => registrar.register(ctx, CHANNEL, handler), 'model-capability-sync: RPC channel')
148
+ }
package/src/mapping.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Turning one catalog entry into the capability fields a provider model entry
3
+ * accepts.
4
+ *
5
+ * Everything here is a pure function of catalog data: no gateway call, no
6
+ * guessing beyond the catalog's own facts. A field the catalog does not state
7
+ * stays absent, so the writer never invents a value.
8
+ * @module dsh-model-capability-sync/mapping
9
+ */
10
+ import type { CatalogEntry } from './catalog.ts'
11
+ import type { CapabilityFields, Modality } from './types.ts'
12
+
13
+ /** Thinking levels the harness recognizes, strongest last. */
14
+ const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
15
+
16
+ /** A positive integer or nothing. */
17
+ function positive(value: unknown): number | undefined {
18
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.round(value) : undefined
19
+ }
20
+
21
+ /** Map the catalog's media list onto the harness's two modalities. */
22
+ function modalitiesOf(entry: CatalogEntry): Modality[] | undefined {
23
+ const declared = entry.model.modalities?.input
24
+ if (declared === undefined) return undefined
25
+ const accepted: Modality[] = []
26
+ for (const media of declared) {
27
+ if (media === 'image' && !accepted.includes('image')) accepted.push('image')
28
+ }
29
+ // A model that lists only images still reads text in practice; the harness
30
+ // always sends text, so `text` is never dropped.
31
+ return ['text', ...accepted]
32
+ }
33
+
34
+ /**
35
+ * Build the reasoning-effort map from the catalog's `effort` option.
36
+ * @returns the dict, `false` when the catalog says the model cannot reason, or
37
+ * `undefined` when the catalog is silent.
38
+ */
39
+ function reasoningOf(entry: CatalogEntry): Record<string, string> | false | undefined {
40
+ const model = entry.model
41
+ if (model.reasoning === false) return false
42
+ const option = model.reasoning_options?.find(candidate => candidate.type === 'effort')
43
+ const values = option?.values ?? []
44
+ const efforts: Record<string, string> = {}
45
+ for (const value of values) {
46
+ if (typeof value !== 'string') continue
47
+ if (!THINKING_LEVELS.has(value)) continue
48
+ efforts[value] = value
49
+ }
50
+ return Object.keys(efforts).length > 0 ? efforts : undefined
51
+ }
52
+
53
+ /**
54
+ * Read every capability the catalog states for one entry.
55
+ *
56
+ * The catalog's display name is deliberately not returned: this plugin fills
57
+ * facts, it never renames a model entry.
58
+ * @param entry - the matched catalog entry.
59
+ * @returns only the fields the catalog actually declares.
60
+ */
61
+ export function capabilitiesOf(entry: CatalogEntry): CapabilityFields {
62
+ const model = entry.model
63
+ const input = modalitiesOf(entry)
64
+ const reasoningEfforts = reasoningOf(entry)
65
+ return {
66
+ ...positive(model.limit?.context) === undefined ? {} : { contextWindow: positive(model.limit?.context) as number },
67
+ ...positive(model.limit?.output) === undefined ? {} : { maxTokens: positive(model.limit?.output) as number },
68
+ ...input === undefined ? {} : { input },
69
+ ...reasoningEfforts === undefined ? {} : { reasoningEfforts },
70
+ }
71
+ }