@eduwork/dsh-oidc 0.1.0-alpha.11

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 (50) hide show
  1. package/CHANGELOG.en.md +120 -0
  2. package/CHANGELOG.md +120 -0
  3. package/LICENSE +21 -0
  4. package/README.en.md +184 -0
  5. package/README.md +143 -0
  6. package/THIRD_PARTY_NOTICES.en.md +51 -0
  7. package/THIRD_PARTY_NOTICES.md +53 -0
  8. package/cordis.patch.yml +8 -0
  9. package/docs/EDUWORK-MIGRATION.en.md +23 -0
  10. package/docs/EDUWORK-MIGRATION.md +23 -0
  11. package/docs/architecture.en.md +125 -0
  12. package/docs/architecture.md +125 -0
  13. package/docs/compatibility.en.md +61 -0
  14. package/docs/compatibility.md +61 -0
  15. package/docs/decisions/0001-one-plugin-composition-boundary.en.md +34 -0
  16. package/docs/decisions/0001-one-plugin-composition-boundary.md +34 -0
  17. package/docs/dsh-integration.en.md +171 -0
  18. package/docs/dsh-integration.md +171 -0
  19. package/docs/ecnu-reference.en.md +66 -0
  20. package/docs/ecnu-reference.md +66 -0
  21. package/docs/enterprise-profile.en.md +159 -0
  22. package/docs/enterprise-profile.md +159 -0
  23. package/docs/getting-started.en.md +137 -0
  24. package/docs/getting-started.md +239 -0
  25. package/docs/key-binding-protocol.en.md +227 -0
  26. package/docs/key-binding-protocol.md +227 -0
  27. package/docs/oidc-interoperability.en.md +100 -0
  28. package/docs/oidc-interoperability.md +100 -0
  29. package/docs/release-checklist.en.md +60 -0
  30. package/docs/release-checklist.md +60 -0
  31. package/docs/security-model.en.md +138 -0
  32. package/docs/security-model.md +138 -0
  33. package/docs/server-integration-contract.en.md +706 -0
  34. package/docs/server-integration-contract.md +706 -0
  35. package/examples/ecnu.enterprise-profile.example.json +81 -0
  36. package/examples/enterprise-profile.example.json +77 -0
  37. package/lib/client.js +5855 -0
  38. package/lib/index.js +59 -0
  39. package/lib/oidc.js +649 -0
  40. package/lib/profile.js +371 -0
  41. package/lib/provider/core.js +138 -0
  42. package/lib/provider/index.js +156 -0
  43. package/lib/provider/transform-adapter.js +65 -0
  44. package/lib/provider/transforms.js +59 -0
  45. package/lib/typert-schemas.js +84 -0
  46. package/lib/typert.host.js +38 -0
  47. package/lib/typert.remote-client.js +37 -0
  48. package/package.json +120 -0
  49. package/protocol/openapi.yaml +257 -0
  50. package/schema/enterprise-profile.v1alpha1.schema.json +174 -0
package/lib/profile.js ADDED
@@ -0,0 +1,371 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ export const PROFILE_SCHEMA_VERSION = 'dsh-oidc/v1alpha1'
4
+ const MAX_PROFILE_BYTES = 512 * 1024
5
+ const idPattern = /^[a-z][a-z0-9-]{0,63}$/
6
+ const credentialPattern = /^[A-Za-z_][A-Za-z0-9_]*$/
7
+ const colorPattern = /^#[0-9a-fA-F]{6}$/
8
+ const allowedModalities = new Set(['text', 'image'])
9
+ const allowedRootKeys = new Set([
10
+ 'schemaVersion', 'id', 'displayName', 'organization', 'nativeInstitutionID',
11
+ 'allowInsecureDevelopment', 'insecureDevelopmentOrigin', 'brand', 'oidc', 'keyBinding', 'provider',
12
+ ])
13
+ const allowedOidcKeys = new Set(['issuer', 'clientId', 'scopes'])
14
+ const allowedKeyBindingKeys = new Set(['baseURL', 'credentialRef'])
15
+ const allowedBrandKeys = new Set([
16
+ 'productName', 'organizationName', 'mark', 'logoURL', 'primaryColor',
17
+ 'loginTitle', 'loginDescription', 'supportURL',
18
+ ])
19
+ const allowedProviderKeys = new Set([
20
+ 'id', 'displayName', 'adapter', 'baseURL', 'reasoning', 'defaultContextWindow',
21
+ 'defaultMaxTokens', 'maxRequestImageBytes', 'requestImagePixelBudget',
22
+ 'requestImageMaxBytes', 'streamIdleTimeoutMs', 'retryPolicy', 'compat', 'models',
23
+ ])
24
+ const allowedModelKeys = new Set([
25
+ 'id', 'name', 'input', 'contextWindow', 'maxTokens', 'reasoning',
26
+ 'reasoningEfforts', 'compat',
27
+ ])
28
+ const allowedCompatKeys = new Set([
29
+ 'supportsDeveloperRole', 'supportsStore', 'supportsReasoningEffort',
30
+ 'supportsUsageInStreaming', 'maxTokensField', 'supportsStrictMode',
31
+ 'supportsLongCacheRetention', 'thinkingFormat',
32
+ 'requiresReasoningContentOnAssistantMessages',
33
+ ])
34
+
35
+ function object(value, label) {
36
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(`${label} must be an object`)
37
+ return value
38
+ }
39
+
40
+ function exactKeys(value, allowed, label) {
41
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`${label}.${key} is not allowed`)
42
+ }
43
+
44
+ function text(value, label, max = 2048) {
45
+ if (typeof value !== 'string' || value.trim() === '' || value !== value.trim() || value.length > max) {
46
+ throw new Error(`${label} must be a non-empty trimmed string no longer than ${max} characters`)
47
+ }
48
+ return value
49
+ }
50
+
51
+ function isLoopback(hostname) {
52
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'
53
+ }
54
+
55
+ function insecureDevelopmentOrigin(value, label) {
56
+ const raw = text(value, label)
57
+ const parsed = new URL(raw)
58
+ if (parsed.protocol !== 'http:' || parsed.username || parsed.password || parsed.search || parsed.hash || (parsed.pathname !== '/' && parsed.pathname !== '')) {
59
+ throw new Error(`${label} must be an exact HTTP origin without credentials, path, query, or fragment`)
60
+ }
61
+ return raw.replace(/\/+$/, '')
62
+ }
63
+
64
+ function endpointProtocolAllowed(parsed, allowInsecure, allowedInsecureOrigin) {
65
+ return parsed.protocol === 'https:' || (allowInsecure && parsed.protocol === 'http:' && (
66
+ isLoopback(parsed.hostname) || parsed.origin === allowedInsecureOrigin
67
+ ))
68
+ }
69
+
70
+ function issuerURL(value, label, allowInsecure = false, allowedInsecureOrigin) {
71
+ const raw = text(value, label)
72
+ const parsed = new URL(raw)
73
+ const permitted = endpointProtocolAllowed(parsed, allowInsecure, allowedInsecureOrigin)
74
+ if (!permitted || parsed.username || parsed.password || parsed.search || parsed.hash) {
75
+ throw new Error(`${label} must be an HTTPS issuer URL without credentials, query, or fragment${allowInsecure ? ' (loopback or the exact allowlisted HTTP origin is allowed in development)' : ''}`)
76
+ }
77
+ return raw
78
+ }
79
+
80
+ function exactURL(value, label, allowInsecure = false, allowedInsecureOrigin) {
81
+ const raw = text(value, label)
82
+ const parsed = new URL(raw)
83
+ const permitted = endpointProtocolAllowed(parsed, allowInsecure, allowedInsecureOrigin)
84
+ if (!permitted || parsed.username || parsed.password || parsed.hash || parsed.search) {
85
+ throw new Error(`${label} must be an absolute HTTPS URL without credentials, query, or fragment${allowInsecure ? ' (loopback or the exact allowlisted HTTP origin is allowed in development)' : ''}`)
86
+ }
87
+ return raw.replace(/\/+$/, '')
88
+ }
89
+
90
+ function safeHTTPSResource(value, label, max) {
91
+ const raw = text(value, label, max)
92
+ const parsed = new URL(raw)
93
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.hash) {
94
+ throw new Error(`${label} must be an HTTPS URL without credentials or fragment`)
95
+ }
96
+ return raw
97
+ }
98
+
99
+ function positiveInteger(value, label) {
100
+ if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${label} must be a positive safe integer`)
101
+ return value
102
+ }
103
+
104
+ function optionalPositiveInteger(value, label) {
105
+ return value === undefined ? undefined : positiveInteger(value, label)
106
+ }
107
+
108
+ function boolean(value, label) {
109
+ if (typeof value !== 'boolean') throw new Error(`${label} must be a boolean`)
110
+ return value
111
+ }
112
+
113
+ function normalizeBrand(value = {}) {
114
+ const source = object(value, 'brand')
115
+ exactKeys(source, allowedBrandKeys, 'brand')
116
+ const result = {}
117
+ if (source.productName !== undefined) result.productName = text(source.productName, 'brand.productName', 80)
118
+ if (source.organizationName !== undefined) result.organizationName = text(source.organizationName, 'brand.organizationName', 120)
119
+ if (source.mark !== undefined) result.mark = text(source.mark, 'brand.mark', 4)
120
+ if (source.primaryColor !== undefined) {
121
+ if (typeof source.primaryColor !== 'string' || !colorPattern.test(source.primaryColor)) throw new Error('brand.primaryColor must be a six-digit hex color')
122
+ result.primaryColor = source.primaryColor.toLowerCase()
123
+ }
124
+ if (source.logoURL !== undefined) {
125
+ const logo = text(source.logoURL, 'brand.logoURL', 128 * 1024)
126
+ if (!/^https:\/\//.test(logo) && !/^data:image\/(?:png|webp);base64,[A-Za-z0-9+/]+=*$/.test(logo)) {
127
+ throw new Error('brand.logoURL must be HTTPS or a base64 PNG/WebP data URL')
128
+ }
129
+ result.logoURL = logo.startsWith('https://') ? safeHTTPSResource(logo, 'brand.logoURL', 128 * 1024) : logo
130
+ }
131
+ for (const key of ['loginTitle', 'loginDescription']) {
132
+ if (source[key] !== undefined) result[key] = text(source[key], `brand.${key}`, key === 'loginTitle' ? 120 : 500)
133
+ }
134
+ if (source.supportURL !== undefined) result.supportURL = exactURL(source.supportURL, 'brand.supportURL')
135
+ return Object.freeze(result)
136
+ }
137
+
138
+ function normalizeCompat(value, label) {
139
+ const source = object(value, label)
140
+ exactKeys(source, allowedCompatKeys, label)
141
+ const result = {}
142
+ for (const key of allowedCompatKeys) {
143
+ 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}`)
147
+ }
148
+ return Object.freeze(result)
149
+ }
150
+
151
+ function normalizeReasoningEfforts(value, providerID, modelID) {
152
+ if (value === false) return false
153
+ 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`)
155
+ return Object.freeze(Object.fromEntries(Object.entries(source).map(([level, wire]) => {
156
+ text(level, `${providerID}.${modelID}.reasoningEfforts level`, 32)
157
+ if (wire !== null) text(wire, `${providerID}.${modelID}.reasoningEfforts.${level}`, 32)
158
+ return [level, wire]
159
+ })))
160
+ }
161
+
162
+ function normalizeModels(models, providerID) {
163
+ if (!Array.isArray(models) || models.length === 0 || models.length > 128) throw new Error(`${providerID}.models must contain 1-128 entries`)
164
+ const seen = new Set()
165
+ return Object.freeze(models.map((raw, index) => {
166
+ const label = `${providerID}.models[${index}]`
167
+ const model = object(raw, label)
168
+ exactKeys(model, allowedModelKeys, label)
169
+ const id = text(model.id, `${label}.id`, 256)
170
+ if (seen.has(id)) throw new Error(`${providerID} repeats model ${id}`)
171
+ seen.add(id)
172
+ const input = model.input ?? ['text']
173
+ if (!Array.isArray(input) || input.length === 0 || input.some(value => !allowedModalities.has(value))) {
174
+ throw new Error(`${providerID}.${id}.input must contain text and/or image`)
175
+ }
176
+ if (new Set(input).size !== input.length) throw new Error(`${providerID}.${id}.input contains duplicates`)
177
+ return Object.freeze({
178
+ id,
179
+ name: model.name === undefined ? id : text(model.name, `${providerID}.${id}.name`, 256),
180
+ input: Object.freeze([...new Set(input)]),
181
+ ...(model.contextWindow === undefined ? {} : { contextWindow: positiveInteger(model.contextWindow, `${providerID}.${id}.contextWindow`) }),
182
+ ...(model.maxTokens === undefined ? {} : { maxTokens: positiveInteger(model.maxTokens, `${providerID}.${id}.maxTokens`) }),
183
+ ...(model.reasoning === undefined ? {} : { reasoning: boolean(model.reasoning, `${providerID}.${id}.reasoning`) }),
184
+ ...(model.reasoningEfforts === undefined ? {} : { reasoningEfforts: normalizeReasoningEfforts(model.reasoningEfforts, providerID, id) }),
185
+ ...(model.compat === undefined ? {} : { compat: normalizeCompat(model.compat, `${providerID}.${id}.compat`) }),
186
+ })
187
+ }))
188
+ }
189
+
190
+ function providerCredentialRef(providerID) {
191
+ return `${providerID.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_API_KEY`
192
+ }
193
+
194
+ function normalizeRetryPolicy(value) {
195
+ const source = object(value, 'profile.provider.retryPolicy')
196
+ exactKeys(source, new Set(['mode', 'maxRetries', 'retryableCodes', 'backoff']), 'profile.provider.retryPolicy')
197
+ if (!['normal', 'always'].includes(source.mode)) throw new Error('profile.provider.retryPolicy.mode must be normal or always')
198
+ if (source.maxRetries !== undefined && (!Number.isSafeInteger(source.maxRetries) || source.maxRetries < 0)) {
199
+ throw new Error('profile.provider.retryPolicy.maxRetries must be a non-negative safe integer')
200
+ }
201
+ if (source.retryableCodes !== undefined) {
202
+ if (!Array.isArray(source.retryableCodes) || source.retryableCodes.length === 0 || source.retryableCodes.length > 64
203
+ || source.retryableCodes.some(code => typeof code !== 'string' || code === '' || code.length > 128)
204
+ || new Set(source.retryableCodes).size !== source.retryableCodes.length) {
205
+ throw new Error('profile.provider.retryPolicy.retryableCodes must contain 1-64 unique non-empty strings')
206
+ }
207
+ }
208
+ let backoff
209
+ if (source.backoff !== undefined) {
210
+ const raw = object(source.backoff, 'profile.provider.retryPolicy.backoff')
211
+ exactKeys(raw, new Set(['initialDelayMs', 'maxDelayMs', 'jitterRatio']), 'profile.provider.retryPolicy.backoff')
212
+ for (const key of ['initialDelayMs', 'maxDelayMs']) {
213
+ if (raw[key] !== undefined && (!Number.isFinite(raw[key]) || raw[key] <= 0 || raw[key] > 2147483647)) {
214
+ throw new Error(`profile.provider.retryPolicy.backoff.${key} is invalid`)
215
+ }
216
+ }
217
+ if (raw.jitterRatio !== undefined && (!Number.isFinite(raw.jitterRatio) || raw.jitterRatio < 0 || raw.jitterRatio > 1)) {
218
+ throw new Error('profile.provider.retryPolicy.backoff.jitterRatio must be between 0 and 1')
219
+ }
220
+ if (raw.initialDelayMs !== undefined && raw.maxDelayMs !== undefined && raw.initialDelayMs > raw.maxDelayMs) {
221
+ throw new Error('profile.provider.retryPolicy.backoff.initialDelayMs must not exceed maxDelayMs')
222
+ }
223
+ backoff = Object.freeze({ ...raw })
224
+ }
225
+ return Object.freeze({
226
+ mode: source.mode,
227
+ ...(source.maxRetries === undefined ? {} : { maxRetries: source.maxRetries }),
228
+ ...(source.retryableCodes === undefined ? {} : { retryableCodes: Object.freeze([...source.retryableCodes]) }),
229
+ ...(backoff === undefined ? {} : { backoff }),
230
+ })
231
+ }
232
+
233
+ function boundedProfile(raw) {
234
+ let encoded
235
+ try { encoded = JSON.stringify(raw) }
236
+ catch (cause) { throw new Error('enterprise profile must be JSON-serializable', { cause }) }
237
+ if (Buffer.byteLength(encoded, 'utf8') > MAX_PROFILE_BYTES) throw new Error(`enterprise profile exceeds ${MAX_PROFILE_BYTES} bytes`)
238
+ }
239
+
240
+ export function normalizeEnterpriseProfile(raw) {
241
+ boundedProfile(raw)
242
+ const source = object(raw, 'enterprise profile')
243
+ exactKeys(source, allowedRootKeys, 'profile')
244
+ if (source.schemaVersion !== PROFILE_SCHEMA_VERSION) throw new Error(`unsupported enterprise profile schemaVersion ${String(source.schemaVersion)}`)
245
+ const id = text(source.id, 'profile.id', 64)
246
+ if (!idPattern.test(id)) throw new Error('profile.id is invalid')
247
+ const allowInsecureDevelopment = source.allowInsecureDevelopment === true
248
+ const allowedInsecureOrigin = source.insecureDevelopmentOrigin === undefined
249
+ ? undefined
250
+ : insecureDevelopmentOrigin(source.insecureDevelopmentOrigin, 'profile.insecureDevelopmentOrigin')
251
+ if (allowedInsecureOrigin !== undefined && !allowInsecureDevelopment) {
252
+ throw new Error('profile.insecureDevelopmentOrigin requires allowInsecureDevelopment=true')
253
+ }
254
+ const oidc = object(source.oidc, 'profile.oidc')
255
+ const keyBinding = object(source.keyBinding, 'profile.keyBinding')
256
+ const provider = object(source.provider, 'profile.provider')
257
+ exactKeys(oidc, allowedOidcKeys, 'profile.oidc')
258
+ exactKeys(keyBinding, allowedKeyBindingKeys, 'profile.keyBinding')
259
+ exactKeys(provider, allowedProviderKeys, 'profile.provider')
260
+ const providerID = text(provider.id, 'profile.provider.id', 64)
261
+ if (!idPattern.test(providerID)) throw new Error('profile.provider.id is invalid')
262
+ const credentialRef = keyBinding.credentialRef === undefined
263
+ ? providerCredentialRef(providerID)
264
+ : text(keyBinding.credentialRef, 'profile.keyBinding.credentialRef', 128)
265
+ if (!credentialPattern.test(credentialRef)) {
266
+ throw new Error('profile.keyBinding.credentialRef must be a valid DSH credential reference')
267
+ }
268
+ if (provider.adapter !== 'openai-compatible') throw new Error(`unsupported provider adapter ${String(provider.adapter)}`)
269
+ const scopes = oidc.scopes
270
+ 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
+ throw new Error('profile.oidc.scopes must contain openid and profile; every scope must be a non-empty token')
272
+ }
273
+ if (new Set(scopes).size !== scopes.length) throw new Error('profile.oidc.scopes contains duplicates')
274
+ return Object.freeze({
275
+ schemaVersion: PROFILE_SCHEMA_VERSION,
276
+ id,
277
+ displayName: text(source.displayName, 'profile.displayName', 120),
278
+ organization: text(source.organization ?? source.displayName, 'profile.organization', 120),
279
+ allowInsecureDevelopment,
280
+ ...(allowedInsecureOrigin === undefined ? {} : { insecureDevelopmentOrigin: allowedInsecureOrigin }),
281
+ nativeInstitutionID: source.nativeInstitutionID === undefined ? id : text(source.nativeInstitutionID, 'profile.nativeInstitutionID', 64),
282
+ brand: normalizeBrand(source.brand),
283
+ oidc: Object.freeze({
284
+ issuer: issuerURL(oidc.issuer, 'profile.oidc.issuer', allowInsecureDevelopment, allowedInsecureOrigin),
285
+ clientId: text(oidc.clientId, 'profile.oidc.clientId', 256),
286
+ scopes: Object.freeze([...scopes]),
287
+ }),
288
+ keyBinding: Object.freeze({
289
+ type: 'worker-user-center-v1',
290
+ baseURL: exactURL(keyBinding.baseURL, 'profile.keyBinding.baseURL', allowInsecureDevelopment, allowedInsecureOrigin),
291
+ providerId: providerID,
292
+ credentialRef,
293
+ }),
294
+ provider: Object.freeze({
295
+ id: providerID,
296
+ displayName: text(provider.displayName ?? source.displayName, 'profile.provider.displayName', 120),
297
+ adapter: provider.adapter,
298
+ baseURL: exactURL(provider.baseURL, 'profile.provider.baseURL', allowInsecureDevelopment, allowedInsecureOrigin),
299
+ reasoning: provider.reasoning === undefined ? 'high' : text(provider.reasoning, 'profile.provider.reasoning', 32),
300
+ defaultContextWindow: optionalPositiveInteger(provider.defaultContextWindow, 'profile.provider.defaultContextWindow'),
301
+ defaultMaxTokens: optionalPositiveInteger(provider.defaultMaxTokens, 'profile.provider.defaultMaxTokens'),
302
+ maxRequestImageBytes: optionalPositiveInteger(provider.maxRequestImageBytes, 'profile.provider.maxRequestImageBytes'),
303
+ requestImagePixelBudget: optionalPositiveInteger(provider.requestImagePixelBudget, 'profile.provider.requestImagePixelBudget'),
304
+ requestImageMaxBytes: optionalPositiveInteger(provider.requestImageMaxBytes, 'profile.provider.requestImageMaxBytes'),
305
+ streamIdleTimeoutMs: optionalPositiveInteger(provider.streamIdleTimeoutMs, 'profile.provider.streamIdleTimeoutMs'),
306
+ retryPolicy: provider.retryPolicy === undefined ? undefined : normalizeRetryPolicy(provider.retryPolicy),
307
+ compat: provider.compat === undefined ? undefined : normalizeCompat(provider.compat, 'profile.provider.compat'),
308
+ models: normalizeModels(provider.models, providerID),
309
+ }),
310
+ })
311
+ }
312
+
313
+ export function loadEnterpriseProfiles(raw = {}, environment = process.env) {
314
+ const profiles = []
315
+ if (raw.profile !== undefined) profiles.push(normalizeEnterpriseProfile(raw.profile))
316
+ if (raw.profiles !== undefined) {
317
+ if (!Array.isArray(raw.profiles)) throw new Error('dsh-oidc profiles must be an array')
318
+ profiles.push(...raw.profiles.map(normalizeEnterpriseProfile))
319
+ }
320
+ if (typeof raw.profilePathEnv === 'string' && environment[raw.profilePathEnv]) {
321
+ const parsed = JSON.parse(readFileSync(environment[raw.profilePathEnv], 'utf8'))
322
+ const rows = Array.isArray(parsed) ? parsed : parsed.profiles ?? [parsed]
323
+ profiles.push(...rows.map(normalizeEnterpriseProfile))
324
+ }
325
+ const unique = new Map()
326
+ const providers = new Set()
327
+ for (const profile of profiles) {
328
+ 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}`)
330
+ unique.set(profile.id, profile)
331
+ providers.add(profile.provider.id)
332
+ }
333
+ return unique
334
+ }
335
+
336
+ export function enterpriseProviderConfig(profiles) {
337
+ return {
338
+ providers: Object.fromEntries([...profiles.values()].map(profile => [profile.provider.id, {
339
+ displayName: profile.provider.displayName,
340
+ apiKeyEnv: profile.keyBinding.credentialRef,
341
+ baseURL: profile.provider.baseURL,
342
+ allowInsecureDevelopment: profile.allowInsecureDevelopment,
343
+ ...(profile.insecureDevelopmentOrigin === undefined ? {} : { insecureDevelopmentOrigin: profile.insecureDevelopmentOrigin }),
344
+ 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,
354
+ }])),
355
+ }
356
+ }
357
+
358
+ export function publicProfile(profile) {
359
+ return {
360
+ id: profile.id,
361
+ displayName: profile.displayName,
362
+ organization: profile.organization,
363
+ brand: profile.brand,
364
+ credentialRef: profile.keyBinding.credentialRef,
365
+ provider: {
366
+ id: profile.provider.id,
367
+ displayName: profile.provider.displayName,
368
+ models: profile.provider.models.map(model => ({ id: model.id, name: model.name, input: model.input })),
369
+ },
370
+ }
371
+ }
@@ -0,0 +1,138 @@
1
+ const modalities = new Set(['text', 'image'])
2
+ export const ENTERPRISE_SETTINGS_NAMESPACE = 'provider-enterprise'
3
+ export const ENTERPRISE_DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024
4
+ export const ENTERPRISE_DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 2048 * 2048
5
+ export const ENTERPRISE_DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024
6
+ export const ENTERPRISE_DEFAULT_RETRY_POLICY = Object.freeze({ mode: 'normal', maxRetries: 2 })
7
+
8
+ function nonEmpty(value, label) {
9
+ if (typeof value !== 'string' || value.trim().length === 0) throw new Error(`${label} must be a non-empty string`)
10
+ return value.trim()
11
+ }
12
+
13
+ function exactDevelopmentOrigin(value, label) {
14
+ if (value === undefined) return undefined
15
+ const raw = nonEmpty(value, label).replace(/\/+$/, '')
16
+ const parsed = new URL(raw)
17
+ if (parsed.protocol !== 'http:' || parsed.username || parsed.password || parsed.hash || parsed.search || (parsed.pathname !== '/' && parsed.pathname !== '')) {
18
+ throw new Error(`${label} must be an exact HTTP origin`)
19
+ }
20
+ return raw
21
+ }
22
+
23
+ function endpointURL(value, label, allowInsecureDevelopment = false, insecureDevelopmentOrigin) {
24
+ const raw = nonEmpty(value, label).replace(/\/+$/, '')
25
+ const parsed = new URL(raw)
26
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname)
27
+ const allowedOrigin = exactDevelopmentOrigin(insecureDevelopmentOrigin, `${label}.insecureDevelopmentOrigin`)
28
+ const permittedHTTP = parsed.protocol === 'http:' && (loopback || (allowInsecureDevelopment === true && parsed.origin === allowedOrigin))
29
+ if ((parsed.protocol !== 'https:' && !permittedHTTP) || parsed.username || parsed.password || parsed.hash || parsed.search) {
30
+ throw new Error(`${label} must be HTTPS or explicitly allowed development HTTP without credentials, query, or fragment`)
31
+ }
32
+ return raw
33
+ }
34
+
35
+ function positiveInteger(value, fallback, label) {
36
+ const resolved = value ?? fallback
37
+ if (!Number.isSafeInteger(resolved) || resolved <= 0) throw new Error(`${label} must be a positive integer`)
38
+ return resolved
39
+ }
40
+
41
+ function normalizeInput(value, label) {
42
+ const input = value ?? ['text']
43
+ if (!Array.isArray(input) || input.length === 0 || input.some(item => !modalities.has(item))) {
44
+ throw new Error(`${label} must contain text and/or image`)
45
+ }
46
+ return [...new Set(input)]
47
+ }
48
+
49
+ function normalizeThinkingMap(value, reasoning) {
50
+ if (value === false || reasoning === false) return { off: null }
51
+ const source = value ?? { off: null, high: 'high', max: 'max' }
52
+ 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]))
54
+ }
55
+
56
+ export function resolveEnterpriseProfiles(raw = {}) {
57
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) throw new Error('enterprise provider config must be an object')
58
+ const providers = raw.providers ?? {}
59
+ if (typeof providers !== 'object' || providers === null || Array.isArray(providers)) throw new Error('enterprise provider config.providers must be an object')
60
+
61
+ return Object.entries(providers).map(([provider, source]) => {
62
+ const route = nonEmpty(provider, 'provider route')
63
+ if (typeof source !== 'object' || source === null || Array.isArray(source)) throw new Error(`${route} config must be an object`)
64
+ const displayName = nonEmpty(source.displayName ?? route, `${route}.displayName`)
65
+ const baseURL = endpointURL(source.baseURL, `${route}.baseURL`, source.allowInsecureDevelopment, source.insecureDevelopmentOrigin)
66
+ const credentialRef = nonEmpty(source.apiKeyEnv, `${route}.apiKeyEnv`)
67
+ const contextWindow = positiveInteger(source.defaultContextWindow, 262144, `${route}.defaultContextWindow`)
68
+ const maxTokens = positiveInteger(source.defaultMaxTokens, 32768, `${route}.defaultMaxTokens`)
69
+ const maxRequestImageBytes = positiveInteger(source.maxRequestImageBytes, ENTERPRISE_DEFAULT_MAX_REQUEST_IMAGE_BYTES, `${route}.maxRequestImageBytes`)
70
+ const requestImagePixelBudget = positiveInteger(source.requestImagePixelBudget, ENTERPRISE_DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, `${route}.requestImagePixelBudget`)
71
+ const requestImageMaxBytes = positiveInteger(source.requestImageMaxBytes, ENTERPRISE_DEFAULT_REQUEST_IMAGE_MAX_BYTES, `${route}.requestImageMaxBytes`)
72
+ if (!Array.isArray(source.models) || source.models.length === 0) throw new Error(`${route}.models must not be empty`)
73
+
74
+ const ids = new Set()
75
+ const models = source.models.map((model, index) => {
76
+ if (typeof model !== 'object' || model === null || Array.isArray(model)) throw new Error(`${route}.models[${index}] must be an object`)
77
+ const id = nonEmpty(model.id, `${route}.models[${index}].id`)
78
+ if (ids.has(id)) throw new Error(`${route} declares duplicate model ${id}`)
79
+ ids.add(id)
80
+ const reasoning = model.reasoning !== false && model.reasoningEfforts !== false
81
+ return {
82
+ id,
83
+ name: nonEmpty(model.name ?? id, `${route}.${id}.name`),
84
+ api: 'openai-completions',
85
+ provider: route,
86
+ baseUrl: baseURL,
87
+ reasoning,
88
+ thinkingLevelMap: normalizeThinkingMap(model.reasoningEfforts, reasoning),
89
+ input: normalizeInput(model.input, `${route}.${id}.input`),
90
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
91
+ contextWindow: positiveInteger(model.contextWindow, contextWindow, `${route}.${id}.contextWindow`),
92
+ 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 ?? {}),
105
+ },
106
+ }
107
+ })
108
+
109
+ return {
110
+ provider: route,
111
+ displayName,
112
+ baseURL,
113
+ credentialRef,
114
+ reasoning: source.reasoning ?? 'high',
115
+ maxRequestImageBytes,
116
+ requestImagePixelBudget,
117
+ requestImageMaxBytes,
118
+ streamIdleTimeoutMs: positiveInteger(source.streamIdleTimeoutMs, 300000, `${route}.streamIdleTimeoutMs`),
119
+ retryPolicy: source.retryPolicy ?? ENTERPRISE_DEFAULT_RETRY_POLICY,
120
+ configuredMaxTokens: new Map(models.map(model => [model.id, model.maxTokens])),
121
+ models,
122
+ }
123
+ })
124
+ }
125
+
126
+ export function configurableEntries(profiles) {
127
+ return [...profiles.values()].map(profile => ({
128
+ provider: profile.provider,
129
+ displayName: profile.displayName,
130
+ settingsNs: ENTERPRISE_SETTINGS_NAMESPACE,
131
+ settingsPath: ['providers', profile.provider],
132
+ declared: true,
133
+ }))
134
+ }
135
+
136
+ export function settingsBase(rawConfig) {
137
+ return { providers: rawConfig.providers ?? {} }
138
+ }
@@ -0,0 +1,156 @@
1
+ import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
2
+ import { LlmError, assertUsableApiKey, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
3
+ import { Config as PiAiConfig, PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
4
+ import { createProvider } from '@earendil-works/pi-ai'
5
+ import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy'
6
+ import {
7
+ configurableEntries, ENTERPRISE_DEFAULT_MAX_REQUEST_IMAGE_BYTES, ENTERPRISE_DEFAULT_REQUEST_IMAGE_MAX_BYTES,
8
+ ENTERPRISE_DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, ENTERPRISE_DEFAULT_RETRY_POLICY, ENTERPRISE_SETTINGS_NAMESPACE,
9
+ resolveEnterpriseProfiles, settingsBase,
10
+ } from './core.js'
11
+ import { TransformingEnterpriseAdapter } from './transform-adapter.js'
12
+ import { EnterpriseModelTransforms } from './transforms.js'
13
+
14
+ export const name = 'dsh-oidc-provider'
15
+ export const inject = ['llm']
16
+ export const SETTINGS_NAMESPACE = ENTERPRISE_SETTINGS_NAMESPACE
17
+
18
+ function installEnterpriseSettings(ctx, rawConfig, hooks) {
19
+ ctx.inject(['settings'], (settingsCtx) => {
20
+ settingsCtx.settings.installSection(
21
+ ctx,
22
+ SETTINGS_NAMESPACE,
23
+ PiAiConfig,
24
+ settingsBase(rawConfig),
25
+ hooks,
26
+ )
27
+ })
28
+ }
29
+
30
+ function apiKeyAuth(displayName) {
31
+ return {
32
+ name: displayName,
33
+ resolve: ({ credential }) => Promise.resolve({
34
+ auth: credential?.key === undefined ? {} : { apiKey: credential.key },
35
+ source: displayName,
36
+ }),
37
+ }
38
+ }
39
+
40
+ function profilesFrom(rawConfig) {
41
+ const profiles = new Map()
42
+ for (const route of resolveEnterpriseProfiles(rawConfig)) {
43
+ profiles.set(route.provider, {
44
+ provider: route.provider,
45
+ displayName: route.displayName,
46
+ apiKeyEnv: route.credentialRef,
47
+ reasoning: route.reasoning,
48
+ maxRequestImageBytes: route.maxRequestImageBytes,
49
+ requestImagePixelBudget: route.requestImagePixelBudget,
50
+ requestImageMaxBytes: route.requestImageMaxBytes,
51
+ streamIdleTimeoutMs: route.streamIdleTimeoutMs,
52
+ retryPolicy: resolveRetryPolicy(route.retryPolicy, `${name}: ${route.provider}.retryPolicy`),
53
+ configuredMaxTokens: route.configuredMaxTokens,
54
+ modelPolicies: new Map(route.models.map(model => [model.id, {
55
+ reasoning: model.reasoning,
56
+ supportsReasoningEffort: model.compat.supportsReasoningEffort,
57
+ }])),
58
+ piProvider: createProvider({
59
+ id: route.provider,
60
+ name: route.displayName,
61
+ baseUrl: route.baseURL,
62
+ auth: { apiKey: apiKeyAuth(route.displayName) },
63
+ models: route.models,
64
+ api: openAICompletionsApi(),
65
+ }),
66
+ })
67
+ }
68
+ return profiles
69
+ }
70
+
71
+ function isolatedPiAiAuth() {
72
+ const stored = new Map()
73
+ return {
74
+ credentials: {
75
+ read: id => Promise.resolve(stored.get(id)),
76
+ list: () => Promise.resolve([]),
77
+ async modify(id, mutate) {
78
+ const next = await mutate(stored.get(id))
79
+ if (next === undefined) stored.delete(id)
80
+ else stored.set(id, next)
81
+ return next
82
+ },
83
+ delete: id => { stored.delete(id); return Promise.resolve() },
84
+ },
85
+ authContext: {
86
+ env: () => Promise.resolve(undefined),
87
+ fileExists: () => Promise.resolve(false),
88
+ },
89
+ }
90
+ }
91
+
92
+ export function apply(ctx, rawConfig = {}) {
93
+ const transforms = new EnterpriseModelTransforms(ctx)
94
+ let current = () => settingsBase(rawConfig)
95
+ let profiles = profilesFrom(current())
96
+ if (profiles.size === 0) return
97
+
98
+ const baseAdapter = new PiAiAdapter({
99
+ profiles: () => profiles,
100
+ auth: isolatedPiAiAuth(),
101
+ resolveApiKey: async (provider, profile) => {
102
+ const ref = profile.apiKeyEnv
103
+ const credentials = ctx.get('credentials')
104
+ const hit = credentials === undefined
105
+ ? launchEnvironmentOf(ctx).get(ref)?.value
106
+ : (await credentials.resolve(ref))?.value
107
+ if (typeof hit === 'string' && hit.length > 0) return assertUsableApiKey(hit, name, ref)
108
+ throw new LlmError(`${name}: no credential for provider route "${provider}" (${ref})`, 'MISSING_CREDENTIAL')
109
+ },
110
+ resolveAttachments: () => ctx.get('attachments'),
111
+ })
112
+
113
+ const adapter = new TransformingEnterpriseAdapter(baseAdapter, transforms, (provider, model) => (
114
+ profiles.get(provider)?.modelPolicies.get(model) ?? { reasoning: true, supportsReasoningEffort: true }
115
+ ))
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())
126
+ const previous = profiles
127
+ profiles = next
128
+ try {
129
+ registration.replace([...next.keys()])
130
+ directory.replace(configurableEntries(next))
131
+ } catch (error) {
132
+ profiles = previous
133
+ throw error
134
+ }
135
+ }
136
+
137
+ installEnterpriseSettings(ctx, rawConfig, {
138
+ setSource(source) { current = source },
139
+ onChange() {
140
+ try { refresh() }
141
+ catch (error) {
142
+ ctx.logger.error('dsh-oidc-provider: keeping previous routes after a refused settings update')
143
+ ctx.logger.error(error)
144
+ }
145
+ },
146
+ })
147
+ }
148
+
149
+ export { resolveEnterpriseProfiles } from './core.js'
150
+ export { TransformingEnterpriseAdapter } from './transform-adapter.js'
151
+ export { EnterpriseModelTransforms } from './transforms.js'
152
+ export {
153
+ configurableEntries, ENTERPRISE_DEFAULT_MAX_REQUEST_IMAGE_BYTES, ENTERPRISE_DEFAULT_REQUEST_IMAGE_MAX_BYTES,
154
+ ENTERPRISE_DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, ENTERPRISE_DEFAULT_RETRY_POLICY,
155
+ ENTERPRISE_SETTINGS_NAMESPACE, settingsBase,
156
+ }