@eduwork/dsh-oidc 0.1.0-alpha.11 → 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.
Files changed (72) hide show
  1. package/CHANGELOG.en.md +58 -1
  2. package/CHANGELOG.md +58 -1
  3. package/CODE_OF_CONDUCT.en.md +17 -0
  4. package/CODE_OF_CONDUCT.md +17 -0
  5. package/CONTRIBUTING.en.md +56 -0
  6. package/CONTRIBUTING.md +56 -0
  7. package/GOVERNANCE.en.md +41 -0
  8. package/GOVERNANCE.md +41 -0
  9. package/MAINTAINERS.en.md +13 -0
  10. package/MAINTAINERS.md +13 -0
  11. package/README.en.md +24 -22
  12. package/README.md +18 -16
  13. package/SECURITY.en.md +45 -0
  14. package/SECURITY.md +45 -0
  15. package/SUPPORT.en.md +15 -0
  16. package/SUPPORT.md +15 -0
  17. package/THIRD_PARTY_NOTICES.en.md +7 -5
  18. package/THIRD_PARTY_NOTICES.md +7 -5
  19. package/cordis.patch.yml +2 -1
  20. package/docs/EDUWORK-MIGRATION.en.md +17 -5
  21. package/docs/EDUWORK-MIGRATION.md +17 -5
  22. package/docs/account-extensions.en.md +40 -0
  23. package/docs/account-extensions.md +42 -0
  24. package/docs/architecture.en.md +9 -9
  25. package/docs/architecture.md +11 -10
  26. package/docs/compatibility.en.md +13 -3
  27. package/docs/compatibility.md +13 -3
  28. package/docs/decisions/0001-one-plugin-composition-boundary.en.md +1 -1
  29. package/docs/decisions/0001-one-plugin-composition-boundary.md +1 -1
  30. package/docs/desktop-host.en.md +69 -0
  31. package/docs/desktop-host.md +79 -0
  32. package/docs/development.en.md +42 -0
  33. package/docs/development.md +42 -0
  34. package/docs/dsh-integration.en.md +24 -8
  35. package/docs/dsh-integration.md +24 -8
  36. package/docs/ecnu-reference.en.md +3 -3
  37. package/docs/ecnu-reference.md +3 -3
  38. package/docs/enterprise-profile.en.md +21 -13
  39. package/docs/enterprise-profile.md +20 -12
  40. package/docs/getting-started.en.md +13 -17
  41. package/docs/getting-started.md +17 -19
  42. package/docs/key-binding-protocol.en.md +2 -2
  43. package/docs/key-binding-protocol.md +2 -2
  44. package/docs/oidc-interoperability.en.md +3 -3
  45. package/docs/oidc-interoperability.md +2 -2
  46. package/docs/public-resource-protocol.en.md +51 -0
  47. package/docs/public-resource-protocol.md +50 -0
  48. package/docs/release-checklist.en.md +13 -8
  49. package/docs/release-checklist.md +13 -8
  50. package/docs/server-integration-contract.en.md +124 -8
  51. package/docs/server-integration-contract.md +136 -8
  52. package/examples/ecnu.enterprise-profile.example.json +2 -2
  53. package/examples/enterprise-profile.example.json +78 -77
  54. package/examples/identity-only.example.json +10 -0
  55. package/examples/resources-discovery.example.json +20 -0
  56. package/lib/callback-page.js +42 -0
  57. package/lib/client.js +938 -294
  58. package/lib/desktop-oidc.js +205 -0
  59. package/lib/index.js +148 -18
  60. package/lib/oidc.js +314 -58
  61. package/lib/profile.js +96 -39
  62. package/lib/provider/core.js +76 -15
  63. package/lib/provider/index.js +54 -22
  64. package/lib/provider/transform-adapter.js +32 -11
  65. package/lib/resources.js +23 -0
  66. package/lib/typert-schemas.js +34 -3
  67. package/lib/typert.host.js +42 -36
  68. package/lib/typert.remote-client.js +41 -35
  69. package/package.json +117 -23
  70. package/protocol/openapi.yaml +2 -2
  71. package/protocol/resources.openapi.yaml +51 -0
  72. package/schema/enterprise-profile.v1alpha1.schema.json +193 -174
package/lib/profile.js CHANGED
@@ -1,17 +1,24 @@
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_]*$/
7
8
  const colorPattern = /^#[0-9a-fA-F]{6}$/
8
9
  const allowedModalities = new Set(['text', 'image'])
10
+ const allowedThinkingLevels = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
11
+ const allowedMaxTokensFields = new Set(['max_completion_tokens', 'max_tokens'])
12
+ const allowedThinkingFormats = new Set([
13
+ 'openai', 'deepseek', 'openrouter', 'together', 'baseten', 'zai', 'qwen',
14
+ 'chat-template', 'qwen-chat-template', 'string-thinking', 'ant-ling',
15
+ ])
9
16
  const allowedRootKeys = new Set([
10
17
  'schemaVersion', 'id', 'displayName', 'organization', 'nativeInstitutionID',
11
18
  'allowInsecureDevelopment', 'insecureDevelopmentOrigin', 'brand', 'oidc', 'keyBinding', 'provider',
12
19
  ])
13
20
  const allowedOidcKeys = new Set(['issuer', 'clientId', 'scopes'])
14
- const allowedKeyBindingKeys = new Set(['baseURL', 'credentialRef'])
21
+ const allowedKeyBindingKeys = new Set(['type', 'baseURL', 'credentialRef'])
15
22
  const allowedBrandKeys = new Set([
16
23
  'productName', 'organizationName', 'mark', 'logoURL', 'primaryColor',
17
24
  'loginTitle', 'loginDescription', 'supportURL',
@@ -19,11 +26,11 @@ const allowedBrandKeys = new Set([
19
26
  const allowedProviderKeys = new Set([
20
27
  'id', 'displayName', 'adapter', 'baseURL', 'reasoning', 'defaultContextWindow',
21
28
  'defaultMaxTokens', 'maxRequestImageBytes', 'requestImagePixelBudget',
22
- 'requestImageMaxBytes', 'streamIdleTimeoutMs', 'retryPolicy', 'compat', 'models',
29
+ 'requestImageMaxBytes', 'streamIdleTimeoutMs', 'retryPolicy', 'compat', 'models', 'modelSource',
23
30
  ])
24
31
  const allowedModelKeys = new Set([
25
32
  'id', 'name', 'input', 'contextWindow', 'maxTokens', 'reasoning',
26
- 'reasoningEfforts', 'compat',
33
+ 'reasoningEfforts', 'defaultReasoningEffort', 'compat',
27
34
  ])
28
35
  const allowedCompatKeys = new Set([
29
36
  'supportsDeveloperRole', 'supportsStore', 'supportsReasoningEffort',
@@ -141,9 +148,15 @@ function normalizeCompat(value, label) {
141
148
  const result = {}
142
149
  for (const key of allowedCompatKeys) {
143
150
  if (source[key] === undefined) continue
144
- result[key] = key === 'maxTokensField' || key === 'thinkingFormat'
145
- ? text(source[key], `${label}.${key}`, 64)
146
- : boolean(source[key], `${label}.${key}`)
151
+ if (key === 'maxTokensField') {
152
+ result[key] = text(source[key], `${label}.${key}`, 64)
153
+ if (!allowedMaxTokensFields.has(result[key])) throw new Error(`${label}.${key} is not supported by DSH pi-ai`)
154
+ } else if (key === 'thinkingFormat') {
155
+ result[key] = text(source[key], `${label}.${key}`, 64)
156
+ if (!allowedThinkingFormats.has(result[key])) throw new Error(`${label}.${key} is not supported by DSH pi-ai`)
157
+ } else {
158
+ result[key] = boolean(source[key], `${label}.${key}`)
159
+ }
147
160
  }
148
161
  return Object.freeze(result)
149
162
  }
@@ -151,15 +164,18 @@ function normalizeCompat(value, label) {
151
164
  function normalizeReasoningEfforts(value, providerID, modelID) {
152
165
  if (value === false) return false
153
166
  const source = object(value, `${providerID}.${modelID}.reasoningEfforts`)
154
- if (Object.keys(source).length === 0 || Object.keys(source).length > 16) throw new Error(`${providerID}.${modelID}.reasoningEfforts must contain 1-16 entries`)
167
+ if (Object.keys(source).length === 0 || Object.keys(source).length > allowedThinkingLevels.size) {
168
+ throw new Error(`${providerID}.${modelID}.reasoningEfforts must contain 1-${allowedThinkingLevels.size} entries`)
169
+ }
155
170
  return Object.freeze(Object.fromEntries(Object.entries(source).map(([level, wire]) => {
156
- text(level, `${providerID}.${modelID}.reasoningEfforts level`, 32)
171
+ if (!allowedThinkingLevels.has(level)) throw new Error(`${providerID}.${modelID}.reasoningEfforts level ${level} is not supported by DSH pi-ai`)
172
+ if (wire === null && level !== 'off') throw new Error(`${providerID}.${modelID}.reasoningEfforts.${level} may be null only for off`)
157
173
  if (wire !== null) text(wire, `${providerID}.${modelID}.reasoningEfforts.${level}`, 32)
158
174
  return [level, wire]
159
175
  })))
160
176
  }
161
177
 
162
- function normalizeModels(models, providerID) {
178
+ export function normalizeModels(models, providerID) {
163
179
  if (!Array.isArray(models) || models.length === 0 || models.length > 128) throw new Error(`${providerID}.models must contain 1-128 entries`)
164
180
  const seen = new Set()
165
181
  return Object.freeze(models.map((raw, index) => {
@@ -174,6 +190,19 @@ function normalizeModels(models, providerID) {
174
190
  throw new Error(`${providerID}.${id}.input must contain text and/or image`)
175
191
  }
176
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
+ }
177
206
  return Object.freeze({
178
207
  id,
179
208
  name: model.name === undefined ? id : text(model.name, `${providerID}.${id}.name`, 256),
@@ -181,14 +210,15 @@ function normalizeModels(models, providerID) {
181
210
  ...(model.contextWindow === undefined ? {} : { contextWindow: positiveInteger(model.contextWindow, `${providerID}.${id}.contextWindow`) }),
182
211
  ...(model.maxTokens === undefined ? {} : { maxTokens: positiveInteger(model.maxTokens, `${providerID}.${id}.maxTokens`) }),
183
212
  ...(model.reasoning === undefined ? {} : { reasoning: boolean(model.reasoning, `${providerID}.${id}.reasoning`) }),
184
- ...(model.reasoningEfforts === undefined ? {} : { reasoningEfforts: normalizeReasoningEfforts(model.reasoningEfforts, providerID, id) }),
213
+ ...(normalizedReasoningEfforts === undefined ? {} : { reasoningEfforts: normalizedReasoningEfforts }),
214
+ ...(defaultReasoningEffort === undefined ? {} : { defaultReasoningEffort }),
185
215
  ...(model.compat === undefined ? {} : { compat: normalizeCompat(model.compat, `${providerID}.${id}.compat`) }),
186
216
  })
187
217
  }))
188
218
  }
189
219
 
190
- function providerCredentialRef(providerID) {
191
- return `${providerID.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_API_KEY`
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`))]
192
222
  }
193
223
 
194
224
  function normalizeRetryPolicy(value) {
@@ -252,20 +282,29 @@ export function normalizeEnterpriseProfile(raw) {
252
282
  throw new Error('profile.insecureDevelopmentOrigin requires allowInsecureDevelopment=true')
253
283
  }
254
284
  const oidc = object(source.oidc, 'profile.oidc')
255
- const keyBinding = object(source.keyBinding, 'profile.keyBinding')
256
- const provider = object(source.provider, 'profile.provider')
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')
257
291
  exactKeys(oidc, allowedOidcKeys, 'profile.oidc')
258
292
  exactKeys(keyBinding, allowedKeyBindingKeys, 'profile.keyBinding')
259
293
  exactKeys(provider, allowedProviderKeys, 'profile.provider')
260
- const providerID = text(provider.id, 'profile.provider.id', 64)
294
+ const providerID = identityOnly ? id : text(provider.id, 'profile.provider.id', 64)
261
295
  if (!idPattern.test(providerID)) throw new Error('profile.provider.id is invalid')
262
- const credentialRef = keyBinding.credentialRef === undefined
263
- ? providerCredentialRef(providerID)
296
+ let credentialRef = keyBinding.credentialRef === undefined
297
+ ? DEFAULT_CREDENTIAL_REF
264
298
  : text(keyBinding.credentialRef, 'profile.keyBinding.credentialRef', 128)
265
299
  if (!credentialPattern.test(credentialRef)) {
266
300
  throw new Error('profile.keyBinding.credentialRef must be a valid DSH credential reference')
267
301
  }
268
- if (provider.adapter !== 'openai-compatible') throw new Error(`unsupported provider adapter ${String(provider.adapter)}`)
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')
269
308
  const scopes = oidc.scopes
270
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))) {
271
310
  throw new Error('profile.oidc.scopes must contain openid and profile; every scope must be a non-empty token')
@@ -285,8 +324,8 @@ export function normalizeEnterpriseProfile(raw) {
285
324
  clientId: text(oidc.clientId, 'profile.oidc.clientId', 256),
286
325
  scopes: Object.freeze([...scopes]),
287
326
  }),
288
- keyBinding: Object.freeze({
289
- type: 'worker-user-center-v1',
327
+ ...(identityOnly ? {} : { keyBinding: Object.freeze({
328
+ type: protocol,
290
329
  baseURL: exactURL(keyBinding.baseURL, 'profile.keyBinding.baseURL', allowInsecureDevelopment, allowedInsecureOrigin),
291
330
  providerId: providerID,
292
331
  credentialRef,
@@ -296,7 +335,13 @@ export function normalizeEnterpriseProfile(raw) {
296
335
  displayName: text(provider.displayName ?? source.displayName, 'profile.provider.displayName', 120),
297
336
  adapter: provider.adapter,
298
337
  baseURL: exactURL(provider.baseURL, 'profile.provider.baseURL', allowInsecureDevelopment, allowedInsecureOrigin),
299
- reasoning: provider.reasoning === undefined ? 'high' : text(provider.reasoning, 'profile.provider.reasoning', 32),
338
+ reasoning: provider.reasoning === undefined
339
+ ? 'high'
340
+ : (() => {
341
+ const reasoning = text(provider.reasoning, 'profile.provider.reasoning', 32)
342
+ if (!allowedThinkingLevels.has(reasoning)) throw new Error('profile.provider.reasoning is not supported by DSH pi-ai')
343
+ return reasoning
344
+ })(),
300
345
  defaultContextWindow: optionalPositiveInteger(provider.defaultContextWindow, 'profile.provider.defaultContextWindow'),
301
346
  defaultMaxTokens: optionalPositiveInteger(provider.defaultMaxTokens, 'profile.provider.defaultMaxTokens'),
302
347
  maxRequestImageBytes: optionalPositiveInteger(provider.maxRequestImageBytes, 'profile.provider.maxRequestImageBytes'),
@@ -305,7 +350,10 @@ export function normalizeEnterpriseProfile(raw) {
305
350
  streamIdleTimeoutMs: optionalPositiveInteger(provider.streamIdleTimeoutMs, 'profile.provider.streamIdleTimeoutMs'),
306
351
  retryPolicy: provider.retryPolicy === undefined ? undefined : normalizeRetryPolicy(provider.retryPolicy),
307
352
  compat: provider.compat === undefined ? undefined : normalizeCompat(provider.compat, 'profile.provider.compat'),
308
- models: normalizeModels(provider.models, providerID),
353
+ modelSource,
354
+ models: modelSource === 'discovery' && (provider.models === undefined || provider.models.length === 0)
355
+ ? Object.freeze([]) : normalizeModels(provider.models, providerID),
356
+ }),
309
357
  }),
310
358
  })
311
359
  }
@@ -317,8 +365,10 @@ export function loadEnterpriseProfiles(raw = {}, environment = process.env) {
317
365
  if (!Array.isArray(raw.profiles)) throw new Error('dsh-oidc profiles must be an array')
318
366
  profiles.push(...raw.profiles.map(normalizeEnterpriseProfile))
319
367
  }
320
- if (typeof raw.profilePathEnv === 'string' && environment[raw.profilePathEnv]) {
321
- const parsed = JSON.parse(readFileSync(environment[raw.profilePathEnv], 'utf8'))
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'))
322
372
  const rows = Array.isArray(parsed) ? parsed : parsed.profiles ?? [parsed]
323
373
  profiles.push(...rows.map(normalizeEnterpriseProfile))
324
374
  }
@@ -326,31 +376,38 @@ export function loadEnterpriseProfiles(raw = {}, environment = process.env) {
326
376
  const providers = new Set()
327
377
  for (const profile of profiles) {
328
378
  if (unique.has(profile.id)) throw new Error(`dsh-oidc repeats profile ${profile.id}`)
329
- 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}`)
330
380
  unique.set(profile.id, profile)
331
- providers.add(profile.provider.id)
381
+ if (profile.provider) providers.add(profile.provider.id)
332
382
  }
333
383
  return unique
334
384
  }
335
385
 
336
386
  export function enterpriseProviderConfig(profiles) {
337
387
  return {
338
- 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, {
339
389
  displayName: profile.provider.displayName,
340
390
  apiKeyEnv: profile.keyBinding.credentialRef,
341
391
  baseURL: profile.provider.baseURL,
342
392
  allowInsecureDevelopment: profile.allowInsecureDevelopment,
343
393
  ...(profile.insecureDevelopmentOrigin === undefined ? {} : { insecureDevelopmentOrigin: profile.insecureDevelopmentOrigin }),
344
394
  reasoning: profile.provider.reasoning,
345
- defaultContextWindow: profile.provider.defaultContextWindow,
346
- defaultMaxTokens: profile.provider.defaultMaxTokens,
347
- maxRequestImageBytes: profile.provider.maxRequestImageBytes,
348
- requestImagePixelBudget: profile.provider.requestImagePixelBudget,
349
- requestImageMaxBytes: profile.provider.requestImageMaxBytes,
350
- streamIdleTimeoutMs: profile.provider.streamIdleTimeoutMs,
351
- retryPolicy: profile.provider.retryPolicy,
352
- compat: profile.provider.compat,
353
- models: profile.provider.models,
395
+ ...(profile.provider.defaultContextWindow === undefined ? {} : { defaultContextWindow: profile.provider.defaultContextWindow }),
396
+ ...(profile.provider.defaultMaxTokens === undefined ? {} : { defaultMaxTokens: profile.provider.defaultMaxTokens }),
397
+ ...(profile.provider.maxRequestImageBytes === undefined ? {} : { maxRequestImageBytes: profile.provider.maxRequestImageBytes }),
398
+ ...(profile.provider.requestImagePixelBudget === undefined ? {} : { requestImagePixelBudget: profile.provider.requestImagePixelBudget }),
399
+ ...(profile.provider.requestImageMaxBytes === undefined ? {} : { requestImageMaxBytes: profile.provider.requestImageMaxBytes }),
400
+ ...(profile.provider.streamIdleTimeoutMs === undefined ? {} : { streamIdleTimeoutMs: profile.provider.streamIdleTimeoutMs }),
401
+ ...(profile.provider.retryPolicy === undefined ? {} : { retryPolicy: structuredClone(profile.provider.retryPolicy) }),
402
+ ...(profile.provider.compat === undefined ? {} : { compat: { ...profile.provider.compat } }),
403
+ models: profile.provider.models.map(model => ({
404
+ ...model,
405
+ input: [...model.input],
406
+ ...(model.reasoningEfforts && typeof model.reasoningEfforts === 'object'
407
+ ? { reasoningEfforts: { ...model.reasoningEfforts } }
408
+ : {}),
409
+ ...(model.compat === undefined ? {} : { compat: { ...model.compat } }),
410
+ })),
354
411
  }])),
355
412
  }
356
413
  }
@@ -361,11 +418,11 @@ export function publicProfile(profile) {
361
418
  displayName: profile.displayName,
362
419
  organization: profile.organization,
363
420
  brand: profile.brand,
364
- credentialRef: profile.keyBinding.credentialRef,
365
- provider: {
421
+ credentialRef: profile.keyBinding?.credentialRef ?? '',
422
+ ...(profile.provider ? { provider: {
366
423
  id: profile.provider.id,
367
424
  displayName: profile.provider.displayName,
368
425
  models: profile.provider.models.map(model => ({ id: model.id, name: model.name, input: model.input })),
369
- },
426
+ } } : {}),
370
427
  }
371
428
  }
@@ -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
- return Object.fromEntries(Object.entries(source).map(([key, level]) => [key, level ?? null]))
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
- supportsDeveloperRole: false,
95
- supportsStore: false,
96
- supportsReasoningEffort: reasoning,
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: source.reasoning ?? 'high',
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
  }
@@ -1,5 +1,5 @@
1
1
  import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
2
- import { LlmError, assertUsableApiKey, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
2
+ import { LlmError, assertUsableApiKey, resolveImageAttachmentAccess, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
3
3
  import { Config as PiAiConfig, PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
4
4
  import { createProvider } from '@earendil-works/pi-ai'
5
5
  import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy'
@@ -22,7 +22,13 @@ function installEnterpriseSettings(ctx, rawConfig, hooks) {
22
22
  SETTINGS_NAMESPACE,
23
23
  PiAiConfig,
24
24
  settingsBase(rawConfig),
25
- hooks,
25
+ {
26
+ ...hooks,
27
+ validate(value) {
28
+ assertServiceableEnterpriseProviders(value)
29
+ hooks.validate?.(value)
30
+ },
31
+ },
26
32
  )
27
33
  })
28
34
  }
@@ -37,24 +43,24 @@ function apiKeyAuth(displayName) {
37
43
  }
38
44
  }
39
45
 
40
- function profilesFrom(rawConfig) {
46
+ export function profilesFrom(rawConfig) {
41
47
  const profiles = new Map()
42
48
  for (const route of resolveEnterpriseProfiles(rawConfig)) {
43
49
  profiles.set(route.provider, {
44
50
  provider: route.provider,
45
51
  displayName: route.displayName,
46
52
  apiKeyEnv: route.credentialRef,
47
- reasoning: route.reasoning,
48
53
  maxRequestImageBytes: route.maxRequestImageBytes,
49
54
  requestImagePixelBudget: route.requestImagePixelBudget,
50
55
  requestImageMaxBytes: route.requestImageMaxBytes,
51
56
  streamIdleTimeoutMs: route.streamIdleTimeoutMs,
52
57
  retryPolicy: resolveRetryPolicy(route.retryPolicy, `${name}: ${route.provider}.retryPolicy`),
53
58
  configuredMaxTokens: route.configuredMaxTokens,
54
- modelPolicies: new Map(route.models.map(model => [model.id, {
55
- reasoning: model.reasoning,
56
- supportsReasoningEffort: model.compat.supportsReasoningEffort,
57
- }])),
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(),
58
64
  piProvider: createProvider({
59
65
  id: route.provider,
60
66
  name: route.displayName,
@@ -68,6 +74,10 @@ function profilesFrom(rawConfig) {
68
74
  return profiles
69
75
  }
70
76
 
77
+ export function assertServiceableEnterpriseProviders(rawConfig) {
78
+ profilesFrom(rawConfig)
79
+ }
80
+
71
81
  function isolatedPiAiAuth() {
72
82
  const stored = new Map()
73
83
  return {
@@ -89,50 +99,71 @@ function isolatedPiAiAuth() {
89
99
  }
90
100
  }
91
101
 
102
+ export function resolveEnterpriseImageAccess(ctx, attachments, ref) {
103
+ return resolveImageAttachmentAccess(
104
+ attachments,
105
+ hostPath => ctx.get('fs')?.processPathFromHostPath(hostPath),
106
+ ref,
107
+ )
108
+ }
109
+
92
110
  export function apply(ctx, rawConfig = {}) {
93
111
  const transforms = new EnterpriseModelTransforms(ctx)
94
112
  let current = () => settingsBase(rawConfig)
95
113
  let profiles = profilesFrom(current())
96
- if (profiles.size === 0) return
97
114
 
98
115
  const baseAdapter = new PiAiAdapter({
99
116
  profiles: () => profiles,
100
117
  auth: isolatedPiAiAuth(),
101
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
+ }
102
122
  const ref = profile.apiKeyEnv
103
123
  const credentials = ctx.get('credentials')
104
- const hit = credentials === undefined
124
+ const hit = typeof rawConfig.resolveCredential === 'function'
125
+ ? (await rawConfig.resolveCredential(provider))?.value
126
+ : credentials === undefined
105
127
  ? launchEnvironmentOf(ctx).get(ref)?.value
106
128
  : (await credentials.resolve(ref))?.value
107
129
  if (typeof hit === 'string' && hit.length > 0) return assertUsableApiKey(hit, name, ref)
108
130
  throw new LlmError(`${name}: no credential for provider route "${provider}" (${ref})`, 'MISSING_CREDENTIAL')
109
131
  },
110
132
  resolveAttachments: () => ctx.get('attachments'),
133
+ resolveImageAccess: (attachments, ref) => resolveEnterpriseImageAccess(ctx, attachments, ref),
111
134
  })
112
135
 
113
136
  const adapter = new TransformingEnterpriseAdapter(baseAdapter, transforms, (provider, model) => (
114
137
  profiles.get(provider)?.modelPolicies.get(model) ?? { reasoning: true, supportsReasoningEffort: true }
115
138
  ))
116
- const registration = ctx.llm.registerAdapter([...profiles.keys()], adapter)
117
- let directory = ctx.llm.registerConfigurableProviders(configurableEntries(profiles))
118
-
119
- if (rawConfig.settingsEnabled !== true) {
120
- installEnterpriseSettings(ctx, rawConfig, { setSource() {}, onChange() {} })
121
- return
122
- }
123
-
124
- const refresh = () => {
125
- const next = profilesFrom(current())
139
+ let registration
140
+ let directory
141
+ const replaceProfiles = next => {
126
142
  const previous = profiles
127
143
  profiles = next
128
144
  try {
129
- registration.replace([...next.keys()])
130
- directory.replace(configurableEntries(next))
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))
131
149
  } catch (error) {
132
150
  profiles = previous
151
+ registration?.replace([...previous.keys()])
133
152
  throw error
134
153
  }
135
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
+ }
136
167
 
137
168
  installEnterpriseSettings(ctx, rawConfig, {
138
169
  setSource(source) { current = source },
@@ -144,6 +175,7 @@ export function apply(ctx, rawConfig = {}) {
144
175
  }
145
176
  },
146
177
  })
178
+ return controller
147
179
  }
148
180
 
149
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 = () => ({})) {
@@ -8,6 +10,7 @@ export class TransformingEnterpriseAdapter {
8
10
 
9
11
  providerInfo(provider) { return this.inner.providerInfo(provider) }
10
12
  providerRetryPolicy(provider) { return this.inner.providerRetryPolicy(provider) }
13
+ imageRequestPricing(provider, model) { return this.inner.imageRequestPricing(provider, model) }
11
14
 
12
15
  async listModels(provider) {
13
16
  const models = await this.inner.listModels(provider)
@@ -18,48 +21,66 @@ export class TransformingEnterpriseAdapter {
18
21
  }
19
22
 
20
23
  async resolveModel(provider, model, signal) {
21
- return this.decorateModel(provider, model, await this.inner.resolveModel(provider, model, signal))
24
+ const policy = this.modelPolicy(provider, model)
25
+ return this.decorateModel(provider, model, await this.inner.resolveModel(provider, model, signal), policy)
22
26
  }
23
27
 
24
- decorateModel(provider, model, resolved) {
28
+ decorateModel(provider, model, resolved, policy = this.modelPolicy(provider, model)) {
25
29
  const decorated = {
26
30
  ...resolved,
27
31
  inputModalities: this.transforms.capabilities(provider, model, resolved.inputModalities),
28
32
  }
29
- const policy = this.modelPolicy(provider, model)
30
- if (policy.reasoning === false || policy.supportsReasoningEffort !== false) return decorated
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
+ }
31
42
  const { reasoning: _unsupportedReasoningEfforts, ...thinkingOnly } = decorated
32
43
  return thinkingOnly
33
44
  }
34
45
 
35
46
  async prepareCall(provider, model, signal) {
47
+ const policy = this.modelPolicy(provider, model)
36
48
  const prepared = typeof this.inner.prepareCall === 'function'
37
49
  ? await this.inner.prepareCall(provider, model, signal)
38
50
  : { model: await this.inner.resolveModel(provider, model, signal), stream: options => this.inner.stream(options) }
39
51
  return {
40
- model: this.decorateModel(provider, model, prepared.model),
41
- stream: options => this.streamPrepared(options, prepared),
52
+ model: this.decorateModel(provider, model, prepared.model, policy),
53
+ stream: options => this.streamPrepared(options, prepared, policy),
42
54
  }
43
55
  }
44
56
 
45
- async * streamPrepared(options, prepared) {
57
+ async * streamPrepared(options, prepared, policy = this.modelPolicy(options.provider, options.model)) {
46
58
  const nativeModelInfo = prepared.model
47
59
  let request = options
48
- const policy = this.modelPolicy(options.provider, options.model)
49
60
  if (policy.reasoning === false) {
50
61
  request = { ...options, reasoningEffort: 'off' }
51
62
  } else if (policy.supportsReasoningEffort === false) {
52
63
  const { reasoningEffort: _unsupportedReasoningEffort, ...withoutEffort } = options
53
- request = withoutEffort
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 }
54
75
  }
55
76
  yield* prepared.stream(await this.transforms.apply(request, nativeModelInfo))
56
77
  }
57
78
 
58
79
  async * stream(options) {
80
+ const policy = this.modelPolicy(options.provider, options.model)
59
81
  const prepared = typeof this.inner.prepareCall === 'function'
60
82
  ? await this.inner.prepareCall(options.provider, options.model, options.signal)
61
83
  : { model: await this.inner.resolveModel(options.provider, options.model, options.signal), stream: request => this.inner.stream(request) }
62
- yield* this.streamPrepared(options, prepared)
84
+ yield* this.streamPrepared(options, prepared, policy)
63
85
  }
64
86
  }
65
-