@fonlan/dsh-model-parameters 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/LICENSE +21 -0
- package/README.en.md +140 -0
- package/README.md +112 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +586 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +637 -0
- package/lib/types/index.d.ts +20 -0
- package/lib/types/server/catalog.d.ts +83 -0
- package/lib/types/server/rpc.d.ts +3 -0
- package/lib/types/server/service.d.ts +66 -0
- package/lib/types/shared/config.d.ts +53 -0
- package/package.json +98 -0
- package/src/client/api.ts +59 -0
- package/src/client/index.tsx +33 -0
- package/src/client/locales.ts +95 -0
- package/src/client/settings-section.css +172 -0
- package/src/client/settings-section.tsx +310 -0
- package/src/client/slot-augment.d.ts +14 -0
- package/src/index.ts +47 -0
- package/src/server/catalog.ts +300 -0
- package/src/server/rpc.ts +162 -0
- package/src/server/service.ts +258 -0
- package/src/shared/config.ts +106 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fenced JSON API under /plugins/@fonlan/dsh-model-parameters/api/<method> for
|
|
3
|
+
* the web settings page. Same browser-trust fence as the /api gateway and the
|
|
4
|
+
* model-router plugin: loopback Host-header or configured trustedHosts,
|
|
5
|
+
* same-origin browser markers.
|
|
6
|
+
*/
|
|
7
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
8
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
9
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
10
|
+
import type { ModelParametersService } from './service.js'
|
|
11
|
+
|
|
12
|
+
const API_PREFIX = '/plugins/@fonlan/dsh-model-parameters/api'
|
|
13
|
+
|
|
14
|
+
// ── browser-trust fence (mirrors dsh-client-connection's api-request-trust) ──
|
|
15
|
+
|
|
16
|
+
function header(headers: IncomingMessage['headers'], name: string): string | undefined {
|
|
17
|
+
const value = headers[name]
|
|
18
|
+
return typeof value === 'string' ? value : undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
22
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
23
|
+
const parts = hostname.split('.')
|
|
24
|
+
return parts.length === 4 && parts[0] === '127' && parts.every(p => /^\d{1,3}$/.test(p) && Number(p) <= 255)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isTrustedApiRequest(req: IncomingMessage, trustedHosts: readonly string[]): boolean {
|
|
28
|
+
const host = header(req.headers, 'host')
|
|
29
|
+
if (host === undefined) return false
|
|
30
|
+
let hostUrl: URL
|
|
31
|
+
try {
|
|
32
|
+
hostUrl = new URL(`http://${host}`)
|
|
33
|
+
} catch {
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !trustedHosts.includes(hostUrl.host) && !trustedHosts.includes(hostUrl.hostname)) {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
if (header(req.headers, 'sec-fetch-site') === 'cross-site') return false
|
|
40
|
+
const origin = header(req.headers, 'origin')
|
|
41
|
+
if (origin === undefined) return true
|
|
42
|
+
try {
|
|
43
|
+
return new URL(origin).host === hostUrl.host
|
|
44
|
+
} catch {
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function trustedHostsOf(ctx: Context): string[] {
|
|
50
|
+
for (const entry of ctx.get('loader')?.entries?.() ?? []) {
|
|
51
|
+
if (entry.options?.name === 'connection') {
|
|
52
|
+
const config = entry.options.config as { trustedHosts?: string[] } | undefined
|
|
53
|
+
return config?.trustedHosts ?? []
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return []
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── wire helpers ────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
function writeJson(res: ServerResponse, body: unknown): void {
|
|
62
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
63
|
+
res.end(JSON.stringify(body))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function writeError(res: ServerResponse, code: string, message: string, status = 400): void {
|
|
67
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
68
|
+
res.end(JSON.stringify({ ok: false, error: { code, message } }))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
72
|
+
const chunks: Buffer[] = []
|
|
73
|
+
for await (const chunk of req) chunks.push(chunk as Buffer)
|
|
74
|
+
const raw = Buffer.concat(chunks).toString('utf8')
|
|
75
|
+
if (raw.trim() === '') return {}
|
|
76
|
+
try {
|
|
77
|
+
const parsed = JSON.parse(raw) as unknown
|
|
78
|
+
return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : {}
|
|
79
|
+
} catch {
|
|
80
|
+
return {}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function requireString(value: unknown, name: string): string {
|
|
85
|
+
if (typeof value !== 'string' || value.length === 0) throw new Error(`${name} must be a non-empty string`)
|
|
86
|
+
return value
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── routes ──────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export function registerApiRoutes(ctx: Context, service: ModelParametersService): () => void {
|
|
92
|
+
const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHostsOf(ctx))
|
|
93
|
+
const disposers: Array<() => void> = []
|
|
94
|
+
|
|
95
|
+
const route = (name: string, handler: (payload: Record<string, unknown>) => Promise<unknown>): void => {
|
|
96
|
+
disposers.push(ctx.webServer.register({
|
|
97
|
+
kind: 'prefix',
|
|
98
|
+
path: `${API_PREFIX}/${name}`,
|
|
99
|
+
handler: async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
100
|
+
if (!fence(req)) {
|
|
101
|
+
writeError(res, 'forbidden', 'forbidden', 403)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
if (req.method !== 'POST' && req.method !== 'GET') {
|
|
105
|
+
writeError(res, 'method', 'method not allowed', 405)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
const payload = await readJsonBody(req)
|
|
109
|
+
try {
|
|
110
|
+
const value = await handler(payload)
|
|
111
|
+
writeJson(res, { ok: true, value })
|
|
112
|
+
} catch (error) {
|
|
113
|
+
writeError(res, 'error', error instanceof Error ? error.message : String(error))
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
}))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
route('state', async () => stateOf(service))
|
|
120
|
+
|
|
121
|
+
route('refresh', async () => {
|
|
122
|
+
await service.refreshAndReconcile()
|
|
123
|
+
return stateOf(service)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
route('reconcile', async () => {
|
|
127
|
+
await service.reconcile()
|
|
128
|
+
return stateOf(service)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
route('set-config', async (payload) => {
|
|
132
|
+
const patch = payload.patch
|
|
133
|
+
if (typeof patch !== 'object' || patch === null) throw new Error('patch must be an object')
|
|
134
|
+
await service.updateConfig(patch as never)
|
|
135
|
+
return stateOf(service)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
return () => {
|
|
139
|
+
for (const dispose of disposers) dispose()
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The settings-page view. Deliberately ships only catalog COUNTS — the full
|
|
145
|
+
* entries array (thousands of models) must never cross the wire or render.
|
|
146
|
+
*/
|
|
147
|
+
function stateOf(service: ModelParametersService): unknown {
|
|
148
|
+
const config = service.config()
|
|
149
|
+
const snapshot = service.catalogSnapshot()
|
|
150
|
+
return {
|
|
151
|
+
config,
|
|
152
|
+
catalog: {
|
|
153
|
+
entries: snapshot.entries.length,
|
|
154
|
+
providers: snapshot.providers,
|
|
155
|
+
fetchedAt: snapshot.fetchedAt,
|
|
156
|
+
fresh: snapshot.fetchedAt > 0
|
|
157
|
+
&& Date.now() - snapshot.fetchedAt < config.ttlDays * 24 * 60 * 60 * 1000,
|
|
158
|
+
ttlDays: config.ttlDays,
|
|
159
|
+
},
|
|
160
|
+
report: service.report(),
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-parameters service: owns the plugin settings namespace, watches
|
|
3
|
+
* the `llm-pi-ai` namespace for provider model writes, reconciles missing
|
|
4
|
+
* fields from the cached models.dev catalog, and tracks a fill report.
|
|
5
|
+
*
|
|
6
|
+
* Trigger model:
|
|
7
|
+
* - any committed `llm-pi-ai` settings change (settings UI apply, manual
|
|
8
|
+
* settings.yaml edit published by the file provider, model-router-style
|
|
9
|
+
* external writes) runs a reconcile;
|
|
10
|
+
* - plugin start runs a backfill reconcile over the existing configuration.
|
|
11
|
+
* Reconcile is fill-only: it never overwrites a present field, and it writes
|
|
12
|
+
* nothing when nothing is missing — so its own writes cannot re-trigger a
|
|
13
|
+
* second patch (the next event sees the fields present and produces no ops).
|
|
14
|
+
*/
|
|
15
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
16
|
+
import {
|
|
17
|
+
settingsNamespace,
|
|
18
|
+
type SettingsScope,
|
|
19
|
+
type SettingsPathOp,
|
|
20
|
+
} from '@deepseek-ai/dsh-settings'
|
|
21
|
+
import type { PiAiModelProfile } from '@deepseek-ai/dsh-llm-pi-ai'
|
|
22
|
+
import {
|
|
23
|
+
Config,
|
|
24
|
+
DEFAULT_CONFIG,
|
|
25
|
+
PLUGIN_NS,
|
|
26
|
+
TARGET_NS,
|
|
27
|
+
type ModelParametersConfig,
|
|
28
|
+
} from '../shared/config.js'
|
|
29
|
+
import {
|
|
30
|
+
CatalogManager,
|
|
31
|
+
acceptedInputModalities,
|
|
32
|
+
identityReasoningEfforts,
|
|
33
|
+
type CatalogEntry,
|
|
34
|
+
type CatalogSnapshot,
|
|
35
|
+
} from './catalog.js'
|
|
36
|
+
|
|
37
|
+
const TARGET = settingsNamespace(TARGET_NS)
|
|
38
|
+
const OWN = settingsNamespace(PLUGIN_NS)
|
|
39
|
+
|
|
40
|
+
/** One reconcile run's outcome, surfaced to the settings page. */
|
|
41
|
+
export interface FillReport {
|
|
42
|
+
at: number
|
|
43
|
+
/** Number of individual field fills applied. */
|
|
44
|
+
filled: number
|
|
45
|
+
/** Number of providers whose models array was rewritten. */
|
|
46
|
+
touched: number
|
|
47
|
+
/** Model ids (provider/model) that matched nothing in the catalog. */
|
|
48
|
+
unmatched: string[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class ModelParametersService {
|
|
52
|
+
/**
|
|
53
|
+
* The settings service value captured from the inject callback. The plugin
|
|
54
|
+
* itself declares `inject: []` (settings is optional — headless profiles
|
|
55
|
+
* have no settings service), so `this.ctx.settings` is never resolvable:
|
|
56
|
+
* the context proxy only walks ancestor fibers, and the provider lives on
|
|
57
|
+
* a sibling fiber. The injected child ctx itself is only valid while its
|
|
58
|
+
* fiber is active — the provider can be reloaded (and every dependent
|
|
59
|
+
* fiber torn down) while an async reconcile is in flight — so all reads
|
|
60
|
+
* and writes go through the captured service value, which is a plain
|
|
61
|
+
* object and never walks the fiber chain.
|
|
62
|
+
*/
|
|
63
|
+
private settings: Context['settings'] | undefined
|
|
64
|
+
private scope: SettingsScope<ModelParametersConfig> | undefined
|
|
65
|
+
private lastReport: FillReport | null = null
|
|
66
|
+
private readonly catalog: CatalogManager
|
|
67
|
+
private reconciling: Promise<void> | null = null
|
|
68
|
+
|
|
69
|
+
constructor(private readonly ctx: Context) {
|
|
70
|
+
this.catalog = new CatalogManager(ctx.logger)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
start(): void {
|
|
74
|
+
// Own settings namespace (config toggles, TTL, provider map). The inject
|
|
75
|
+
// callback fires asynchronously once the settings service is available, so
|
|
76
|
+
// the first-run backfill reconcile lives INSIDE it — reconcile needs the
|
|
77
|
+
// settings service (to read the target namespace and persist fills).
|
|
78
|
+
this.ctx.inject(['settings'], (sctx) => {
|
|
79
|
+
this.settings = sctx.settings
|
|
80
|
+
this.scope = this.settings.register(OWN, Config) as SettingsScope<ModelParametersConfig>
|
|
81
|
+
// First-run backfill: ensure a fresh-enough catalog, then fill existing
|
|
82
|
+
// models. Never blocks plugin startup.
|
|
83
|
+
void this.reconcile()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// Watch the target namespace: any committed provider-model change may
|
|
87
|
+
// leave fields missing -> reconcile.
|
|
88
|
+
this.ctx.on('settings/updated', (ns) => {
|
|
89
|
+
if (ns !== TARGET) return
|
|
90
|
+
void this.reconcile()
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
stop(): void {
|
|
95
|
+
this.scope = undefined
|
|
96
|
+
this.settings = undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── reads for the settings page ────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
config(): ModelParametersConfig {
|
|
102
|
+
if (this.scope !== undefined) {
|
|
103
|
+
try {
|
|
104
|
+
return this.scope.get()
|
|
105
|
+
} catch {
|
|
106
|
+
// fall through to defaults
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { ...DEFAULT_CONFIG, providerMap: {}, officialProviders: [...DEFAULT_CONFIG.officialProviders] }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
catalogSnapshot(): CatalogSnapshot {
|
|
113
|
+
return this.catalog.snapshotOf()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
report(): FillReport | null {
|
|
117
|
+
return this.lastReport
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Force a catalog refresh, then reconcile and report. */
|
|
121
|
+
async refreshAndReconcile(): Promise<void> {
|
|
122
|
+
await this.catalog.refresh(this.config().ttlDays)
|
|
123
|
+
await this.reconcile()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Update plugin config toggles from the settings page. */
|
|
127
|
+
async updateConfig(patch: Partial<ModelParametersConfig>): Promise<void> {
|
|
128
|
+
if (this.scope === undefined) {
|
|
129
|
+
throw new Error('model-parameters: settings service is not available in this profile')
|
|
130
|
+
}
|
|
131
|
+
const current = this.config()
|
|
132
|
+
await this.scope.update({ ...current, ...patch })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── reconcile ──────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Fill missing fields on every provider model from the catalog. Serialized:
|
|
139
|
+
* concurrent triggers (event + backfill) coalesce into one run. Fill-only
|
|
140
|
+
* guarantees termination: after a write the fields are present, so the next
|
|
141
|
+
* event produces no ops.
|
|
142
|
+
*/
|
|
143
|
+
async reconcile(): Promise<void> {
|
|
144
|
+
if (this.reconciling !== null) return this.reconciling
|
|
145
|
+
this.reconciling = this.runReconcile().finally(() => {
|
|
146
|
+
this.reconciling = null
|
|
147
|
+
})
|
|
148
|
+
return this.reconciling
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private async runReconcile(): Promise<void> {
|
|
152
|
+
const config = this.config()
|
|
153
|
+
if (!config.enabled) return
|
|
154
|
+
const settings = this.settings
|
|
155
|
+
if (settings === undefined) return
|
|
156
|
+
try {
|
|
157
|
+
await this.catalog.ensureFresh(config)
|
|
158
|
+
} catch (error) {
|
|
159
|
+
this.ctx.logger.warn(`model-parameters: reconcile skipped, catalog unavailable: ${error instanceof Error ? error.message : String(error)}`)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
// Operate on the RAW user section (descriptor.user), not the resolved
|
|
163
|
+
// value: the resolved section is schema-normalized (pi-ai's loose object
|
|
164
|
+
// resolution adds empty `input`/`compat` defaults), and persisting that
|
|
165
|
+
// shape would leak schema-default noise into settings.yaml. The raw
|
|
166
|
+
// section is exactly what the user document stores.
|
|
167
|
+
const descriptor = settings.describe({ redactSecrets: false }).find((candidate) => candidate.ns === TARGET)
|
|
168
|
+
const section = descriptor?.user as { providers?: Record<string, { models?: PiAiModelProfile[] }> } | undefined
|
|
169
|
+
if (section === undefined || typeof section !== 'object' || section.providers === undefined) return
|
|
170
|
+
|
|
171
|
+
const ops: SettingsPathOp[] = []
|
|
172
|
+
const report: FillReport = { at: Date.now(), filled: 0, touched: 0, unmatched: [] }
|
|
173
|
+
|
|
174
|
+
for (const [providerId, profile] of Object.entries(section.providers)) {
|
|
175
|
+
if (typeof profile !== 'object' || profile === null) continue
|
|
176
|
+
const models = profile.models
|
|
177
|
+
if (!Array.isArray(models) || models.length === 0) continue
|
|
178
|
+
const nextModels: PiAiModelProfile[] = []
|
|
179
|
+
let changed = false
|
|
180
|
+
for (const model of models) {
|
|
181
|
+
const next = this.fillModel(model, providerId, config, report)
|
|
182
|
+
if (next !== model) changed = true
|
|
183
|
+
nextModels.push(next)
|
|
184
|
+
}
|
|
185
|
+
if (changed) {
|
|
186
|
+
ops.push({ op: 'set', path: ['providers', providerId, 'models'], value: nextModels })
|
|
187
|
+
report.touched++
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (ops.length > 0) {
|
|
192
|
+
try {
|
|
193
|
+
await settings.mutate(TARGET, ops)
|
|
194
|
+
} catch (error) {
|
|
195
|
+
this.ctx.logger.warn(`model-parameters: reconcile persist failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
this.lastReport = report
|
|
199
|
+
if (report.filled > 0 || report.unmatched.length > 0) {
|
|
200
|
+
this.ctx.logger.info(
|
|
201
|
+
`model-parameters: reconcile filled ${report.filled} fields across ${report.touched} providers; ${report.unmatched.length} unmatched (${report.unmatched.slice(0, 5).join(', ')}${report.unmatched.length > 5 ? ', …' : ''})`,
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Fill one model entry (fill-only), returning the same object when untouched. */
|
|
207
|
+
private fillModel(
|
|
208
|
+
model: PiAiModelProfile,
|
|
209
|
+
providerId: string,
|
|
210
|
+
config: ModelParametersConfig,
|
|
211
|
+
report: FillReport,
|
|
212
|
+
): PiAiModelProfile {
|
|
213
|
+
const entry = this.catalog.resolve(model.id, providerId, config)
|
|
214
|
+
if (entry === undefined) {
|
|
215
|
+
report.unmatched.push(`${providerId}/${model.id}`)
|
|
216
|
+
return model
|
|
217
|
+
}
|
|
218
|
+
const next = { ...model }
|
|
219
|
+
let filledFields = 0
|
|
220
|
+
|
|
221
|
+
if (config.fillName && next.name === undefined) {
|
|
222
|
+
const name = catalogName(entry)
|
|
223
|
+
if (name !== undefined) {
|
|
224
|
+
next.name = name
|
|
225
|
+
filledFields++
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (config.fillContext && next.contextWindow === undefined && entry.context !== undefined && entry.context > 0) {
|
|
229
|
+
next.contextWindow = entry.context
|
|
230
|
+
filledFields++
|
|
231
|
+
}
|
|
232
|
+
if (config.fillMaxTokens && next.maxTokens === undefined && entry.output !== undefined && entry.output > 0) {
|
|
233
|
+
next.maxTokens = entry.output
|
|
234
|
+
filledFields++
|
|
235
|
+
}
|
|
236
|
+
if (config.fillReasoning && next.reasoningEfforts === undefined) {
|
|
237
|
+
const efforts = identityReasoningEfforts(entry)
|
|
238
|
+
if (efforts !== undefined) {
|
|
239
|
+
next.reasoningEfforts = efforts
|
|
240
|
+
filledFields++
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (config.fillInput && next.input === undefined) {
|
|
244
|
+
const input = acceptedInputModalities(entry)
|
|
245
|
+
if (input !== undefined) {
|
|
246
|
+
next.input = input
|
|
247
|
+
filledFields++
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (filledFields === 0) return model
|
|
251
|
+
report.filled += filledFields
|
|
252
|
+
return next
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function catalogName(entry: CatalogEntry): string | undefined {
|
|
257
|
+
return entry.name !== undefined && entry.name.length > 0 ? entry.name : undefined
|
|
258
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin configuration and shared constants for @fonlan/dsh-model-parameters.
|
|
3
|
+
*
|
|
4
|
+
* The plugin owns the `model-parameters` settings namespace (stored in
|
|
5
|
+
* settings.yaml) and OBSERVES the `llm-pi-ai` namespace: whenever provider
|
|
6
|
+
* models are written there with missing capability fields (which is the norm
|
|
7
|
+
* for a freshly synced gateway), it patches the missing fields from the cached
|
|
8
|
+
* models.dev catalog.
|
|
9
|
+
*/
|
|
10
|
+
import z from '@deepseek-ai/schemastery'
|
|
11
|
+
|
|
12
|
+
/** This plugin's own settings namespace. */
|
|
13
|
+
export const PLUGIN_NS = 'model-parameters'
|
|
14
|
+
|
|
15
|
+
/** The namespace whose models this plugin completes: pi-ai provider profiles. */
|
|
16
|
+
export const TARGET_NS = 'llm-pi-ai'
|
|
17
|
+
|
|
18
|
+
/** models.dev catalog endpoint. */
|
|
19
|
+
export const CATALOG_URL = 'https://models.dev/api.json'
|
|
20
|
+
|
|
21
|
+
/** Default provider ids treated as first-party vendors (tie-break for id collisions). */
|
|
22
|
+
export const DEFAULT_OFFICIAL_PROVIDERS: readonly string[] = [
|
|
23
|
+
'deepseek',
|
|
24
|
+
'openai',
|
|
25
|
+
'anthropic',
|
|
26
|
+
'google',
|
|
27
|
+
'xai',
|
|
28
|
+
'moonshotai',
|
|
29
|
+
'zai',
|
|
30
|
+
'zhipuai',
|
|
31
|
+
'minimax',
|
|
32
|
+
'stepfun',
|
|
33
|
+
'xiaomi',
|
|
34
|
+
'meta',
|
|
35
|
+
'cohere',
|
|
36
|
+
'nvidia',
|
|
37
|
+
'alibaba',
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* dsh pi-ai only accepts these request modalities; models.dev may also list
|
|
42
|
+
* pdf/audio/video, which must be filtered out before writing (schema rejects
|
|
43
|
+
* unknown modality values).
|
|
44
|
+
*/
|
|
45
|
+
export const ACCEPTED_INPUT_MODALITIES: readonly string[] = ['text', 'image']
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Valid `reasoningEfforts` keys in pi-ai, in escalation order. models.dev
|
|
49
|
+
* effort values like `default`/`none`/empty are filtered out; only values in
|
|
50
|
+
* this set are written.
|
|
51
|
+
*/
|
|
52
|
+
export const REASONING_LEVELS: readonly string[] = [
|
|
53
|
+
'off',
|
|
54
|
+
'minimal',
|
|
55
|
+
'low',
|
|
56
|
+
'medium',
|
|
57
|
+
'high',
|
|
58
|
+
'xhigh',
|
|
59
|
+
'max',
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
/** Plugin configuration shape. */
|
|
63
|
+
export interface ModelParametersConfig {
|
|
64
|
+
/** Master switch: when off, no fills happen (listener is inert). */
|
|
65
|
+
enabled: boolean
|
|
66
|
+
/** How many days a cached models.dev catalog is considered fresh. */
|
|
67
|
+
ttlDays: number
|
|
68
|
+
/** Fill display name from the catalog when the entry lacks one. */
|
|
69
|
+
fillName: boolean
|
|
70
|
+
/** Fill contextWindow when the entry lacks one. */
|
|
71
|
+
fillContext: boolean
|
|
72
|
+
/** Fill maxTokens when the entry lacks one. */
|
|
73
|
+
fillMaxTokens: boolean
|
|
74
|
+
/** Fill reasoningEfforts (identity dict over offered effort levels) when absent. */
|
|
75
|
+
fillReasoning: boolean
|
|
76
|
+
/** Fill input modalities (filtered to text/image) when absent. */
|
|
77
|
+
fillInput: boolean
|
|
78
|
+
/** dsh provider id -> models.dev provider id overrides for matching. */
|
|
79
|
+
providerMap: Record<string, string>
|
|
80
|
+
/** models.dev provider ids treated as first-party vendors for tie-breaking. */
|
|
81
|
+
officialProviders: string[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const Config: z<ModelParametersConfig> = z.object({
|
|
85
|
+
enabled: z.boolean().default(true),
|
|
86
|
+
ttlDays: z.number().min(1).max(90).default(7),
|
|
87
|
+
fillName: z.boolean().default(true),
|
|
88
|
+
fillContext: z.boolean().default(true),
|
|
89
|
+
fillMaxTokens: z.boolean().default(true),
|
|
90
|
+
fillReasoning: z.boolean().default(true),
|
|
91
|
+
fillInput: z.boolean().default(true),
|
|
92
|
+
providerMap: z.dict(z.string()).default({}),
|
|
93
|
+
officialProviders: z.array(z.string()).default([...DEFAULT_OFFICIAL_PROVIDERS]),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
export const DEFAULT_CONFIG: ModelParametersConfig = {
|
|
97
|
+
enabled: true,
|
|
98
|
+
ttlDays: 7,
|
|
99
|
+
fillName: true,
|
|
100
|
+
fillContext: true,
|
|
101
|
+
fillMaxTokens: true,
|
|
102
|
+
fillReasoning: true,
|
|
103
|
+
fillInput: true,
|
|
104
|
+
providerMap: {},
|
|
105
|
+
officialProviders: [...DEFAULT_OFFICIAL_PROVIDERS],
|
|
106
|
+
}
|