@eduwork/dsh-oidc 0.1.0 → 0.2.1

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 (58) hide show
  1. package/CHANGELOG.en.md +40 -1
  2. package/CHANGELOG.md +40 -1
  3. package/README.en.md +23 -21
  4. package/README.md +17 -15
  5. package/THIRD_PARTY_NOTICES.en.md +7 -5
  6. package/THIRD_PARTY_NOTICES.md +7 -5
  7. package/cordis.patch.yml +2 -1
  8. package/docs/EDUWORK-MIGRATION.en.md +14 -2
  9. package/docs/EDUWORK-MIGRATION.md +14 -2
  10. package/docs/account-extensions.en.md +40 -0
  11. package/docs/account-extensions.md +42 -0
  12. package/docs/architecture.en.md +9 -9
  13. package/docs/architecture.md +11 -10
  14. package/docs/compatibility.en.md +11 -2
  15. package/docs/compatibility.md +11 -2
  16. package/docs/desktop-host.en.md +69 -0
  17. package/docs/desktop-host.md +79 -0
  18. package/docs/development.en.md +42 -0
  19. package/docs/development.md +42 -0
  20. package/docs/dsh-integration.en.md +23 -7
  21. package/docs/dsh-integration.md +23 -7
  22. package/docs/ecnu-reference.en.md +3 -3
  23. package/docs/ecnu-reference.md +3 -3
  24. package/docs/enterprise-profile.en.md +19 -11
  25. package/docs/enterprise-profile.md +18 -10
  26. package/docs/getting-started.en.md +12 -12
  27. package/docs/getting-started.md +16 -14
  28. package/docs/key-binding-protocol.en.md +2 -2
  29. package/docs/key-binding-protocol.md +2 -2
  30. package/docs/oidc-interoperability.en.md +3 -3
  31. package/docs/oidc-interoperability.md +2 -2
  32. package/docs/public-resource-protocol.en.md +51 -0
  33. package/docs/public-resource-protocol.md +50 -0
  34. package/docs/release-checklist.en.md +4 -2
  35. package/docs/release-checklist.md +4 -2
  36. package/docs/server-integration-contract.en.md +124 -8
  37. package/docs/server-integration-contract.md +136 -8
  38. package/examples/ecnu.enterprise-profile.example.json +2 -2
  39. package/examples/enterprise-profile.example.json +78 -77
  40. package/examples/identity-only.example.json +10 -0
  41. package/examples/resources-discovery.example.json +20 -0
  42. package/lib/callback-page.js +42 -0
  43. package/lib/client.js +935 -295
  44. package/lib/desktop-oidc.js +205 -0
  45. package/lib/index.js +148 -18
  46. package/lib/oidc.js +314 -58
  47. package/lib/profile.js +53 -24
  48. package/lib/provider/core.js +76 -15
  49. package/lib/provider/index.js +33 -20
  50. package/lib/provider/transform-adapter.js +31 -10
  51. package/lib/resources.js +23 -0
  52. package/lib/typert-schemas.js +34 -3
  53. package/lib/typert.host.js +42 -36
  54. package/lib/typert.remote-client.js +41 -35
  55. package/package.json +100 -29
  56. package/protocol/openapi.yaml +2 -2
  57. package/protocol/resources.openapi.yaml +51 -0
  58. 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
- ...(model.reasoningEfforts === undefined ? {} : { reasoningEfforts: normalizeReasoningEfforts(model.reasoningEfforts, providerID, id) }),
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 providerCredentialRef(providerID) {
206
- 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`))]
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 keyBinding = object(source.keyBinding, 'profile.keyBinding')
271
- 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')
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
- const credentialRef = keyBinding.credentialRef === undefined
278
- ? providerCredentialRef(providerID)
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.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')
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: 'worker-user-center-v1',
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
- 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
+ }),
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
- if (typeof raw.profilePathEnv === 'string' && environment[raw.profilePathEnv]) {
342
- 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'))
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.credentialRef,
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
  }
@@ -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
  }
@@ -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: new Map(route.models.map(model => [model.id, {
61
- reasoning: model.reasoning,
62
- supportsReasoningEffort: model.compat.supportsReasoningEffort,
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 = credentials === undefined
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
- const registration = ctx.llm.registerAdapter([...profiles.keys()], adapter)
136
- let directory = ctx.llm.registerConfigurableProviders(configurableEntries(profiles))
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
- 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))
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
- 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)
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
- const policy = this.modelPolicy(provider, model)
31
- 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
+ }
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
- 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 }
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
  }
@@ -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
+ }
@@ -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) }).strict(),
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
  }