@eduwork/dsh-oidc 0.3.0-dev.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.en.md +12 -0
- package/CHANGELOG.md +12 -0
- package/README.md +9 -1
- package/README_EN.md +9 -1
- package/THIRD_PARTY_NOTICES.en.md +5 -4
- package/THIRD_PARTY_NOTICES.md +5 -5
- package/docs/account-extensions.en.md +7 -1
- package/docs/account-extensions.md +7 -1
- package/docs/compatibility.en.md +3 -1
- package/docs/compatibility.md +3 -1
- package/docs/development.en.md +1 -1
- package/docs/development.md +1 -1
- package/docs/dsh-integration.en.md +3 -3
- package/docs/dsh-integration.md +3 -3
- package/docs/enterprise-profile.en.md +49 -2
- package/docs/enterprise-profile.md +49 -2
- package/docs/gateway-auth/README.md +7 -6
- package/docs/gateway-auth/README_EN.md +7 -6
- package/docs/gateway-auth/experimental-oidc-llm.en.md +10 -8
- package/docs/gateway-auth/experimental-oidc-llm.md +9 -7
- package/docs/gateway-auth/litellm-setup.en.md +10 -4
- package/docs/gateway-auth/litellm-setup.md +10 -4
- package/docs/gateway-auth/oidc-llm-draft.en.md +89 -63
- package/docs/gateway-auth/oidc-llm-draft.md +86 -60
- package/docs/getting-started.en.md +3 -1
- package/docs/getting-started.md +3 -1
- package/docs/key-binding-protocol.en.md +1 -1
- package/docs/key-binding-protocol.md +1 -1
- package/docs/oidc-interoperability.en.md +1 -1
- package/docs/oidc-interoperability.md +1 -1
- package/docs/open-integration.en.md +3 -1
- package/docs/open-integration.md +3 -1
- package/examples/ecnu.enterprise-profile.example.json +4 -1
- package/examples/enterprise-profile.example.json +4 -1
- package/examples/identity-only.example.json +7 -2
- package/examples/litellm.enterprise-profile.example.json +9 -0
- package/examples/oidc-llm.enterprise-profile.example.json +33 -0
- package/examples/resources-discovery.example.json +9 -2
- package/lib/callback-page.js +9 -4
- package/lib/client.js +460 -360
- package/lib/desktop-oidc.js +2 -2
- package/lib/gateway-backend.js +20 -5
- package/lib/index.js +8 -6
- package/lib/litellm-protocol.js +4 -3
- package/lib/model-types.js +7 -0
- package/lib/oidc-llm-protocol.js +8 -5
- package/lib/oidc.js +22 -28
- package/lib/profile.js +36 -47
- package/lib/provider/authorization-scope.js +4 -0
- package/lib/provider/core.js +8 -19
- package/lib/provider/index.js +18 -2
- package/lib/provider/transform-adapter.js +4 -0
- package/lib/resources.js +12 -4
- package/lib/token-lifetime.js +15 -0
- package/lib/transport.js +5 -0
- package/lib/typert-schemas.js +14 -12
- package/package.json +104 -97
- package/protocol/resources.openapi.yaml +11 -3
- package/schema/enterprise-profile.v1alpha1.schema.json +40 -5
package/lib/desktop-oidc.js
CHANGED
|
@@ -6,7 +6,7 @@ import { callbackLanguage, callbackPage } from './callback-page.js'
|
|
|
6
6
|
const MAX_FLOWS = 32
|
|
7
7
|
const MAX_HISTORY = 128
|
|
8
8
|
const error = (code, message) => Object.assign(new Error(message), { code })
|
|
9
|
-
const safeCodes = new Set(['oidc_callback_invalid', 'oidc_authorization_rejected', 'oidc_token_invalid', 'oidc_id_token_invalid', 'oidc_userinfo_invalid', 'gateway_token_invalid', 'gateway_scope_changed', 'gateway_identity_changed', 'gateway_callback_issuer_missing', 'gateway_callback_issuer_invalid'])
|
|
9
|
+
const safeCodes = new Set(['oidc_id_token_time_invalid', 'oidc_callback_invalid', 'oidc_authorization_rejected', 'oidc_token_invalid', 'oidc_id_token_invalid', 'oidc_userinfo_invalid', 'gateway_token_invalid', 'gateway_scope_changed', 'gateway_identity_changed', 'gateway_callback_issuer_missing', 'gateway_callback_issuer_invalid'])
|
|
10
10
|
|
|
11
11
|
function reply(response, status, profile, outcome, language) {
|
|
12
12
|
const page = callbackPage(profile, outcome, language)
|
|
@@ -138,7 +138,7 @@ export class DesktopOidcBackend extends WebOidcBackend {
|
|
|
138
138
|
this.ctx.logger.warn('Desktop OIDC host credentials are unavailable')
|
|
139
139
|
})
|
|
140
140
|
await attempt.processing
|
|
141
|
-
const failedOutcome = ['gateway_callback_issuer_missing', 'gateway_callback_issuer_invalid'].includes(attempt.errorCode) ? 'issuer-invalid' : 'failed'
|
|
141
|
+
const failedOutcome = ['gateway_callback_issuer_missing', 'gateway_callback_issuer_invalid'].includes(attempt.errorCode) ? 'issuer-invalid' : attempt.errorCode === 'oidc_id_token_time_invalid' ? 'time-invalid' : 'failed'
|
|
142
142
|
if (!response.destroyed) reply(response, attempt.state === 'completed' ? 200 : 400, profile,
|
|
143
143
|
attempt.state === 'completed' ? 'completed' : failedOutcome, language)
|
|
144
144
|
await this.closeCallback(attempt)
|
package/lib/gateway-backend.js
CHANGED
|
@@ -6,6 +6,7 @@ import { gatewayJSON, liteLLMToken, protocolError, readGatewayJSON, registerLite
|
|
|
6
6
|
import { detectGatewayProtocol } from './gateway-protocol.js'
|
|
7
7
|
import { oidcLlmIdentity, oidcLlmToken } from './oidc-llm-protocol.js'
|
|
8
8
|
import { bufferResourceResponse, validateResourceRead } from './model-resource-transport.js'
|
|
9
|
+
import { accessTokenNeedsRefresh } from './token-lifetime.js'
|
|
9
10
|
|
|
10
11
|
const seconds = backend => Math.floor(backend.now() / 1000)
|
|
11
12
|
const fingerprint = (profile, descriptor) => createHash('sha256').update(JSON.stringify({ auth: profile.auth, descriptor })).digest('hex')
|
|
@@ -266,7 +267,7 @@ function withGatewayAuth(Base) {
|
|
|
266
267
|
const epoch = this.accountEpoch(profile)
|
|
267
268
|
const session = await this.loadSession(profile)
|
|
268
269
|
if (!session) return undefined
|
|
269
|
-
if (session
|
|
270
|
+
if (!accessTokenNeedsRefresh(session, this.now)) return session
|
|
270
271
|
try { return await this.refresh(profile, session) }
|
|
271
272
|
catch (cause) {
|
|
272
273
|
// Brief outages must not discard a still-valid access token; expired tokens stop.
|
|
@@ -314,14 +315,26 @@ function withGatewayAuth(Base) {
|
|
|
314
315
|
return account(profile, session, true, 'access_token')
|
|
315
316
|
}
|
|
316
317
|
|
|
317
|
-
async authorizedFetch(profileID, endpoint, init = {}) {
|
|
318
|
+
async authorizedFetch(profileID, endpoint, init = {}, authorization = {}) {
|
|
319
|
+
const retryUnauthorized = authorization.retryUnauthorized === true
|
|
320
|
+
if (retryUnauthorized && init.body != null && typeof init.body !== 'string') throw new TypeError('Authorization retry requires a buffered text request body')
|
|
318
321
|
const profile = this.profile(profileID)
|
|
319
322
|
if (!profile.auth) return super.authorizedFetch(profileID, endpoint, init)
|
|
320
323
|
const epoch = this.accountEpoch(profile), descriptor = await this.discover(profile)
|
|
321
324
|
const target = new URL(endpoint)
|
|
322
325
|
// Host-only model and own-account routes, never key administration or arbitrary origins.
|
|
323
326
|
const modelBase = new URL(`${descriptor.baseURL}/`)
|
|
324
|
-
|
|
327
|
+
// Trusted Host services can grant one exact issuer POST route for their
|
|
328
|
+
// own request. This option is never accepted through a renderer RPC or
|
|
329
|
+
// persisted as a broader model-resource allowlist.
|
|
330
|
+
const servicePath = authorization.issuerServicePath
|
|
331
|
+
const issuerService = typeof servicePath === 'string'
|
|
332
|
+
&& /^\/(?:[a-zA-Z0-9_-]+\/)*[a-zA-Z0-9_-]+$/.test(servicePath)
|
|
333
|
+
&& (init.method ?? 'GET').toUpperCase() === 'POST'
|
|
334
|
+
&& target.href === new URL(descriptor.issuer).origin + servicePath
|
|
335
|
+
const modelResource = target.origin === modelBase.origin
|
|
336
|
+
&& (target.href === descriptor.userInfoEndpoint || target.pathname.startsWith(modelBase.pathname))
|
|
337
|
+
if (target.username || target.password || target.hash || !(modelResource || issuerService)) throw protocolError('oidc_authorized_origin_denied', 'Gateway token destination is not an authorized resource')
|
|
325
338
|
const scope = await this.createGatewayScope(profileID)
|
|
326
339
|
const lease = scope.open(init.signal ?? AbortSignal.timeout(20_000))
|
|
327
340
|
let response
|
|
@@ -331,8 +344,9 @@ function withGatewayAuth(Base) {
|
|
|
331
344
|
if (!session) throw protocolError('oidc_login_required', 'Gateway sign-in is required')
|
|
332
345
|
const request = current => this.fetch(target.href, { ...init, headers: { ...Object.fromEntries(new Headers(init.headers)), authorization: `Bearer ${current.accessToken}` }, redirect: 'error', signal: lease.signal })
|
|
333
346
|
response = await request(session)
|
|
334
|
-
//
|
|
335
|
-
|
|
347
|
+
// Reads recover automatically. Trusted Host services may explicitly opt
|
|
348
|
+
// in for replayable calls; model generation does not opt in.
|
|
349
|
+
if (response.status === 401 && (retryUnauthorized || ['GET', 'HEAD'].includes((init.method ?? 'GET').toUpperCase()))) {
|
|
336
350
|
await response.body?.cancel()
|
|
337
351
|
session = await this.refresh(profile, session)
|
|
338
352
|
lease.assertCurrent()
|
|
@@ -379,6 +393,7 @@ function withGatewayAuth(Base) {
|
|
|
379
393
|
this.updateProvider(next)
|
|
380
394
|
this.profiles.set(profileID, next)
|
|
381
395
|
result.models = models
|
|
396
|
+
if (models.some(model => model.type === 'unknown')) result.issues.push('model_types_unresolved')
|
|
382
397
|
} catch (cause) {
|
|
383
398
|
if (cause.code === 'oidc_login_cancelled' || cause.code === 'oidc_login_required') throw cause
|
|
384
399
|
result.issues.push('models_unavailable')
|
package/lib/index.js
CHANGED
|
@@ -45,7 +45,7 @@ export class OidcAccountService extends TypertRemoteService {
|
|
|
45
45
|
})
|
|
46
46
|
const backendOptions = {
|
|
47
47
|
authorizedOrigins: config.authorizedOrigins,
|
|
48
|
-
accountChanged: status => this.notifyAccount(status),
|
|
48
|
+
accountChanged: status => this.notifyAccount(status, { force: true }),
|
|
49
49
|
updateProvider: profile => {
|
|
50
50
|
const profiles = new Map(this.profiles)
|
|
51
51
|
profiles.set(profile.id, profile)
|
|
@@ -98,18 +98,20 @@ export class OidcAccountService extends TypertRemoteService {
|
|
|
98
98
|
return { opened: true }
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
notifyAccount(status) {
|
|
102
|
-
|
|
101
|
+
notifyAccount(status, { force = false } = {}) {
|
|
102
|
+
// A completed sign-in replaces authorization even when both sessions are
|
|
103
|
+
// "connected". Consumers must reset work blocked by the previous session.
|
|
104
|
+
if (force || this.accountStates.get(status.profileID) !== status.state) {
|
|
103
105
|
this.accountStates.set(status.profileID, status.state)
|
|
104
|
-
this.ctx.emit('oidc/accounts-changed', { profileID: status.profileID, state: status.state })
|
|
106
|
+
this.ctx.emit('oidc/accounts-changed', { profileID: status.profileID, state: status.state, ...(force ? { authorizationChanged: true } : {}) })
|
|
105
107
|
}
|
|
106
108
|
return status
|
|
107
109
|
}
|
|
108
110
|
async status(profileID) { return this.notifyAccount(await this.callBackend('status', profileID)) }
|
|
109
111
|
resources(profileID) { return this.callBackend('resources', profileID) }
|
|
110
112
|
// Host-only capability: deliberately absent from Remote markers and Typert.
|
|
111
|
-
authorizedFetch(profileID, endpoint, init) {
|
|
112
|
-
return this.callBackend('authorizedFetch', profileID, endpoint, init)
|
|
113
|
+
authorizedFetch(profileID, endpoint, init, authorization) {
|
|
114
|
+
return this.callBackend('authorizedFetch', profileID, endpoint, init, authorization)
|
|
113
115
|
}
|
|
114
116
|
modelResourceFetch(profileID, relativePath, options) {
|
|
115
117
|
return this.callBackend('modelResourceFetch', profileID, relativePath, options)
|
package/lib/litellm-protocol.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { serviceProtocolAllowed } from './transport.js'
|
|
2
|
+
import { accessTokenTiming } from './token-lifetime.js'
|
|
1
3
|
// LiteLLM v1.101.0, native CLI auth contract 1. This is OAuth, not OIDC.
|
|
2
4
|
// Protocol-specific wire fields stay here; tokens never cross the renderer RPC.
|
|
3
5
|
const MAX_BYTES = 1024 * 1024
|
|
@@ -51,8 +53,7 @@ export async function gatewayJSON(fetcher, url, init = {}) {
|
|
|
51
53
|
function trustedURL(value, profile, origin) {
|
|
52
54
|
let url
|
|
53
55
|
try { url = new URL(value) } catch { throw protocolError('gateway_discovery_invalid', 'Gateway metadata contains an invalid URL') }
|
|
54
|
-
|
|
55
|
-
if (!nonempty(value, 2048) || (url.protocol !== 'https:' && !local) || url.username || url.password || url.search || url.hash || (origin && url.origin !== origin)) {
|
|
56
|
+
if (!nonempty(value, 2048) || !serviceProtocolAllowed(url, profile.allowInsecureDevelopment) || url.username || url.password || url.search || url.hash || (origin && url.origin !== origin)) {
|
|
56
57
|
throw protocolError('gateway_discovery_invalid', 'Gateway metadata URL is outside the configured trust boundary')
|
|
57
58
|
}
|
|
58
59
|
return url
|
|
@@ -99,7 +100,7 @@ export function liteLLMToken(raw, now, previous) {
|
|
|
99
100
|
throw protocolError('gateway_token_invalid', 'LiteLLM token response is incomplete or invalid')
|
|
100
101
|
}
|
|
101
102
|
if (previous && (raw.user_id !== previous.identity.sub || raw.team_id !== previous.teamID)) throw protocolError('gateway_identity_changed', 'Gateway authorization identity changed during refresh; sign in again')
|
|
102
|
-
return { accessToken: raw.access_token, refreshToken: raw.refresh_token,
|
|
103
|
+
return { accessToken: raw.access_token, refreshToken: raw.refresh_token, ...accessTokenTiming(raw.expires_in, now), teamID: raw.team_id, identity: { sub: raw.user_id, name: previous?.identity?.name || raw.user_id } }
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
export function tokenRequest(fetcher, descriptor, values) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Purpose is independent of input modalities: a vision LLM is still an LLM.
|
|
2
|
+
// Unknown (including future server types) is never registered for conversation.
|
|
3
|
+
export const MODEL_TYPES = Object.freeze(['llm', 'embedding', 'rerank', 'image', 'tts', 'unknown'])
|
|
4
|
+
|
|
5
|
+
export function isLLM(model) {
|
|
6
|
+
return model.type === 'llm'
|
|
7
|
+
}
|
package/lib/oidc-llm-protocol.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { serviceProtocolAllowed } from './transport.js'
|
|
2
|
+
import { accessTokenTiming } from './token-lifetime.js'
|
|
1
3
|
// Experimental draft 0.1. These wire rules are not a published standard.
|
|
2
4
|
import { protocolError } from './litellm-protocol.js'
|
|
3
5
|
|
|
@@ -10,9 +12,7 @@ const includes = (raw, key, values) => {
|
|
|
10
12
|
function endpoint(value, profile, origin) {
|
|
11
13
|
let url
|
|
12
14
|
try { url = new URL(value) } catch { fail('oidc-llm metadata contains an invalid URL') }
|
|
13
|
-
|
|
14
|
-
&& (['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || url.origin === profile.insecureDevelopmentOrigin)
|
|
15
|
-
if (typeof value !== 'string' || value.length > 2048 || (url.protocol !== 'https:' && !development)
|
|
15
|
+
if (typeof value !== 'string' || value.length > 2048 || !serviceProtocolAllowed(url, profile.allowInsecureDevelopment)
|
|
16
16
|
|| url.username || url.password || url.search || url.hash || origin && url.origin !== origin) fail('oidc-llm URL is outside the configured trust boundary')
|
|
17
17
|
return url
|
|
18
18
|
}
|
|
@@ -31,9 +31,12 @@ export function detectOidcLlm(raw, profile) {
|
|
|
31
31
|
const identityMode = profile.auth.identityMode
|
|
32
32
|
includes(extension, 'identity_modes_supported', ['oauth', identityMode])
|
|
33
33
|
includes(extension, 'client_registration_methods_supported', ['static'])
|
|
34
|
-
const
|
|
34
|
+
const identityScopes = identityMode === 'oidc'
|
|
35
35
|
? ['openid', 'profile', 'offline_access', 'llm:models:read', 'llm:invoke']
|
|
36
36
|
: ['llm:profile', 'llm:models:read', 'llm:invoke']
|
|
37
|
+
// Only request explicitly configured services; discovery is not consent to
|
|
38
|
+
// every capability advertised by the authorization server.
|
|
39
|
+
const scopes = [...new Set([...identityScopes, ...(profile.auth.additionalScopes ?? [])])]
|
|
37
40
|
for (const [key, values] of Object.entries({
|
|
38
41
|
response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
39
42
|
code_challenge_methods_supported: ['S256'], token_endpoint_auth_methods_supported: ['none'],
|
|
@@ -74,7 +77,7 @@ export function oidcLlmToken(raw, descriptor, now, previous) {
|
|
|
74
77
|
throw protocolError('gateway_scope_changed', 'oidc-llm granted scopes do not match the requested authorization')
|
|
75
78
|
}
|
|
76
79
|
// No unapproved 15-minute ceiling: honor the actual advertised TTL.
|
|
77
|
-
return { accessToken: raw.access_token, refreshToken: raw.refresh_token,
|
|
80
|
+
return { accessToken: raw.access_token, refreshToken: raw.refresh_token, ...accessTokenTiming(raw.expires_in, now), scopes, teamID: null }
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
export function oidcLlmIdentity(raw) {
|
package/lib/oidc.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { serviceProtocolAllowed } from './transport.js'
|
|
1
2
|
import { createHash, createPublicKey, randomBytes, timingSafeEqual, verify as verifySignature } from 'node:crypto'
|
|
2
3
|
import { emptyResources } from './resources.js'
|
|
4
|
+
import { accessTokenNeedsRefresh, accessTokenTiming } from './token-lifetime.js'
|
|
3
5
|
|
|
4
6
|
const FLOW_TTL_MS = 10 * 60 * 1000
|
|
5
7
|
const MAX_PENDING_FLOWS = 32
|
|
6
8
|
const CLOCK_SKEW_SECONDS = 60
|
|
7
|
-
const ACCESS_REFRESH_SECONDS = 90
|
|
8
9
|
const MAX_RESPONSE_BYTES = 1024 * 1024
|
|
9
10
|
export const OIDC_CALLBACK_PATH = '/oauth/callback'
|
|
10
11
|
|
|
@@ -43,10 +44,6 @@ async function responseJSON(response, stage) {
|
|
|
43
44
|
return value
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
function isLoopback(hostname) {
|
|
47
|
-
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'
|
|
48
|
-
}
|
|
49
|
-
|
|
50
47
|
/** OIDC metadata endpoints may use origins distinct from the issuer over HTTPS. */
|
|
51
48
|
function discoveredEndpoint(value, profile, label, optional = false) {
|
|
52
49
|
if (optional && (value === undefined || value === '')) return ''
|
|
@@ -54,17 +51,8 @@ function discoveredEndpoint(value, profile, label, optional = false) {
|
|
|
54
51
|
let endpoint
|
|
55
52
|
try { endpoint = new URL(value) }
|
|
56
53
|
catch (cause) { throw publicError('oidc_discovery_invalid', `OIDC ${label} is not an absolute URL`, cause) }
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const localDevelopment = issuerURL.protocol === 'http:' && isLoopback(issuerURL.hostname)
|
|
60
|
-
&& endpoint.protocol === 'http:' && isLoopback(endpoint.hostname)
|
|
61
|
-
const explicitlyAllowedDevelopment = profile.allowInsecureDevelopment === true
|
|
62
|
-
&& typeof profile.insecureDevelopmentOrigin === 'string'
|
|
63
|
-
&& issuerURL.origin === profile.insecureDevelopmentOrigin
|
|
64
|
-
&& endpoint.protocol === 'http:'
|
|
65
|
-
&& endpoint.origin === profile.insecureDevelopmentOrigin
|
|
66
|
-
if ((!secure && !localDevelopment && !explicitlyAllowedDevelopment) || endpoint.username || endpoint.password || endpoint.hash) {
|
|
67
|
-
throw publicError('oidc_discovery_invalid', `OIDC ${label} must be HTTPS, loopback HTTP, or the profile's exact development HTTP origin`)
|
|
54
|
+
if (!serviceProtocolAllowed(endpoint, profile.allowInsecureDevelopment) || endpoint.username || endpoint.password || endpoint.hash) {
|
|
55
|
+
throw publicError('oidc_discovery_invalid', `OIDC ${label} must be HTTPS; set allowInsecureDevelopment=true to allow HTTP`)
|
|
68
56
|
}
|
|
69
57
|
return endpoint.toString()
|
|
70
58
|
}
|
|
@@ -237,7 +225,7 @@ export class WebOidcBackend {
|
|
|
237
225
|
let response
|
|
238
226
|
try {
|
|
239
227
|
response = await this.fetch(discoveryURL(profile.oidc.issuer), {
|
|
240
|
-
headers: { accept: 'application/json' }, signal: AbortSignal.timeout(20_000),
|
|
228
|
+
headers: { accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(20_000),
|
|
241
229
|
})
|
|
242
230
|
} catch (cause) {
|
|
243
231
|
throw publicError('oidc_discovery_failed', 'unable to read OIDC discovery metadata', cause)
|
|
@@ -340,7 +328,7 @@ export class WebOidcBackend {
|
|
|
340
328
|
})
|
|
341
329
|
const response = await this.fetch(discovery.tokenEndpoint, {
|
|
342
330
|
method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
343
|
-
body, signal: AbortSignal.timeout(20_000),
|
|
331
|
+
body, redirect: 'error', signal: AbortSignal.timeout(20_000),
|
|
344
332
|
})
|
|
345
333
|
const token = await responseJSON(response, 'oidc_token_request_failed')
|
|
346
334
|
if (typeof token.access_token !== 'string' || typeof token.id_token !== 'string' || token.token_type?.toLowerCase() !== 'bearer') {
|
|
@@ -348,6 +336,7 @@ export class WebOidcBackend {
|
|
|
348
336
|
}
|
|
349
337
|
const expiresIn = token.expires_in === undefined ? 3600 : Number(token.expires_in)
|
|
350
338
|
if (!Number.isFinite(expiresIn) || expiresIn <= 0) throw publicError('oidc_token_invalid', 'OIDC token expiry is invalid')
|
|
339
|
+
const timing = accessTokenTiming(expiresIn, this.now)
|
|
351
340
|
const claims = await this.verifyIDToken(profile, discovery, token.id_token, nonce, token.access_token)
|
|
352
341
|
const userInfo = await this.userInfo(discovery.userInfoEndpoint, token.access_token)
|
|
353
342
|
if (!equalText(userInfo.sub, claims.sub)) throw publicError('oidc_userinfo_invalid', 'OIDC UserInfo subject does not match ID Token')
|
|
@@ -358,7 +347,7 @@ export class WebOidcBackend {
|
|
|
358
347
|
clientId: profile.oidc.clientId,
|
|
359
348
|
accessToken: token.access_token,
|
|
360
349
|
refreshToken: typeof token.refresh_token === 'string' ? token.refresh_token : '',
|
|
361
|
-
|
|
350
|
+
...timing,
|
|
362
351
|
identity: {
|
|
363
352
|
sub: claims.sub,
|
|
364
353
|
name: userName,
|
|
@@ -399,9 +388,12 @@ export class WebOidcBackend {
|
|
|
399
388
|
throw publicError('oidc_id_token_invalid', 'OIDC ID Token identity claims are invalid')
|
|
400
389
|
}
|
|
401
390
|
if (!Number.isFinite(claims.exp) || !Number.isFinite(claims.iat)
|
|
402
|
-
|| claims.
|
|
403
|
-
|
|
404
|
-
|
|
391
|
+
|| (claims.nbf !== undefined && !Number.isFinite(claims.nbf))) {
|
|
392
|
+
throw publicError('oidc_id_token_invalid', 'OIDC ID Token time claims are malformed')
|
|
393
|
+
}
|
|
394
|
+
if (claims.exp < now - CLOCK_SKEW_SECONDS || claims.iat > now + CLOCK_SKEW_SECONDS
|
|
395
|
+
|| (claims.nbf !== undefined && claims.nbf > now + CLOCK_SKEW_SECONDS)) {
|
|
396
|
+
throw publicError('oidc_id_token_time_invalid', 'OIDC ID Token time claims are invalid')
|
|
405
397
|
}
|
|
406
398
|
if (claims.at_hash !== undefined && (typeof claims.at_hash !== 'string' || !verifyAccessTokenHash(accessToken, claims.at_hash))) {
|
|
407
399
|
throw publicError('oidc_id_token_invalid', 'OIDC ID Token access-token hash is invalid')
|
|
@@ -411,7 +403,7 @@ export class WebOidcBackend {
|
|
|
411
403
|
|
|
412
404
|
async userInfo(endpoint, accessToken) {
|
|
413
405
|
const response = await this.fetch(endpoint, {
|
|
414
|
-
headers: { authorization: `Bearer ${accessToken}`, accept: 'application/json' }, signal: AbortSignal.timeout(20_000),
|
|
406
|
+
headers: { authorization: `Bearer ${accessToken}`, accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(20_000),
|
|
415
407
|
})
|
|
416
408
|
const result = await responseJSON(response, 'oidc_userinfo_failed')
|
|
417
409
|
if (typeof result.sub !== 'string' || result.sub === '') throw publicError('oidc_userinfo_invalid', 'OIDC UserInfo has no subject')
|
|
@@ -471,13 +463,14 @@ export class WebOidcBackend {
|
|
|
471
463
|
const epoch = this.accountEpoch(profile)
|
|
472
464
|
const saved = await this.loadSession(profile)
|
|
473
465
|
if (!saved) throw publicError('oidc_login_required', 'OIDC session is no longer available')
|
|
474
|
-
if (saved
|
|
466
|
+
if (saved.accessToken !== session.accessToken && saved.expiresAt > nowSeconds(this.now)) return saved
|
|
467
|
+
session = saved
|
|
475
468
|
if (!session.refreshToken) throw publicError('oidc_login_required', 'OIDC session cannot be refreshed')
|
|
476
469
|
const discovery = await this.discover(profile)
|
|
477
470
|
const response = await this.fetch(discovery.tokenEndpoint, {
|
|
478
471
|
method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
479
472
|
body: new URLSearchParams({ grant_type: 'refresh_token', client_id: profile.oidc.clientId, refresh_token: session.refreshToken }),
|
|
480
|
-
signal: AbortSignal.timeout(20_000),
|
|
473
|
+
redirect: 'error', signal: AbortSignal.timeout(20_000),
|
|
481
474
|
})
|
|
482
475
|
let token
|
|
483
476
|
try { token = await responseJSON(response, 'oidc_refresh_failed') }
|
|
@@ -498,7 +491,7 @@ export class WebOidcBackend {
|
|
|
498
491
|
...session,
|
|
499
492
|
accessToken: token.access_token,
|
|
500
493
|
refreshToken: typeof token.refresh_token === 'string' ? token.refresh_token : session.refreshToken,
|
|
501
|
-
|
|
494
|
+
...accessTokenTiming(expiresIn, this.now),
|
|
502
495
|
}
|
|
503
496
|
await this.saveSession(profile, next, epoch)
|
|
504
497
|
return next
|
|
@@ -508,8 +501,9 @@ export class WebOidcBackend {
|
|
|
508
501
|
const epoch = this.accountEpoch(profile)
|
|
509
502
|
let session = await this.loadSession(profile)
|
|
510
503
|
if (!session) return undefined
|
|
511
|
-
if (session
|
|
504
|
+
if (accessTokenNeedsRefresh(session, this.now)) {
|
|
512
505
|
if (!session.refreshToken) {
|
|
506
|
+
if (session.expiresAt > nowSeconds(this.now)) return session
|
|
513
507
|
await this.clearCredentials(profile, epoch)
|
|
514
508
|
this.accountChanged(account(profile, undefined, false))
|
|
515
509
|
return undefined
|
|
@@ -607,7 +601,7 @@ export class WebOidcBackend {
|
|
|
607
601
|
const token = session.refreshToken || session.accessToken
|
|
608
602
|
await this.fetch(discovery.revocationEndpoint, {
|
|
609
603
|
method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
610
|
-
body: new URLSearchParams({ token, client_id: profile.oidc.clientId }), signal: AbortSignal.timeout(10_000),
|
|
604
|
+
body: new URLSearchParams({ token, client_id: profile.oidc.clientId }), redirect: 'error', signal: AbortSignal.timeout(10_000),
|
|
611
605
|
})
|
|
612
606
|
}
|
|
613
607
|
} catch (cause) { this.ctx.logger.warn(cause instanceof Error ? cause : new Error(String(cause))) }
|
package/lib/profile.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { serviceProtocolAllowed } from './transport.js'
|
|
2
|
+
import { MODEL_TYPES, isLLM } from './model-types.js'
|
|
1
3
|
import { readFileSync } from 'node:fs'
|
|
2
4
|
|
|
3
5
|
export const PROFILE_SCHEMA_VERSION = 'dsh-oidc/v1alpha1'
|
|
@@ -18,7 +20,7 @@ const allowedRootKeys = new Set([
|
|
|
18
20
|
const allowedOidcKeys = new Set(['issuer', 'clientId', 'scopes'])
|
|
19
21
|
const allowedBrandKeys = new Set([
|
|
20
22
|
'productName', 'organizationName', 'mark', 'logoURL', 'primaryColor',
|
|
21
|
-
'loginTitle', 'loginDescription', 'supportURL',
|
|
23
|
+
'loginTitle', 'loginDescription', 'loginButtonLabel', 'supportURL', 'language',
|
|
22
24
|
])
|
|
23
25
|
const allowedProviderKeys = new Set([
|
|
24
26
|
'id', 'displayName', 'adapter', 'baseURL', 'reasoning', 'defaultContextWindow',
|
|
@@ -26,7 +28,7 @@ const allowedProviderKeys = new Set([
|
|
|
26
28
|
'requestImageMaxBytes', 'streamIdleTimeoutMs', 'retryPolicy', 'compat', 'models', 'modelSource',
|
|
27
29
|
])
|
|
28
30
|
const allowedModelKeys = new Set([
|
|
29
|
-
'id', 'name', 'input', 'contextWindow', 'maxTokens', 'reasoning',
|
|
31
|
+
'id', 'type', 'name', 'input', 'contextWindow', 'maxTokens', 'reasoning',
|
|
30
32
|
'reasoningEfforts', 'defaultReasoningEffort', 'compat',
|
|
31
33
|
])
|
|
32
34
|
const allowedCompatKeys = new Set([
|
|
@@ -52,41 +54,22 @@ function text(value, label, max = 2048) {
|
|
|
52
54
|
return value
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
function
|
|
56
|
-
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function insecureDevelopmentOrigin(value, label) {
|
|
57
|
+
function issuerURL(value, label, allowInsecure = false) {
|
|
60
58
|
const raw = text(value, label)
|
|
61
59
|
const parsed = new URL(raw)
|
|
62
|
-
|
|
63
|
-
throw new Error(`${label} must be an exact HTTP origin without credentials, path, query, or fragment`)
|
|
64
|
-
}
|
|
65
|
-
return raw.replace(/\/+$/, '')
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function endpointProtocolAllowed(parsed, allowInsecure, allowedInsecureOrigin) {
|
|
69
|
-
return parsed.protocol === 'https:' || (allowInsecure && parsed.protocol === 'http:' && (
|
|
70
|
-
isLoopback(parsed.hostname) || parsed.origin === allowedInsecureOrigin
|
|
71
|
-
))
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function issuerURL(value, label, allowInsecure = false, allowedInsecureOrigin) {
|
|
75
|
-
const raw = text(value, label)
|
|
76
|
-
const parsed = new URL(raw)
|
|
77
|
-
const permitted = endpointProtocolAllowed(parsed, allowInsecure, allowedInsecureOrigin)
|
|
60
|
+
const permitted = serviceProtocolAllowed(parsed, allowInsecure)
|
|
78
61
|
if (!permitted || parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
79
|
-
throw new Error(`${label} must be an HTTPS issuer URL without credentials, query, or fragment${allowInsecure ? ' (
|
|
62
|
+
throw new Error(`${label} must be an HTTPS issuer URL without credentials, query, or fragment${allowInsecure ? ' (HTTP is allowed with allowInsecureDevelopment=true)' : '; set allowInsecureDevelopment=true to allow HTTP'}`)
|
|
80
63
|
}
|
|
81
64
|
return raw
|
|
82
65
|
}
|
|
83
66
|
|
|
84
|
-
function exactURL(value, label, allowInsecure = false
|
|
67
|
+
function exactURL(value, label, allowInsecure = false) {
|
|
85
68
|
const raw = text(value, label)
|
|
86
69
|
const parsed = new URL(raw)
|
|
87
|
-
const permitted =
|
|
70
|
+
const permitted = serviceProtocolAllowed(parsed, allowInsecure)
|
|
88
71
|
if (!permitted || parsed.username || parsed.password || parsed.hash || parsed.search) {
|
|
89
|
-
throw new Error(`${label} must be an absolute HTTPS URL without credentials, query, or fragment${allowInsecure ? ' (
|
|
72
|
+
throw new Error(`${label} must be an absolute HTTPS URL without credentials, query, or fragment${allowInsecure ? ' (HTTP is allowed with allowInsecureDevelopment=true)' : '; set allowInsecureDevelopment=true to allow HTTP'}`)
|
|
90
73
|
}
|
|
91
74
|
return raw.replace(/\/+$/, '')
|
|
92
75
|
}
|
|
@@ -132,10 +115,14 @@ function normalizeBrand(value = {}) {
|
|
|
132
115
|
}
|
|
133
116
|
result.logoURL = logo.startsWith('https://') ? safeHTTPSResource(logo, 'brand.logoURL', 128 * 1024) : logo
|
|
134
117
|
}
|
|
135
|
-
for (const key of
|
|
136
|
-
if (source[key] !== undefined) result[key] = text(source[key], `brand.${key}`,
|
|
118
|
+
for (const [key, max] of Object.entries({ loginTitle: 120, loginDescription: 500, loginButtonLabel: 40 })) {
|
|
119
|
+
if (source[key] !== undefined) result[key] = text(source[key], `brand.${key}`, max)
|
|
137
120
|
}
|
|
138
121
|
if (source.supportURL !== undefined) result.supportURL = exactURL(source.supportURL, 'brand.supportURL')
|
|
122
|
+
if (source.language !== undefined) {
|
|
123
|
+
if (!['auto', 'zh-CN', 'en'].includes(source.language)) throw new Error('brand.language must be auto, zh-CN or en')
|
|
124
|
+
result.language = source.language
|
|
125
|
+
}
|
|
139
126
|
return Object.freeze(result)
|
|
140
127
|
}
|
|
141
128
|
|
|
@@ -180,6 +167,8 @@ export function normalizeModels(models, providerID) {
|
|
|
180
167
|
const model = object(raw, label)
|
|
181
168
|
exactKeys(model, allowedModelKeys, label)
|
|
182
169
|
const id = text(model.id, `${label}.id`, 256)
|
|
170
|
+
const type = model.type === undefined ? 'unknown' : model.type
|
|
171
|
+
if (!MODEL_TYPES.includes(type)) throw new Error(`${label}.type must be one of ${MODEL_TYPES.join(', ')}`)
|
|
183
172
|
if (seen.has(id)) throw new Error(`${providerID} repeats model ${id}`)
|
|
184
173
|
seen.add(id)
|
|
185
174
|
const input = model.input ?? ['text']
|
|
@@ -202,6 +191,7 @@ export function normalizeModels(models, providerID) {
|
|
|
202
191
|
}
|
|
203
192
|
return Object.freeze({
|
|
204
193
|
id,
|
|
194
|
+
type,
|
|
205
195
|
name: model.name === undefined ? id : text(model.name, `${providerID}.${id}.name`, 256),
|
|
206
196
|
input: Object.freeze([...new Set(input)]),
|
|
207
197
|
...(model.contextWindow === undefined ? {} : { contextWindow: positiveInteger(model.contextWindow, `${providerID}.${id}.contextWindow`) }),
|
|
@@ -269,28 +259,29 @@ export function normalizeEnterpriseProfile(raw) {
|
|
|
269
259
|
const id = text(source.id, 'profile.id', 64)
|
|
270
260
|
if (!idPattern.test(id)) throw new Error('profile.id is invalid')
|
|
271
261
|
const allowInsecureDevelopment = source.allowInsecureDevelopment === true
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
: insecureDevelopmentOrigin(source.insecureDevelopmentOrigin, 'profile.insecureDevelopmentOrigin')
|
|
275
|
-
if (allowedInsecureOrigin !== undefined && !allowInsecureDevelopment) {
|
|
276
|
-
throw new Error('profile.insecureDevelopmentOrigin requires allowInsecureDevelopment=true')
|
|
277
|
-
}
|
|
262
|
+
if (source.allowInsecureDevelopment !== undefined && typeof source.allowInsecureDevelopment !== 'boolean') throw new Error('profile.allowInsecureDevelopment must be boolean')
|
|
263
|
+
// Accept the obsolete field in old files, but it no longer controls transport.
|
|
278
264
|
const gateway = source.auth !== undefined
|
|
279
265
|
if (gateway && source.oidc !== undefined) throw new Error('profile.auth cannot be combined with oidc')
|
|
280
266
|
let auth
|
|
281
267
|
if (gateway) {
|
|
282
268
|
const rawAuth = object(source.auth, 'profile.auth')
|
|
283
|
-
exactKeys(rawAuth, new Set(['discoveryUrl', 'expectedIssuer', 'experimentalOidcLlm', 'clientId', 'identityMode']), 'profile.auth')
|
|
269
|
+
exactKeys(rawAuth, new Set(['discoveryUrl', 'expectedIssuer', 'experimentalOidcLlm', 'clientId', 'identityMode', 'additionalScopes']), 'profile.auth')
|
|
284
270
|
const experimental = rawAuth.experimentalOidcLlm === true
|
|
285
271
|
if (rawAuth.experimentalOidcLlm !== undefined && typeof rawAuth.experimentalOidcLlm !== 'boolean') throw new Error('profile.auth.experimentalOidcLlm must be boolean')
|
|
286
|
-
if (!experimental && (rawAuth.clientId !== undefined || rawAuth.identityMode !== undefined)) throw new Error('profile.auth clientId and
|
|
272
|
+
if (!experimental && (rawAuth.clientId !== undefined || rawAuth.identityMode !== undefined || rawAuth.additionalScopes !== undefined)) throw new Error('profile.auth clientId, identityMode and additionalScopes require experimentalOidcLlm=true')
|
|
287
273
|
if (experimental && !['oauth', 'oidc'].includes(rawAuth.identityMode)) throw new Error('profile.auth.identityMode must explicitly select oauth or oidc')
|
|
288
|
-
if (
|
|
289
|
-
|
|
274
|
+
if (rawAuth.additionalScopes !== undefined && (!Array.isArray(rawAuth.additionalScopes)
|
|
275
|
+
|| rawAuth.additionalScopes.length > 64
|
|
276
|
+
|| rawAuth.additionalScopes.some(scope => typeof scope !== 'string' || scope.length > 128 || !/^[\x21\x23-\x5b\x5d-\x7e]+$/.test(scope))
|
|
277
|
+
|| new Set(rawAuth.additionalScopes).size !== rawAuth.additionalScopes.length)) {
|
|
278
|
+
throw new Error('profile.auth.additionalScopes must contain at most 64 unique OAuth scope tokens, each at most 128 characters')
|
|
279
|
+
}
|
|
290
280
|
auth = Object.freeze({
|
|
291
|
-
discoveryUrl: issuerURL(rawAuth.discoveryUrl, 'profile.auth.discoveryUrl', allowInsecureDevelopment
|
|
292
|
-
...(rawAuth.expectedIssuer === undefined ? {} : { expectedIssuer: issuerURL(rawAuth.expectedIssuer, 'profile.auth.expectedIssuer', allowInsecureDevelopment
|
|
281
|
+
discoveryUrl: issuerURL(rawAuth.discoveryUrl, 'profile.auth.discoveryUrl', allowInsecureDevelopment),
|
|
282
|
+
...(rawAuth.expectedIssuer === undefined ? {} : { expectedIssuer: issuerURL(rawAuth.expectedIssuer, 'profile.auth.expectedIssuer', allowInsecureDevelopment) }),
|
|
293
283
|
...(experimental ? { experimentalOidcLlm: true, clientId: text(rawAuth.clientId, 'profile.auth.clientId', 256), identityMode: rawAuth.identityMode } : {}),
|
|
284
|
+
...(rawAuth.additionalScopes === undefined ? {} : { additionalScopes: Object.freeze([...rawAuth.additionalScopes]) }),
|
|
294
285
|
})
|
|
295
286
|
if (source.provider?.baseURL !== undefined) throw new Error('profile.auth derives provider.baseURL from validated discovery')
|
|
296
287
|
if (source.provider?.modelSource !== undefined && source.provider.modelSource !== 'discovery') throw new Error('profile.auth requires discovery modelSource')
|
|
@@ -316,11 +307,10 @@ export function normalizeEnterpriseProfile(raw) {
|
|
|
316
307
|
displayName: text(source.displayName, 'profile.displayName', 120),
|
|
317
308
|
organization: text(source.organization ?? source.displayName, 'profile.organization', 120),
|
|
318
309
|
allowInsecureDevelopment,
|
|
319
|
-
...(allowedInsecureOrigin === undefined ? {} : { insecureDevelopmentOrigin: allowedInsecureOrigin }),
|
|
320
310
|
nativeInstitutionID: source.nativeInstitutionID === undefined ? id : text(source.nativeInstitutionID, 'profile.nativeInstitutionID', 64),
|
|
321
311
|
brand: normalizeBrand(source.brand),
|
|
322
312
|
...(gateway ? { auth } : { oidc: Object.freeze({
|
|
323
|
-
issuer: issuerURL(oidc.issuer, 'profile.oidc.issuer', allowInsecureDevelopment
|
|
313
|
+
issuer: issuerURL(oidc.issuer, 'profile.oidc.issuer', allowInsecureDevelopment),
|
|
324
314
|
clientId: text(oidc.clientId, 'profile.oidc.clientId', 256),
|
|
325
315
|
scopes: Object.freeze([...scopes]),
|
|
326
316
|
}) }),
|
|
@@ -379,12 +369,11 @@ export function loadEnterpriseProfiles(raw = {}, environment = process.env) {
|
|
|
379
369
|
|
|
380
370
|
export function enterpriseProviderConfig(profiles) {
|
|
381
371
|
return {
|
|
382
|
-
providers: Object.fromEntries([...profiles.values()].filter(profile => profile.provider?.baseURL && profile.provider.models.
|
|
372
|
+
providers: Object.fromEntries([...profiles.values()].filter(profile => profile.provider?.baseURL && profile.provider.models.some(isLLM)).map(profile => [profile.provider.id, {
|
|
383
373
|
displayName: profile.provider.displayName,
|
|
384
374
|
apiKeyEnv: `DSH_GATEWAY_${profile.id.toUpperCase().replace(/-/g, '_')}_ACCESS`,
|
|
385
375
|
baseURL: profile.provider.baseURL,
|
|
386
376
|
allowInsecureDevelopment: profile.allowInsecureDevelopment,
|
|
387
|
-
...(profile.insecureDevelopmentOrigin === undefined ? {} : { insecureDevelopmentOrigin: profile.insecureDevelopmentOrigin }),
|
|
388
377
|
reasoning: profile.provider.reasoning,
|
|
389
378
|
...(profile.provider.defaultContextWindow === undefined ? {} : { defaultContextWindow: profile.provider.defaultContextWindow }),
|
|
390
379
|
...(profile.provider.defaultMaxTokens === undefined ? {} : { defaultMaxTokens: profile.provider.defaultMaxTokens }),
|
|
@@ -394,7 +383,7 @@ export function enterpriseProviderConfig(profiles) {
|
|
|
394
383
|
...(profile.provider.streamIdleTimeoutMs === undefined ? {} : { streamIdleTimeoutMs: profile.provider.streamIdleTimeoutMs }),
|
|
395
384
|
...(profile.provider.retryPolicy === undefined ? {} : { retryPolicy: structuredClone(profile.provider.retryPolicy) }),
|
|
396
385
|
...(profile.provider.compat === undefined ? {} : { compat: { ...profile.provider.compat } }),
|
|
397
|
-
models: profile.provider.models.map(model => ({
|
|
386
|
+
models: profile.provider.models.filter(isLLM).map(({ type, ...model }) => ({
|
|
398
387
|
...model,
|
|
399
388
|
input: [...model.input],
|
|
400
389
|
...(model.reasoningEfforts && typeof model.reasoningEfforts === 'object'
|
|
@@ -416,7 +405,7 @@ export function publicProfile(profile) {
|
|
|
416
405
|
...(profile.provider ? { provider: {
|
|
417
406
|
id: profile.provider.id,
|
|
418
407
|
displayName: profile.provider.displayName,
|
|
419
|
-
models: profile.provider.models.map(model => ({ id: model.id, name: model.name, input: model.input })),
|
|
408
|
+
models: profile.provider.models.filter(isLLM).map(model => ({ id: model.id, name: model.name, input: model.input })),
|
|
420
409
|
} } : {}),
|
|
421
410
|
}
|
|
422
411
|
}
|
|
@@ -9,6 +9,10 @@ export class AuthorizationScopedAdapter {
|
|
|
9
9
|
providerInfo(provider) { return this.inner.providerInfo(provider) }
|
|
10
10
|
providerRetryPolicy(provider) { return this.inner.providerRetryPolicy(provider) }
|
|
11
11
|
imageRequestPricing(provider, model) { return this.inner.imageRequestPricing(provider, model) }
|
|
12
|
+
imageRequestTarget(provider, model, image) {
|
|
13
|
+
return this.inner.imageRequestTarget?.(provider, model, image)
|
|
14
|
+
?? { width: image.width, height: image.height, maxBytes: image.bytes }
|
|
15
|
+
}
|
|
12
16
|
listModels(provider) { return this.inner.listModels(provider) }
|
|
13
17
|
resolveModel(provider, model, signal) { return this.inner.resolveModel(provider, model, signal) }
|
|
14
18
|
|
package/lib/provider/core.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { serviceProtocolAllowed } from '../transport.js'
|
|
1
2
|
const modalities = new Set(['text', 'image'])
|
|
2
3
|
export const REASONING_EFFORT_ORDER = Object.freeze(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
|
|
3
4
|
const reasoningEfforts = new Set(REASONING_EFFORT_ORDER)
|
|
@@ -12,23 +13,11 @@ function nonEmpty(value, label) {
|
|
|
12
13
|
return value.trim()
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
function
|
|
16
|
-
if (value === undefined) return undefined
|
|
16
|
+
function endpointURL(value, label, allowInsecureDevelopment = false) {
|
|
17
17
|
const raw = nonEmpty(value, label).replace(/\/+$/, '')
|
|
18
18
|
const parsed = new URL(raw)
|
|
19
|
-
if (
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
return raw
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function endpointURL(value, label, allowInsecureDevelopment = false, insecureDevelopmentOrigin) {
|
|
26
|
-
const raw = nonEmpty(value, label).replace(/\/+$/, '')
|
|
27
|
-
const parsed = new URL(raw)
|
|
28
|
-
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname)
|
|
29
|
-
const allowedOrigin = exactDevelopmentOrigin(insecureDevelopmentOrigin, `${label}.insecureDevelopmentOrigin`)
|
|
30
|
-
const permittedHTTP = parsed.protocol === 'http:' && (loopback || (allowInsecureDevelopment === true && parsed.origin === allowedOrigin))
|
|
31
|
-
if ((parsed.protocol !== 'https:' && !permittedHTTP) || parsed.username || parsed.password || parsed.hash || parsed.search) {
|
|
19
|
+
if (typeof allowInsecureDevelopment !== 'boolean') throw new Error(`${label}.allowInsecureDevelopment must be boolean`)
|
|
20
|
+
if (!serviceProtocolAllowed(parsed, allowInsecureDevelopment) || parsed.username || parsed.password || parsed.hash || parsed.search) {
|
|
32
21
|
throw new Error(`${label} must be HTTPS or explicitly allowed development HTTP without credentials, query, or fragment`)
|
|
33
22
|
}
|
|
34
23
|
return raw
|
|
@@ -100,7 +89,7 @@ export function resolveEnterpriseProfiles(raw = {}) {
|
|
|
100
89
|
const route = nonEmpty(provider, 'provider route')
|
|
101
90
|
if (typeof source !== 'object' || source === null || Array.isArray(source)) throw new Error(`${route} config must be an object`)
|
|
102
91
|
const displayName = nonEmpty(source.displayName ?? route, `${route}.displayName`)
|
|
103
|
-
const baseURL = endpointURL(source.baseURL, `${route}.baseURL`, source.allowInsecureDevelopment
|
|
92
|
+
const baseURL = endpointURL(source.baseURL, `${route}.baseURL`, source.allowInsecureDevelopment)
|
|
104
93
|
const credentialRef = nonEmpty(source.apiKeyEnv, `${route}.apiKeyEnv`)
|
|
105
94
|
const contextWindow = positiveInteger(source.defaultContextWindow, 262144, `${route}.defaultContextWindow`)
|
|
106
95
|
const maxTokens = positiveInteger(source.defaultMaxTokens, 32768, `${route}.defaultMaxTokens`)
|
|
@@ -185,16 +174,16 @@ export function resolveEnterpriseProfiles(raw = {}) {
|
|
|
185
174
|
})
|
|
186
175
|
}
|
|
187
176
|
|
|
188
|
-
export function configurableEntries(profiles) {
|
|
177
|
+
export function configurableEntries(profiles, settingsNs = ENTERPRISE_SETTINGS_NAMESPACE) {
|
|
189
178
|
return [...profiles.values()].map(profile => ({
|
|
190
179
|
provider: profile.provider,
|
|
191
180
|
displayName: profile.displayName,
|
|
192
|
-
settingsNs
|
|
181
|
+
settingsNs,
|
|
193
182
|
settingsPath: ['providers', profile.provider],
|
|
194
183
|
declared: true,
|
|
195
184
|
}))
|
|
196
185
|
}
|
|
197
186
|
|
|
198
187
|
export function settingsBase(rawConfig) {
|
|
199
|
-
return { providers: rawConfig.providers ?? {} }
|
|
188
|
+
return { providers: typeof rawConfig.providers?.get === 'function' ? rawConfig.providers.get() : rawConfig.providers ?? {} }
|
|
200
189
|
}
|