@eduwork/dsh-oidc 0.1.0 → 0.2.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/CHANGELOG.en.md +40 -1
- package/CHANGELOG.md +40 -1
- package/README.en.md +23 -21
- package/README.md +17 -15
- package/THIRD_PARTY_NOTICES.en.md +7 -5
- package/THIRD_PARTY_NOTICES.md +7 -5
- package/cordis.patch.yml +2 -1
- package/docs/EDUWORK-MIGRATION.en.md +14 -2
- package/docs/EDUWORK-MIGRATION.md +14 -2
- package/docs/account-extensions.en.md +40 -0
- package/docs/account-extensions.md +42 -0
- package/docs/architecture.en.md +9 -9
- package/docs/architecture.md +11 -10
- package/docs/compatibility.en.md +11 -2
- package/docs/compatibility.md +11 -2
- package/docs/desktop-host.en.md +69 -0
- package/docs/desktop-host.md +79 -0
- package/docs/development.en.md +42 -0
- package/docs/development.md +42 -0
- package/docs/dsh-integration.en.md +23 -7
- package/docs/dsh-integration.md +23 -7
- package/docs/ecnu-reference.en.md +3 -3
- package/docs/ecnu-reference.md +3 -3
- package/docs/enterprise-profile.en.md +19 -11
- package/docs/enterprise-profile.md +18 -10
- package/docs/getting-started.en.md +12 -12
- package/docs/getting-started.md +16 -14
- package/docs/key-binding-protocol.en.md +2 -2
- package/docs/key-binding-protocol.md +2 -2
- package/docs/oidc-interoperability.en.md +3 -3
- package/docs/oidc-interoperability.md +2 -2
- package/docs/public-resource-protocol.en.md +51 -0
- package/docs/public-resource-protocol.md +50 -0
- package/docs/release-checklist.en.md +4 -2
- package/docs/release-checklist.md +4 -2
- package/docs/server-integration-contract.en.md +124 -8
- package/docs/server-integration-contract.md +136 -8
- package/examples/ecnu.enterprise-profile.example.json +2 -2
- package/examples/enterprise-profile.example.json +78 -77
- package/examples/identity-only.example.json +10 -0
- package/examples/resources-discovery.example.json +20 -0
- package/lib/callback-page.js +42 -0
- package/lib/client.js +934 -294
- package/lib/desktop-oidc.js +205 -0
- package/lib/index.js +148 -18
- package/lib/oidc.js +314 -58
- package/lib/profile.js +53 -24
- package/lib/provider/core.js +76 -15
- package/lib/provider/index.js +33 -20
- package/lib/provider/transform-adapter.js +31 -10
- package/lib/resources.js +23 -0
- package/lib/typert-schemas.js +34 -3
- package/lib/typert.host.js +42 -36
- package/lib/typert.remote-client.js +41 -35
- package/package.json +100 -29
- package/protocol/openapi.yaml +2 -2
- package/protocol/resources.openapi.yaml +51 -0
- package/schema/enterprise-profile.v1alpha1.schema.json +193 -188
package/lib/profile.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
2
|
|
|
3
3
|
export const PROFILE_SCHEMA_VERSION = 'dsh-oidc/v1alpha1'
|
|
4
|
+
export const DEFAULT_CREDENTIAL_REF = 'EDUWORK_API_KEY'
|
|
4
5
|
const MAX_PROFILE_BYTES = 512 * 1024
|
|
5
6
|
const idPattern = /^[a-z][a-z0-9-]{0,63}$/
|
|
6
7
|
const credentialPattern = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
@@ -17,7 +18,7 @@ const allowedRootKeys = new Set([
|
|
|
17
18
|
'allowInsecureDevelopment', 'insecureDevelopmentOrigin', 'brand', 'oidc', 'keyBinding', 'provider',
|
|
18
19
|
])
|
|
19
20
|
const allowedOidcKeys = new Set(['issuer', 'clientId', 'scopes'])
|
|
20
|
-
const allowedKeyBindingKeys = new Set(['baseURL', 'credentialRef'])
|
|
21
|
+
const allowedKeyBindingKeys = new Set(['type', 'baseURL', 'credentialRef'])
|
|
21
22
|
const allowedBrandKeys = new Set([
|
|
22
23
|
'productName', 'organizationName', 'mark', 'logoURL', 'primaryColor',
|
|
23
24
|
'loginTitle', 'loginDescription', 'supportURL',
|
|
@@ -25,11 +26,11 @@ const allowedBrandKeys = new Set([
|
|
|
25
26
|
const allowedProviderKeys = new Set([
|
|
26
27
|
'id', 'displayName', 'adapter', 'baseURL', 'reasoning', 'defaultContextWindow',
|
|
27
28
|
'defaultMaxTokens', 'maxRequestImageBytes', 'requestImagePixelBudget',
|
|
28
|
-
'requestImageMaxBytes', 'streamIdleTimeoutMs', 'retryPolicy', 'compat', 'models',
|
|
29
|
+
'requestImageMaxBytes', 'streamIdleTimeoutMs', 'retryPolicy', 'compat', 'models', 'modelSource',
|
|
29
30
|
])
|
|
30
31
|
const allowedModelKeys = new Set([
|
|
31
32
|
'id', 'name', 'input', 'contextWindow', 'maxTokens', 'reasoning',
|
|
32
|
-
'reasoningEfforts', 'compat',
|
|
33
|
+
'reasoningEfforts', 'defaultReasoningEffort', 'compat',
|
|
33
34
|
])
|
|
34
35
|
const allowedCompatKeys = new Set([
|
|
35
36
|
'supportsDeveloperRole', 'supportsStore', 'supportsReasoningEffort',
|
|
@@ -174,7 +175,7 @@ function normalizeReasoningEfforts(value, providerID, modelID) {
|
|
|
174
175
|
})))
|
|
175
176
|
}
|
|
176
177
|
|
|
177
|
-
function normalizeModels(models, providerID) {
|
|
178
|
+
export function normalizeModels(models, providerID) {
|
|
178
179
|
if (!Array.isArray(models) || models.length === 0 || models.length > 128) throw new Error(`${providerID}.models must contain 1-128 entries`)
|
|
179
180
|
const seen = new Set()
|
|
180
181
|
return Object.freeze(models.map((raw, index) => {
|
|
@@ -189,6 +190,19 @@ function normalizeModels(models, providerID) {
|
|
|
189
190
|
throw new Error(`${providerID}.${id}.input must contain text and/or image`)
|
|
190
191
|
}
|
|
191
192
|
if (new Set(input).size !== input.length) throw new Error(`${providerID}.${id}.input contains duplicates`)
|
|
193
|
+
const normalizedReasoningEfforts = model.reasoningEfforts === undefined
|
|
194
|
+
? undefined
|
|
195
|
+
: normalizeReasoningEfforts(model.reasoningEfforts, providerID, id)
|
|
196
|
+
let defaultReasoningEffort
|
|
197
|
+
if (model.defaultReasoningEffort !== undefined) {
|
|
198
|
+
defaultReasoningEffort = text(model.defaultReasoningEffort, `${providerID}.${id}.defaultReasoningEffort`, 32)
|
|
199
|
+
if (!allowedThinkingLevels.has(defaultReasoningEffort)) {
|
|
200
|
+
throw new Error(`${providerID}.${id}.defaultReasoningEffort is not supported by DSH pi-ai`)
|
|
201
|
+
}
|
|
202
|
+
if (!normalizedReasoningEfforts || normalizedReasoningEfforts === false || !(defaultReasoningEffort in normalizedReasoningEfforts)) {
|
|
203
|
+
throw new Error(`${providerID}.${id}.defaultReasoningEffort must be one of the model's declared reasoningEfforts`)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
192
206
|
return Object.freeze({
|
|
193
207
|
id,
|
|
194
208
|
name: model.name === undefined ? id : text(model.name, `${providerID}.${id}.name`, 256),
|
|
@@ -196,14 +210,15 @@ function normalizeModels(models, providerID) {
|
|
|
196
210
|
...(model.contextWindow === undefined ? {} : { contextWindow: positiveInteger(model.contextWindow, `${providerID}.${id}.contextWindow`) }),
|
|
197
211
|
...(model.maxTokens === undefined ? {} : { maxTokens: positiveInteger(model.maxTokens, `${providerID}.${id}.maxTokens`) }),
|
|
198
212
|
...(model.reasoning === undefined ? {} : { reasoning: boolean(model.reasoning, `${providerID}.${id}.reasoning`) }),
|
|
199
|
-
...(
|
|
213
|
+
...(normalizedReasoningEfforts === undefined ? {} : { reasoningEfforts: normalizedReasoningEfforts }),
|
|
214
|
+
...(defaultReasoningEffort === undefined ? {} : { defaultReasoningEffort }),
|
|
200
215
|
...(model.compat === undefined ? {} : { compat: normalizeCompat(model.compat, `${providerID}.${id}.compat`) }),
|
|
201
216
|
})
|
|
202
217
|
}))
|
|
203
218
|
}
|
|
204
219
|
|
|
205
|
-
function
|
|
206
|
-
return `${
|
|
220
|
+
export function legacyCredentialRefs(profile) {
|
|
221
|
+
return [...new Set([profile.id, profile.provider?.id].filter(Boolean).map(id => `${id.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_API_KEY`))]
|
|
207
222
|
}
|
|
208
223
|
|
|
209
224
|
function normalizeRetryPolicy(value) {
|
|
@@ -267,20 +282,29 @@ export function normalizeEnterpriseProfile(raw) {
|
|
|
267
282
|
throw new Error('profile.insecureDevelopmentOrigin requires allowInsecureDevelopment=true')
|
|
268
283
|
}
|
|
269
284
|
const oidc = object(source.oidc, 'profile.oidc')
|
|
270
|
-
const
|
|
271
|
-
|
|
285
|
+
const identityOnly = source.keyBinding === undefined && source.provider === undefined
|
|
286
|
+
if (!identityOnly && (source.keyBinding === undefined || source.provider === undefined)) {
|
|
287
|
+
throw new Error('profile.keyBinding and profile.provider must either both be present or both be omitted')
|
|
288
|
+
}
|
|
289
|
+
const keyBinding = identityOnly ? {} : object(source.keyBinding, 'profile.keyBinding')
|
|
290
|
+
const provider = identityOnly ? {} : object(source.provider, 'profile.provider')
|
|
272
291
|
exactKeys(oidc, allowedOidcKeys, 'profile.oidc')
|
|
273
292
|
exactKeys(keyBinding, allowedKeyBindingKeys, 'profile.keyBinding')
|
|
274
293
|
exactKeys(provider, allowedProviderKeys, 'profile.provider')
|
|
275
|
-
const providerID = text(provider.id, 'profile.provider.id', 64)
|
|
294
|
+
const providerID = identityOnly ? id : text(provider.id, 'profile.provider.id', 64)
|
|
276
295
|
if (!idPattern.test(providerID)) throw new Error('profile.provider.id is invalid')
|
|
277
|
-
|
|
278
|
-
?
|
|
296
|
+
let credentialRef = keyBinding.credentialRef === undefined
|
|
297
|
+
? DEFAULT_CREDENTIAL_REF
|
|
279
298
|
: text(keyBinding.credentialRef, 'profile.keyBinding.credentialRef', 128)
|
|
280
299
|
if (!credentialPattern.test(credentialRef)) {
|
|
281
300
|
throw new Error('profile.keyBinding.credentialRef must be a valid DSH credential reference')
|
|
282
301
|
}
|
|
283
|
-
if (provider
|
|
302
|
+
if (legacyCredentialRefs({ id, provider: { id: providerID } }).includes(credentialRef)) credentialRef = DEFAULT_CREDENTIAL_REF
|
|
303
|
+
if (!identityOnly && provider.adapter !== 'openai-compatible') throw new Error(`unsupported provider adapter ${String(provider.adapter)}`)
|
|
304
|
+
const protocol = keyBinding.type ?? 'worker-user-center-v1'
|
|
305
|
+
if (!['worker-user-center-v1', 'eduwork-resources-v1'].includes(protocol)) throw new Error('profile.keyBinding.type is not supported')
|
|
306
|
+
const modelSource = provider.modelSource ?? 'profile'
|
|
307
|
+
if (!['profile', 'discovery'].includes(modelSource)) throw new Error('profile.provider.modelSource must be profile or discovery')
|
|
284
308
|
const scopes = oidc.scopes
|
|
285
309
|
if (!Array.isArray(scopes) || scopes.length < 2 || scopes.length > 64 || !scopes.includes('openid') || !scopes.includes('profile') || scopes.some(scope => typeof scope !== 'string' || scope.length > 128 || scope.trim() !== scope || scope === '' || /\s/.test(scope))) {
|
|
286
310
|
throw new Error('profile.oidc.scopes must contain openid and profile; every scope must be a non-empty token')
|
|
@@ -300,8 +324,8 @@ export function normalizeEnterpriseProfile(raw) {
|
|
|
300
324
|
clientId: text(oidc.clientId, 'profile.oidc.clientId', 256),
|
|
301
325
|
scopes: Object.freeze([...scopes]),
|
|
302
326
|
}),
|
|
303
|
-
keyBinding: Object.freeze({
|
|
304
|
-
type:
|
|
327
|
+
...(identityOnly ? {} : { keyBinding: Object.freeze({
|
|
328
|
+
type: protocol,
|
|
305
329
|
baseURL: exactURL(keyBinding.baseURL, 'profile.keyBinding.baseURL', allowInsecureDevelopment, allowedInsecureOrigin),
|
|
306
330
|
providerId: providerID,
|
|
307
331
|
credentialRef,
|
|
@@ -326,7 +350,10 @@ export function normalizeEnterpriseProfile(raw) {
|
|
|
326
350
|
streamIdleTimeoutMs: optionalPositiveInteger(provider.streamIdleTimeoutMs, 'profile.provider.streamIdleTimeoutMs'),
|
|
327
351
|
retryPolicy: provider.retryPolicy === undefined ? undefined : normalizeRetryPolicy(provider.retryPolicy),
|
|
328
352
|
compat: provider.compat === undefined ? undefined : normalizeCompat(provider.compat, 'profile.provider.compat'),
|
|
329
|
-
|
|
353
|
+
modelSource,
|
|
354
|
+
models: modelSource === 'discovery' && (provider.models === undefined || provider.models.length === 0)
|
|
355
|
+
? Object.freeze([]) : normalizeModels(provider.models, providerID),
|
|
356
|
+
}),
|
|
330
357
|
}),
|
|
331
358
|
})
|
|
332
359
|
}
|
|
@@ -338,8 +365,10 @@ export function loadEnterpriseProfiles(raw = {}, environment = process.env) {
|
|
|
338
365
|
if (!Array.isArray(raw.profiles)) throw new Error('dsh-oidc profiles must be an array')
|
|
339
366
|
profiles.push(...raw.profiles.map(normalizeEnterpriseProfile))
|
|
340
367
|
}
|
|
341
|
-
|
|
342
|
-
|
|
368
|
+
const pathEnv = raw.profilePathEnv ?? 'EDUWORK_OIDC_PROFILE'
|
|
369
|
+
const profilePath = environment[pathEnv] || (pathEnv === 'EDUWORK_OIDC_PROFILE' ? environment.DSH_OIDC_ENTERPRISE_PROFILE : undefined)
|
|
370
|
+
if (typeof profilePath === 'string' && profilePath) {
|
|
371
|
+
const parsed = JSON.parse(readFileSync(profilePath, 'utf8'))
|
|
343
372
|
const rows = Array.isArray(parsed) ? parsed : parsed.profiles ?? [parsed]
|
|
344
373
|
profiles.push(...rows.map(normalizeEnterpriseProfile))
|
|
345
374
|
}
|
|
@@ -347,16 +376,16 @@ export function loadEnterpriseProfiles(raw = {}, environment = process.env) {
|
|
|
347
376
|
const providers = new Set()
|
|
348
377
|
for (const profile of profiles) {
|
|
349
378
|
if (unique.has(profile.id)) throw new Error(`dsh-oidc repeats profile ${profile.id}`)
|
|
350
|
-
if (providers.has(profile.provider.id)) throw new Error(`dsh-oidc repeats provider route ${profile.provider.id}`)
|
|
379
|
+
if (profile.provider && providers.has(profile.provider.id)) throw new Error(`dsh-oidc repeats provider route ${profile.provider.id}`)
|
|
351
380
|
unique.set(profile.id, profile)
|
|
352
|
-
providers.add(profile.provider.id)
|
|
381
|
+
if (profile.provider) providers.add(profile.provider.id)
|
|
353
382
|
}
|
|
354
383
|
return unique
|
|
355
384
|
}
|
|
356
385
|
|
|
357
386
|
export function enterpriseProviderConfig(profiles) {
|
|
358
387
|
return {
|
|
359
|
-
providers: Object.fromEntries([...profiles.values()].map(profile => [profile.provider.id, {
|
|
388
|
+
providers: Object.fromEntries([...profiles.values()].filter(profile => profile.provider?.models.length).map(profile => [profile.provider.id, {
|
|
360
389
|
displayName: profile.provider.displayName,
|
|
361
390
|
apiKeyEnv: profile.keyBinding.credentialRef,
|
|
362
391
|
baseURL: profile.provider.baseURL,
|
|
@@ -389,11 +418,11 @@ export function publicProfile(profile) {
|
|
|
389
418
|
displayName: profile.displayName,
|
|
390
419
|
organization: profile.organization,
|
|
391
420
|
brand: profile.brand,
|
|
392
|
-
credentialRef: profile.keyBinding
|
|
393
|
-
provider: {
|
|
421
|
+
credentialRef: profile.keyBinding?.credentialRef ?? '',
|
|
422
|
+
...(profile.provider ? { provider: {
|
|
394
423
|
id: profile.provider.id,
|
|
395
424
|
displayName: profile.provider.displayName,
|
|
396
425
|
models: profile.provider.models.map(model => ({ id: model.id, name: model.name, input: model.input })),
|
|
397
|
-
},
|
|
426
|
+
} } : {}),
|
|
398
427
|
}
|
|
399
428
|
}
|
package/lib/provider/core.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
const modalities = new Set(['text', 'image'])
|
|
2
|
+
export const REASONING_EFFORT_ORDER = Object.freeze(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
|
|
3
|
+
const reasoningEfforts = new Set(REASONING_EFFORT_ORDER)
|
|
2
4
|
export const ENTERPRISE_SETTINGS_NAMESPACE = 'provider-enterprise'
|
|
3
5
|
export const ENTERPRISE_DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024
|
|
4
6
|
export const ENTERPRISE_DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 2048 * 2048
|
|
@@ -50,7 +52,43 @@ function normalizeThinkingMap(value, reasoning) {
|
|
|
50
52
|
if (value === false || reasoning === false) return { off: null }
|
|
51
53
|
const source = value ?? { off: null, high: 'high', max: 'max' }
|
|
52
54
|
if (typeof source !== 'object' || source === null || Array.isArray(source)) throw new Error('reasoningEfforts must be an object or false')
|
|
53
|
-
|
|
55
|
+
const entries = Object.entries(source)
|
|
56
|
+
if (entries.length === 0 || !entries.some(([effort]) => effort !== 'off')) {
|
|
57
|
+
throw new Error('reasoningEfforts must declare at least one thinking level')
|
|
58
|
+
}
|
|
59
|
+
for (const [effort, wire] of entries) {
|
|
60
|
+
if (!reasoningEfforts.has(effort)) throw new Error(`reasoningEfforts level ${effort} is not supported`)
|
|
61
|
+
if (wire === null && effort !== 'off') throw new Error(`reasoningEfforts.${effort} may be null only for off`)
|
|
62
|
+
if (wire !== null && (typeof wire !== 'string' || wire.length === 0)) throw new Error(`reasoningEfforts.${effort} must be a non-empty string`)
|
|
63
|
+
}
|
|
64
|
+
const map = {}
|
|
65
|
+
for (const effort of REASONING_EFFORT_ORDER) {
|
|
66
|
+
const wire = source[effort]
|
|
67
|
+
if (wire === undefined) map[effort] = null
|
|
68
|
+
else if (wire !== null) map[effort] = wire
|
|
69
|
+
}
|
|
70
|
+
return map
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function declaredReasoningEfforts(value, reasoning) {
|
|
74
|
+
if (reasoning === false || value === false) return []
|
|
75
|
+
const source = value ?? { off: null, high: 'high', max: 'max' }
|
|
76
|
+
return REASONING_EFFORT_ORDER.filter(effort => Object.hasOwn(source, effort))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function reasoningPreference(value, label) {
|
|
80
|
+
const preference = value ?? 'high'
|
|
81
|
+
if (!reasoningEfforts.has(preference)) throw new Error(`${label} is not a supported reasoning effort`)
|
|
82
|
+
return preference
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function nearestReasoningEffort(preference, supported) {
|
|
86
|
+
const preferredRank = REASONING_EFFORT_ORDER.indexOf(preference)
|
|
87
|
+
return [...supported].sort((left, right) => {
|
|
88
|
+
const leftRank = REASONING_EFFORT_ORDER.indexOf(left)
|
|
89
|
+
const rightRank = REASONING_EFFORT_ORDER.indexOf(right)
|
|
90
|
+
return Math.abs(leftRank - preferredRank) - Math.abs(rightRank - preferredRank) || leftRank - rightRank
|
|
91
|
+
})[0]
|
|
54
92
|
}
|
|
55
93
|
|
|
56
94
|
export function resolveEnterpriseProfiles(raw = {}) {
|
|
@@ -69,6 +107,7 @@ export function resolveEnterpriseProfiles(raw = {}) {
|
|
|
69
107
|
const maxRequestImageBytes = positiveInteger(source.maxRequestImageBytes, ENTERPRISE_DEFAULT_MAX_REQUEST_IMAGE_BYTES, `${route}.maxRequestImageBytes`)
|
|
70
108
|
const requestImagePixelBudget = positiveInteger(source.requestImagePixelBudget, ENTERPRISE_DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, `${route}.requestImagePixelBudget`)
|
|
71
109
|
const requestImageMaxBytes = positiveInteger(source.requestImageMaxBytes, ENTERPRISE_DEFAULT_REQUEST_IMAGE_MAX_BYTES, `${route}.requestImageMaxBytes`)
|
|
110
|
+
const providerReasoning = reasoningPreference(source.reasoning, `${route}.reasoning`)
|
|
72
111
|
if (!Array.isArray(source.models) || source.models.length === 0) throw new Error(`${route}.models must not be empty`)
|
|
73
112
|
|
|
74
113
|
const ids = new Set()
|
|
@@ -78,6 +117,35 @@ export function resolveEnterpriseProfiles(raw = {}) {
|
|
|
78
117
|
if (ids.has(id)) throw new Error(`${route} declares duplicate model ${id}`)
|
|
79
118
|
ids.add(id)
|
|
80
119
|
const reasoning = model.reasoning !== false && model.reasoningEfforts !== false
|
|
120
|
+
const compat = {
|
|
121
|
+
supportsDeveloperRole: false,
|
|
122
|
+
supportsStore: false,
|
|
123
|
+
supportsReasoningEffort: reasoning,
|
|
124
|
+
supportsUsageInStreaming: true,
|
|
125
|
+
maxTokensField: 'max_tokens',
|
|
126
|
+
supportsStrictMode: false,
|
|
127
|
+
supportsLongCacheRetention: false,
|
|
128
|
+
thinkingFormat: 'deepseek',
|
|
129
|
+
requiresReasoningContentOnAssistantMessages: true,
|
|
130
|
+
...(source.compat ?? {}),
|
|
131
|
+
...(model.compat ?? {}),
|
|
132
|
+
}
|
|
133
|
+
const supportsReasoningEffort = reasoning && compat.supportsReasoningEffort !== false
|
|
134
|
+
const efforts = supportsReasoningEffort ? declaredReasoningEfforts(model.reasoningEfforts, reasoning) : []
|
|
135
|
+
if (model.defaultReasoningEffort !== undefined && !reasoningEfforts.has(model.defaultReasoningEffort)) {
|
|
136
|
+
throw new Error(`${route}.${id}.defaultReasoningEffort is not a supported reasoning effort`)
|
|
137
|
+
}
|
|
138
|
+
if (model.defaultReasoningEffort !== undefined && !efforts.includes(model.defaultReasoningEffort)) {
|
|
139
|
+
throw new Error(`${route}.${id}.defaultReasoningEffort must be one of the model's declared reasoningEfforts`)
|
|
140
|
+
}
|
|
141
|
+
const defaultReasoningEffort = supportsReasoningEffort
|
|
142
|
+
? (model.defaultReasoningEffort ?? nearestReasoningEffort(providerReasoning, efforts))
|
|
143
|
+
: undefined
|
|
144
|
+
// PiAi uses an internal effort to enable thinking even when the gateway
|
|
145
|
+
// accepts no reasoning_effort field. Keep that default separate from UI choices.
|
|
146
|
+
const internalThinkingEffort = reasoning && !supportsReasoningEffort
|
|
147
|
+
? nearestReasoningEffort(providerReasoning, declaredReasoningEfforts(model.reasoningEfforts, reasoning))
|
|
148
|
+
: undefined
|
|
81
149
|
return {
|
|
82
150
|
id,
|
|
83
151
|
name: nonEmpty(model.name ?? id, `${route}.${id}.name`),
|
|
@@ -90,18 +158,10 @@ export function resolveEnterpriseProfiles(raw = {}) {
|
|
|
90
158
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
91
159
|
contextWindow: positiveInteger(model.contextWindow, contextWindow, `${route}.${id}.contextWindow`),
|
|
92
160
|
maxTokens: positiveInteger(model.maxTokens, maxTokens, `${route}.${id}.maxTokens`),
|
|
93
|
-
compat
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
supportsUsageInStreaming: true,
|
|
98
|
-
maxTokensField: 'max_tokens',
|
|
99
|
-
supportsStrictMode: false,
|
|
100
|
-
supportsLongCacheRetention: false,
|
|
101
|
-
thinkingFormat: 'deepseek',
|
|
102
|
-
requiresReasoningContentOnAssistantMessages: true,
|
|
103
|
-
...(source.compat ?? {}),
|
|
104
|
-
...(model.compat ?? {}),
|
|
161
|
+
compat,
|
|
162
|
+
modelPolicy: {
|
|
163
|
+
reasoning, supportsReasoningEffort, reasoningEfforts: efforts, defaultReasoningEffort,
|
|
164
|
+
...(internalThinkingEffort === undefined ? {} : { internalThinkingEffort }),
|
|
105
165
|
},
|
|
106
166
|
}
|
|
107
167
|
})
|
|
@@ -111,14 +171,15 @@ export function resolveEnterpriseProfiles(raw = {}) {
|
|
|
111
171
|
displayName,
|
|
112
172
|
baseURL,
|
|
113
173
|
credentialRef,
|
|
114
|
-
reasoning:
|
|
174
|
+
reasoning: providerReasoning,
|
|
115
175
|
maxRequestImageBytes,
|
|
116
176
|
requestImagePixelBudget,
|
|
117
177
|
requestImageMaxBytes,
|
|
118
178
|
streamIdleTimeoutMs: positiveInteger(source.streamIdleTimeoutMs, 300000, `${route}.streamIdleTimeoutMs`),
|
|
119
179
|
retryPolicy: source.retryPolicy ?? ENTERPRISE_DEFAULT_RETRY_POLICY,
|
|
120
180
|
configuredMaxTokens: new Map(models.map(model => [model.id, model.maxTokens])),
|
|
121
|
-
models,
|
|
181
|
+
modelPolicies: new Map(models.map(model => [model.id, model.modelPolicy])),
|
|
182
|
+
models: models.map(({ modelPolicy: _modelPolicy, ...model }) => model),
|
|
122
183
|
}
|
|
123
184
|
})
|
|
124
185
|
}
|
package/lib/provider/index.js
CHANGED
|
@@ -43,24 +43,24 @@ function apiKeyAuth(displayName) {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
function profilesFrom(rawConfig) {
|
|
46
|
+
export function profilesFrom(rawConfig) {
|
|
47
47
|
const profiles = new Map()
|
|
48
48
|
for (const route of resolveEnterpriseProfiles(rawConfig)) {
|
|
49
49
|
profiles.set(route.provider, {
|
|
50
50
|
provider: route.provider,
|
|
51
51
|
displayName: route.displayName,
|
|
52
52
|
apiKeyEnv: route.credentialRef,
|
|
53
|
-
reasoning: route.reasoning,
|
|
54
53
|
maxRequestImageBytes: route.maxRequestImageBytes,
|
|
55
54
|
requestImagePixelBudget: route.requestImagePixelBudget,
|
|
56
55
|
requestImageMaxBytes: route.requestImageMaxBytes,
|
|
57
56
|
streamIdleTimeoutMs: route.streamIdleTimeoutMs,
|
|
58
57
|
retryPolicy: resolveRetryPolicy(route.retryPolicy, `${name}: ${route.provider}.retryPolicy`),
|
|
59
58
|
configuredMaxTokens: route.configuredMaxTokens,
|
|
60
|
-
modelPolicies:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
modelPolicies: route.modelPolicies,
|
|
60
|
+
// DSH 0.1.5-rc.1 reads per-model configuration errors before resolution.
|
|
61
|
+
// Our declarative profiles validate eagerly, so a successfully constructed
|
|
62
|
+
// provider has no deferred errors. Earlier adapters ignore this field.
|
|
63
|
+
modelErrors: new Map(),
|
|
64
64
|
piProvider: createProvider({
|
|
65
65
|
id: route.provider,
|
|
66
66
|
name: route.displayName,
|
|
@@ -111,15 +111,19 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
111
111
|
const transforms = new EnterpriseModelTransforms(ctx)
|
|
112
112
|
let current = () => settingsBase(rawConfig)
|
|
113
113
|
let profiles = profilesFrom(current())
|
|
114
|
-
if (profiles.size === 0) return
|
|
115
114
|
|
|
116
115
|
const baseAdapter = new PiAiAdapter({
|
|
117
116
|
profiles: () => profiles,
|
|
118
117
|
auth: isolatedPiAiAuth(),
|
|
119
118
|
resolveApiKey: async (provider, profile) => {
|
|
119
|
+
if (await rawConfig.beforeResolveCredential?.(provider) === false) {
|
|
120
|
+
throw new LlmError(`${name}: organization sign-in is required for provider route "${provider}"`, 'MISSING_CREDENTIAL')
|
|
121
|
+
}
|
|
120
122
|
const ref = profile.apiKeyEnv
|
|
121
123
|
const credentials = ctx.get('credentials')
|
|
122
|
-
const hit =
|
|
124
|
+
const hit = typeof rawConfig.resolveCredential === 'function'
|
|
125
|
+
? (await rawConfig.resolveCredential(provider))?.value
|
|
126
|
+
: credentials === undefined
|
|
123
127
|
? launchEnvironmentOf(ctx).get(ref)?.value
|
|
124
128
|
: (await credentials.resolve(ref))?.value
|
|
125
129
|
if (typeof hit === 'string' && hit.length > 0) return assertUsableApiKey(hit, name, ref)
|
|
@@ -132,26 +136,34 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
132
136
|
const adapter = new TransformingEnterpriseAdapter(baseAdapter, transforms, (provider, model) => (
|
|
133
137
|
profiles.get(provider)?.modelPolicies.get(model) ?? { reasoning: true, supportsReasoningEffort: true }
|
|
134
138
|
))
|
|
135
|
-
|
|
136
|
-
let directory
|
|
137
|
-
|
|
138
|
-
if (rawConfig.settingsEnabled !== true) {
|
|
139
|
-
installEnterpriseSettings(ctx, rawConfig, { setSource() {}, onChange() {} })
|
|
140
|
-
return
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const refresh = () => {
|
|
144
|
-
const next = profilesFrom(current())
|
|
139
|
+
let registration
|
|
140
|
+
let directory
|
|
141
|
+
const replaceProfiles = next => {
|
|
145
142
|
const previous = profiles
|
|
146
143
|
profiles = next
|
|
147
144
|
try {
|
|
148
|
-
registration.replace([...next.keys()])
|
|
149
|
-
|
|
145
|
+
if (registration) registration.replace([...next.keys()])
|
|
146
|
+
else if (next.size) registration = ctx.llm.registerAdapter([...next.keys()], adapter)
|
|
147
|
+
if (directory) directory.replace(configurableEntries(next))
|
|
148
|
+
else if (next.size) directory = ctx.llm.registerConfigurableProviders(configurableEntries(next))
|
|
150
149
|
} catch (error) {
|
|
151
150
|
profiles = previous
|
|
151
|
+
registration?.replace([...previous.keys()])
|
|
152
152
|
throw error
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
|
+
if (profiles.size) replaceProfiles(profiles)
|
|
156
|
+
const controller = { replaceConfig(config) { replaceProfiles(profilesFrom(config)) } }
|
|
157
|
+
|
|
158
|
+
if (rawConfig.settingsEnabled !== true) {
|
|
159
|
+
installEnterpriseSettings(ctx, rawConfig, { setSource() {}, onChange() {} })
|
|
160
|
+
return controller
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const refresh = () => {
|
|
164
|
+
const next = profilesFrom(current())
|
|
165
|
+
replaceProfiles(next)
|
|
166
|
+
}
|
|
155
167
|
|
|
156
168
|
installEnterpriseSettings(ctx, rawConfig, {
|
|
157
169
|
setSource(source) { current = source },
|
|
@@ -163,6 +175,7 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
163
175
|
}
|
|
164
176
|
},
|
|
165
177
|
})
|
|
178
|
+
return controller
|
|
166
179
|
}
|
|
167
180
|
|
|
168
181
|
export { resolveEnterpriseProfiles } from './core.js'
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { LlmError } from '@deepseek-ai/dsh-llm'
|
|
2
|
+
|
|
1
3
|
/** Delegate one enterprise route while applying capabilities contributed by replaceable feature plugins. */
|
|
2
4
|
export class TransformingEnterpriseAdapter {
|
|
3
5
|
constructor(inner, transforms, modelPolicy = () => ({})) {
|
|
@@ -19,47 +21,66 @@ export class TransformingEnterpriseAdapter {
|
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
async resolveModel(provider, model, signal) {
|
|
22
|
-
|
|
24
|
+
const policy = this.modelPolicy(provider, model)
|
|
25
|
+
return this.decorateModel(provider, model, await this.inner.resolveModel(provider, model, signal), policy)
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
decorateModel(provider, model, resolved) {
|
|
28
|
+
decorateModel(provider, model, resolved, policy = this.modelPolicy(provider, model)) {
|
|
26
29
|
const decorated = {
|
|
27
30
|
...resolved,
|
|
28
31
|
inputModalities: this.transforms.capabilities(provider, model, resolved.inputModalities),
|
|
29
32
|
}
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
if (policy.reasoning !== false && policy.supportsReasoningEffort !== false && Array.isArray(policy.reasoningEfforts)) {
|
|
34
|
+
return {
|
|
35
|
+
...decorated,
|
|
36
|
+
reasoning: {
|
|
37
|
+
efforts: policy.reasoningEfforts.map(id => ({ id, name: `${id.charAt(0).toUpperCase()}${id.slice(1)}` })),
|
|
38
|
+
defaultEffort: policy.defaultReasoningEffort,
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|
|
32
42
|
const { reasoning: _unsupportedReasoningEfforts, ...thinkingOnly } = decorated
|
|
33
43
|
return thinkingOnly
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
async prepareCall(provider, model, signal) {
|
|
47
|
+
const policy = this.modelPolicy(provider, model)
|
|
37
48
|
const prepared = typeof this.inner.prepareCall === 'function'
|
|
38
49
|
? await this.inner.prepareCall(provider, model, signal)
|
|
39
50
|
: { model: await this.inner.resolveModel(provider, model, signal), stream: options => this.inner.stream(options) }
|
|
40
51
|
return {
|
|
41
|
-
model: this.decorateModel(provider, model, prepared.model),
|
|
42
|
-
stream: options => this.streamPrepared(options, prepared),
|
|
52
|
+
model: this.decorateModel(provider, model, prepared.model, policy),
|
|
53
|
+
stream: options => this.streamPrepared(options, prepared, policy),
|
|
43
54
|
}
|
|
44
55
|
}
|
|
45
56
|
|
|
46
|
-
async * streamPrepared(options, prepared) {
|
|
57
|
+
async * streamPrepared(options, prepared, policy = this.modelPolicy(options.provider, options.model)) {
|
|
47
58
|
const nativeModelInfo = prepared.model
|
|
48
59
|
let request = options
|
|
49
|
-
const policy = this.modelPolicy(options.provider, options.model)
|
|
50
60
|
if (policy.reasoning === false) {
|
|
51
61
|
request = { ...options, reasoningEffort: 'off' }
|
|
52
62
|
} else if (policy.supportsReasoningEffort === false) {
|
|
53
63
|
const { reasoningEffort: _unsupportedReasoningEffort, ...withoutEffort } = options
|
|
54
|
-
|
|
64
|
+
// This enables the model's configured thinking mode inside PiAi;
|
|
65
|
+
// compat.supportsReasoningEffort=false suppresses the HTTP effort field.
|
|
66
|
+
request = policy.internalThinkingEffort === undefined
|
|
67
|
+
? withoutEffort
|
|
68
|
+
: { ...withoutEffort, reasoningEffort: policy.internalThinkingEffort }
|
|
69
|
+
} else if (Array.isArray(policy.reasoningEfforts)) {
|
|
70
|
+
const effort = options.reasoningEffort ?? policy.defaultReasoningEffort
|
|
71
|
+
if (!policy.reasoningEfforts.includes(effort)) {
|
|
72
|
+
throw new LlmError(`enterprise model "${options.provider}/${options.model}" does not support reasoning effort "${String(effort)}"`, 'UNSUPPORTED_REASONING_EFFORT')
|
|
73
|
+
}
|
|
74
|
+
request = { ...options, reasoningEffort: effort }
|
|
55
75
|
}
|
|
56
76
|
yield* prepared.stream(await this.transforms.apply(request, nativeModelInfo))
|
|
57
77
|
}
|
|
58
78
|
|
|
59
79
|
async * stream(options) {
|
|
80
|
+
const policy = this.modelPolicy(options.provider, options.model)
|
|
60
81
|
const prepared = typeof this.inner.prepareCall === 'function'
|
|
61
82
|
? await this.inner.prepareCall(options.provider, options.model, options.signal)
|
|
62
83
|
: { model: await this.inner.resolveModel(options.provider, options.model, options.signal), stream: request => this.inner.stream(request) }
|
|
63
|
-
yield* this.streamPrepared(options, prepared)
|
|
84
|
+
yield* this.streamPrepared(options, prepared, policy)
|
|
64
85
|
}
|
|
65
86
|
}
|
package/lib/resources.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { normalizeModels } from './profile.js'
|
|
2
|
+
|
|
3
|
+
// The public resource contract extends the deployed worker wire protocol. OIDC
|
|
4
|
+
// access tokens remain on the management plane; these calls use the model key.
|
|
5
|
+
export function normalizeResourceModels(raw, profile) {
|
|
6
|
+
if (!Array.isArray(raw?.data) || raw.data.length > 128) throw new Error('invalid model catalog')
|
|
7
|
+
const reviewed = new Map(profile.provider.models.map(model => [model.id, model]))
|
|
8
|
+
const models = raw.data.map(row => {
|
|
9
|
+
const id = typeof row?.id === 'string' ? row.id.trim() : ''
|
|
10
|
+
// A plain /models response does not prove reasoning or vision support.
|
|
11
|
+
return reviewed.get(id) ?? { id, name: id, input: ['text'], reasoning: false }
|
|
12
|
+
})
|
|
13
|
+
return normalizeModels(models, profile.provider.id)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function emptyResources(profile) {
|
|
17
|
+
return {
|
|
18
|
+
profileID: profile.id,
|
|
19
|
+
modelSource: profile.provider?.modelSource ?? 'none',
|
|
20
|
+
models: profile.provider?.models ?? [],
|
|
21
|
+
issues: [],
|
|
22
|
+
}
|
|
23
|
+
}
|
package/lib/typert-schemas.js
CHANGED
|
@@ -2,9 +2,20 @@ import { z } from 'zod'
|
|
|
2
2
|
|
|
3
3
|
const pkg = '@eduwork/dsh-oidc'
|
|
4
4
|
export const profileIdSchema = z.string().min(1).max(64)
|
|
5
|
+
export const loginIdSchema = z.string().regex(/^[A-Za-z0-9_-]{32}$/)
|
|
5
6
|
export const reconcileOptionsSchema = z.object({ allowProvision: z.boolean().optional() }).strict()
|
|
6
7
|
export const baseURLSchema = z.string().min(1).max(2048)
|
|
7
8
|
export const modelCatalogModeSchema = z.enum(['discovery', 'manual'])
|
|
9
|
+
export const configurationTargetSchema = z.enum(['config', 'examples'])
|
|
10
|
+
export const openConfigurationResult = Object.freeze({
|
|
11
|
+
mode: 'strict', typeSymbol: `${pkg}#OpenConfigurationResult`,
|
|
12
|
+
schema: z.object({ opened: z.literal(true) }).strict(),
|
|
13
|
+
})
|
|
14
|
+
export const enterpriseSelectionResult = Object.freeze({
|
|
15
|
+
mode: 'strict', typeSymbol: `${pkg}#EnterpriseSelectionResult`,
|
|
16
|
+
schema: z.object({ changed: z.boolean(), selection: z.object({ provider: z.string(), model: z.string(), reasoningEffort: z.string().optional() }).strict().optional() }).strict(),
|
|
17
|
+
})
|
|
18
|
+
export const enterpriseSelectionOptionsSchema = z.object({ onlyIfMissing: z.boolean().optional() }).strict()
|
|
8
19
|
|
|
9
20
|
const brandSchema = z.object({
|
|
10
21
|
productName: z.string().optional(), organizationName: z.string().optional(), mark: z.string().optional(),
|
|
@@ -18,7 +29,7 @@ const publicProfileSchema = z.object({
|
|
|
18
29
|
provider: z.object({
|
|
19
30
|
id: z.string(), displayName: z.string(),
|
|
20
31
|
models: z.array(z.object({ id: z.string(), name: z.string(), input: z.array(z.enum(['text', 'image'])) }).strict()),
|
|
21
|
-
}).strict(),
|
|
32
|
+
}).strict().optional(),
|
|
22
33
|
}).strict()
|
|
23
34
|
|
|
24
35
|
const accountSchema = z.object({
|
|
@@ -33,12 +44,20 @@ export const runtimeModelSchema = z.object({
|
|
|
33
44
|
upstreamModelID: z.string().max(256).optional(), contextWindow: z.number().int().positive().optional(),
|
|
34
45
|
maxTokens: z.number().int().positive().optional(),
|
|
35
46
|
input: z.array(z.enum(['text', 'image', 'audio', 'video'])).optional(),
|
|
36
|
-
reasoning: z.boolean().optional(), reasoningEfforts: z.record(z.string(), z.string().nullable()).optional(),
|
|
47
|
+
reasoning: z.boolean().optional(), reasoningEfforts: z.union([z.literal(false), z.record(z.string(), z.string().nullable())]).optional(),
|
|
48
|
+
defaultReasoningEffort: z.enum(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']).optional(),
|
|
37
49
|
compat: z.record(z.string(), z.unknown()).optional(),
|
|
38
50
|
}).strict()
|
|
39
51
|
|
|
40
52
|
export const runtimeModelsSchema = z.array(runtimeModelSchema).max(128)
|
|
41
53
|
|
|
54
|
+
export const resourcesResult = Object.freeze({
|
|
55
|
+
mode: 'strict', typeSymbol: `${pkg}#Resources`,
|
|
56
|
+
schema: z.object({
|
|
57
|
+
profileID: z.string(), modelSource: z.string(), models: runtimeModelsSchema, issues: z.array(z.string()),
|
|
58
|
+
}).strict(),
|
|
59
|
+
})
|
|
60
|
+
|
|
42
61
|
const managementProfileSchema = z.object({
|
|
43
62
|
id: z.string(), displayName: z.string(), organization: z.string(), baseURL: z.string(),
|
|
44
63
|
builtIn: z.boolean(), configured: z.boolean(), enabled: z.boolean(), providerID: z.string(),
|
|
@@ -51,7 +70,9 @@ const managementProfileSchema = z.object({
|
|
|
51
70
|
|
|
52
71
|
export const configurationResult = Object.freeze({
|
|
53
72
|
mode: 'strict', typeSymbol: `${pkg}#Configuration`,
|
|
54
|
-
schema: z.object({ schemaVersion: z.literal('dsh-oidc/v1alpha1'), uiMode: z.enum(['standard', 'models-only', 'external']), profiles: z.array(publicProfileSchema)
|
|
73
|
+
schema: z.object({ schemaVersion: z.literal('dsh-oidc/v1alpha1'), uiMode: z.enum(['standard', 'models-only', 'external']), profiles: z.array(publicProfileSchema),
|
|
74
|
+
manageProductBrand: z.boolean().optional(), configFile: z.object({ path: z.string(), examplesPath: z.string(), canOpen: z.boolean() }).strict().optional(),
|
|
75
|
+
}).strict(),
|
|
55
76
|
})
|
|
56
77
|
|
|
57
78
|
export const managementResult = Object.freeze({
|
|
@@ -76,9 +97,19 @@ export const beginResult = Object.freeze({
|
|
|
76
97
|
schema: z.union([
|
|
77
98
|
z.object({ mode: z.literal('redirect'), authorizationURL: z.string().url() }).strict(),
|
|
78
99
|
z.object({ mode: z.literal('completed'), status: accountSchema }).strict(),
|
|
100
|
+
z.object({ mode: z.literal('external'), loginID: loginIdSchema, expiresAt: z.string().datetime() }).strict(),
|
|
79
101
|
]),
|
|
80
102
|
})
|
|
81
103
|
|
|
104
|
+
export const loginResult = Object.freeze({
|
|
105
|
+
mode: 'strict', typeSymbol: `${pkg}#LoginResult`,
|
|
106
|
+
schema: z.object({
|
|
107
|
+
loginID: loginIdSchema, profileID: profileIdSchema, expiresAt: z.string().datetime(),
|
|
108
|
+
state: z.enum(['pending', 'completed', 'cancelled', 'expired', 'failed']),
|
|
109
|
+
status: accountSchema.optional(), errorCode: z.string().optional(),
|
|
110
|
+
}).strict(),
|
|
111
|
+
})
|
|
112
|
+
|
|
82
113
|
export function jsonParameter(name, schema, typeSymbol) {
|
|
83
114
|
return Object.freeze({ name, wire: name, source: 'json', codec: Object.freeze({ mode: 'strict', typeSymbol, schema }) })
|
|
84
115
|
}
|