@thebtf/dsh-cliproxyapi-provider 0.4.3 → 0.4.4

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/src/migration.js CHANGED
@@ -1,132 +1,132 @@
1
- import { createHash } from 'node:crypto'
2
- import { ConnectionError, validateEndpoint } from './connection.js'
3
- import { credentialField } from './secrets.js'
4
- import { PROTOCOLS, validatePolicy } from './policy.js'
5
-
6
- const OWN = 'llm-cliproxyapi'
7
- const LEGACY = 'llm-pi-ai'
8
- const PROVIDER = 'CLIProxyAPI'
9
- const fingerprintOf = (profile) => createHash('sha256').update(JSON.stringify(profile)).digest('hex')
10
- const PLACEHOLDER = 'Bearer dsh-cliproxyapi-no-key'
11
- function portableProfile(value) {
12
- const profile = structuredClone(value)
13
- if (profile.headers) {
14
- for (const [key, header] of Object.entries(profile.headers)) {
15
- if (key.toLowerCase() === 'x-dsh-provider-cpa-sync' || key.toLowerCase() === 'authorization' && header === PLACEHOLDER) delete profile.headers[key]
16
- }
17
- }
18
- return profile
19
- }
20
- function containsSecrets(value) {
21
- if (!value || typeof value !== 'object') return false
22
- return Object.entries(value).some(([key, item]) => credentialField(key) || containsSecrets(item))
23
- }
24
- function rows(settings) {
25
- const entries = settings.describe()
26
- return { own: entries.find((row) => row.ns === OWN), legacy: entries.find((row) => row.ns === LEGACY) }
27
- }
28
- export function legacyView(settings) {
29
- const { legacy } = rows(settings)
30
- const profile = legacy?.user?.providers?.[PROVIDER]
31
- if (!profile) return { available: false }
32
- if (legacy.base?.providers?.[PROVIDER]) return { available: false, reason: 'Legacy provider also exists in a base layer' }
33
- if (!profile.baseURL) return { available: false, reason: 'Legacy endpoint is missing' }
34
- if (containsSecrets(portableProfile(profile))) return { available: false, reason: 'Legacy provider contains a secret field requiring manual migration' }
35
- return { available: true, baseURL: profile.baseURL, revision: legacy.revision, fingerprint: fingerprintOf(profile) }
36
- }
37
- async function setOwn(settings, values, revision) {
38
- await settings.mutate(OWN, Object.entries(values).map(([key, value]) => ({ op: 'set', path: [key], value })), revision)
39
- }
40
- export async function prepareLegacy(settings, input) {
41
- const { own, legacy } = rows(settings)
42
- if (!own || !legacy || own.revision !== input.expectedRevision || legacy.revision !== input.expectedLegacyRevision) throw new ConnectionError('SETTINGS_CONFLICT', 'Settings changed before migration')
43
- if (own.value?.baseURL || own.value?.migration) throw new ConnectionError('MIGRATION_CONFLICT', 'Connection already exists')
44
- const summary = legacyView(settings)
45
- if (!summary.available || summary.fingerprint !== input.fingerprint) throw new ConnectionError('MIGRATION_CONFLICT', summary.reason ?? 'Legacy provider changed')
46
- const profile = portableProfile(legacy.user.providers[PROVIDER])
47
- validateEndpoint(profile.baseURL)
48
- if (profile.api !== undefined && !PROTOCOLS.includes(profile.api)) throw new ConnectionError('MIGRATION_UNSUPPORTED', 'Legacy wire protocol cannot be migrated')
49
- const known = new Set(['displayName', 'api', 'baseURL', 'apiKeyEnv', 'models', 'modelOverrides', 'defaultContextWindow', 'defaultMaxTokens', 'defaultInput', 'headers', 'compat', 'retryPolicy', 'reasoning', 'thinkingBudgets', 'streamIdleTimeoutMs', 'maxRequestImageBytes', 'maxRequestImages', 'imageOffloadCountQuantum', 'requestImagePixelBudget', 'requestImageMaxBytes', 'cacheRetention', 'transport', 'timeoutMs', 'websocketConnectTimeoutMs'])
50
- if (Object.keys(profile).some((key) => !known.has(key))) throw new ConnectionError('MIGRATION_UNSUPPORTED', 'Legacy provider has unsupported fields')
51
- if (typeof input.operationId !== 'string' || !input.operationId) throw new ConnectionError('INVALID_OPERATION', 'An operation ID is required')
52
- const generation = createHash('sha256').update(input.operationId).update('\0').update(String(own.revision)).digest('hex')
53
- const authIdentity = profile.apiKeyEnv ? { type: 'reference', ref: profile.apiKeyEnv } : { type: 'none' }
54
- const { baseURL, apiKeyEnv, models, modelOverrides, ...policy } = profile
55
- validatePolicy(policy)
56
- const overrides = {}
57
- for (const model of models ?? []) {
58
- const { id, input, ...rest } = model
59
- if (typeof id !== 'string' || !id || Object.keys(rest).some((key) => !['name', 'contextWindow', 'maxTokens', 'reasoningEfforts', 'compat'].includes(key))) throw new ConnectionError('MIGRATION_UNSUPPORTED', 'Legacy model has unsupported fields')
60
- overrides[id] = { ...rest, ...(input === undefined ? {} : { inputModalities: input }) }
61
- }
62
- for (const [id, value] of Object.entries(modelOverrides ?? {})) {
63
- const { input, ...rest } = value
64
- if (Object.keys(rest).some((key) => !['name', 'contextWindow', 'maxTokens', 'reasoningEfforts', 'compat'].includes(key))) throw new ConnectionError('MIGRATION_UNSUPPORTED', 'Legacy model override has unsupported fields')
65
- overrides[id] = { ...overrides[id], ...rest, ...(rest.compat && overrides[id]?.compat ? { compat: { ...overrides[id].compat, ...rest.compat } } : {}), ...(input === undefined ? {} : { inputModalities: input }) }
66
- }
67
- const migration = { phase: 'prepared', operationId: input.operationId, generation, legacyRevision: legacy.revision, fingerprint: summary.fingerprint, backupFingerprint: fingerprintOf(profile), backup: profile, provenance: 'legacy' }
68
- await setOwn(settings, { baseURL, authIdentity, enabled: true, generation, operationId: input.operationId, policy, overrides, migration }, own.revision)
69
- return migration
70
- }
71
- // Undo a prepared-but-not-cut-over migration. The legacy provider is still in
72
- // place at this point, so only our own row is cleared; rollbackLegacy is for the
73
- // post-cutover case where the legacy entry has to be restored.
74
- export async function discardPrepared(settings) {
75
- const { own } = rows(settings)
76
- const migration = own?.value?.migration
77
- if (!migration || migration.phase !== 'prepared') return false
78
- if (own.value.generation !== migration.generation) throw new ConnectionError('MIGRATION_CONFLICT', 'Connection changed after migration; forward repair is required')
79
- await settings.mutate(OWN, ['baseURL', 'authIdentity', 'generation', 'operationId', 'policy', 'overrides', 'migration'].map((key) => ({ op: 'unset', path: [key] })), own.revision)
80
- return true
81
- }
82
- export async function cutoverLegacy(settings) {
83
- let { own, legacy } = rows(settings)
84
- let migration = own?.value?.migration
85
- if (!migration || !['prepared', 'cutover'].includes(migration.phase)) return false
86
- const current = legacy?.user?.providers?.[PROVIDER]
87
- if (legacy?.base?.providers?.[PROVIDER]) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy provider exists in a base layer')
88
- if (current && fingerprintOf(current) !== migration.fingerprint) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy provider changed during migration')
89
- if (migration.phase === 'prepared') {
90
- if (!current || legacy.revision !== migration.legacyRevision) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy provider changed before cutover')
91
- migration = { ...migration, phase: 'cutover' }
92
- await setOwn(settings, { migration }, own.revision)
93
- ; ({ own, legacy } = rows(settings))
94
- }
95
- if (legacy?.user?.providers?.[PROVIDER]) await settings.mutate(LEGACY, [{ op: 'unset', path: ['providers', PROVIDER] }], legacy.revision)
96
- ; ({ legacy } = rows(settings))
97
- if (legacy?.value?.providers?.[PROVIDER]) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy provider remains after cutover')
98
- return true
99
- }
100
- export async function finishLegacy(settings) {
101
- const { own, legacy } = rows(settings)
102
- if (own?.value?.migration?.phase !== 'cutover' || legacy?.value?.providers?.[PROVIDER]) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy cutover is not complete')
103
- await setOwn(settings, { migration: { ...own.value.migration, phase: 'active' } }, own.revision)
104
- }
105
-
106
- export async function rollbackLegacy(settings, releaseRoute) {
107
- let { own, legacy } = rows(settings)
108
- const migration = own?.value?.migration
109
- if (!migration || own.value.generation !== migration.generation) throw new ConnectionError('MIGRATION_CONFLICT', 'Connection changed after migration; forward repair is required')
110
- let ownedRevision = own.revision
111
- const checkOwner = () => {
112
- const current = rows(settings).own
113
- if (current?.revision !== ownedRevision || current.value?.generation !== migration.generation || current.value?.migration?.operationId !== migration.operationId || current.value?.migration?.generation !== migration.generation) throw new ConnectionError('MIGRATION_CONFLICT', 'Connection changed during rollback; forward repair is required')
114
- }
115
- if (legacy?.base?.providers?.[PROVIDER]) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy provider exists in a base layer')
116
- if (migration.phase !== 'rollback') {
117
- if (legacy?.value?.providers?.[PROVIDER] || legacy?.user?.providers?.[PROVIDER]) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy provider already exists')
118
- await setOwn(settings, { migration: { ...migration, phase: 'rollback' } }, own.revision)
119
- ownedRevision += 1
120
- checkOwner()
121
- }
122
- await releaseRoute()
123
- checkOwner()
124
- ; ({ own, legacy } = rows(settings))
125
- const existing = legacy?.user?.providers?.[PROVIDER]
126
- if (legacy?.base?.providers?.[PROVIDER] || (existing && fingerprintOf(existing) !== migration.backupFingerprint) || (!existing && legacy?.value?.providers?.[PROVIDER])) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy provider appeared during rollback')
127
- if (!existing) await settings.mutate(LEGACY, [{ op: 'set', path: ['providers', PROVIDER], value: migration.backup }], legacy.revision)
128
- checkOwner()
129
- ; ({ own, legacy } = rows(settings))
130
- if (!legacy?.user?.providers?.[PROVIDER] || fingerprintOf(legacy.user.providers[PROVIDER]) !== migration.backupFingerprint) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy rollback could not be confirmed')
131
- await settings.mutate(OWN, ['baseURL', 'authIdentity', 'generation', 'operationId', 'policy', 'overrides', 'migration'].map((key) => ({ op: 'unset', path: [key] })), ownedRevision)
132
- }
1
+ import { createHash } from 'node:crypto'
2
+ import { ConnectionError, validateEndpoint } from './connection.js'
3
+ import { credentialField } from './secrets.js'
4
+ import { PROTOCOLS, validatePolicy } from './policy.js'
5
+
6
+ const OWN = 'llm-cliproxyapi'
7
+ const LEGACY = 'llm-pi-ai'
8
+ const PROVIDER = 'CLIProxyAPI'
9
+ const fingerprintOf = (profile) => createHash('sha256').update(JSON.stringify(profile)).digest('hex')
10
+ const PLACEHOLDER = 'Bearer dsh-cliproxyapi-no-key'
11
+ function portableProfile(value) {
12
+ const profile = structuredClone(value)
13
+ if (profile.headers) {
14
+ for (const [key, header] of Object.entries(profile.headers)) {
15
+ if (key.toLowerCase() === 'x-dsh-provider-cpa-sync' || key.toLowerCase() === 'authorization' && header === PLACEHOLDER) delete profile.headers[key]
16
+ }
17
+ }
18
+ return profile
19
+ }
20
+ function containsSecrets(value) {
21
+ if (!value || typeof value !== 'object') return false
22
+ return Object.entries(value).some(([key, item]) => credentialField(key) || containsSecrets(item))
23
+ }
24
+ function rows(settings) {
25
+ const entries = settings.describe()
26
+ return { own: entries.find((row) => row.ns === OWN), legacy: entries.find((row) => row.ns === LEGACY) }
27
+ }
28
+ export function legacyView(settings) {
29
+ const { legacy } = rows(settings)
30
+ const profile = legacy?.user?.providers?.[PROVIDER]
31
+ if (!profile) return { available: false }
32
+ if (legacy.base?.providers?.[PROVIDER]) return { available: false, reason: 'Legacy provider also exists in a base layer' }
33
+ if (!profile.baseURL) return { available: false, reason: 'Legacy endpoint is missing' }
34
+ if (containsSecrets(portableProfile(profile))) return { available: false, reason: 'Legacy provider contains a secret field requiring manual migration' }
35
+ return { available: true, baseURL: profile.baseURL, revision: legacy.revision, fingerprint: fingerprintOf(profile) }
36
+ }
37
+ async function setOwn(settings, values, revision) {
38
+ await settings.mutate(OWN, Object.entries(values).map(([key, value]) => ({ op: 'set', path: [key], value })), revision)
39
+ }
40
+ export async function prepareLegacy(settings, input) {
41
+ const { own, legacy } = rows(settings)
42
+ if (!own || !legacy || own.revision !== input.expectedRevision || legacy.revision !== input.expectedLegacyRevision) throw new ConnectionError('SETTINGS_CONFLICT', 'Settings changed before migration')
43
+ if (own.value?.baseURL || own.value?.migration) throw new ConnectionError('MIGRATION_CONFLICT', 'Connection already exists')
44
+ const summary = legacyView(settings)
45
+ if (!summary.available || summary.fingerprint !== input.fingerprint) throw new ConnectionError('MIGRATION_CONFLICT', summary.reason ?? 'Legacy provider changed')
46
+ const profile = portableProfile(legacy.user.providers[PROVIDER])
47
+ validateEndpoint(profile.baseURL)
48
+ if (profile.api !== undefined && !PROTOCOLS.includes(profile.api)) throw new ConnectionError('MIGRATION_UNSUPPORTED', 'Legacy wire protocol cannot be migrated')
49
+ const known = new Set(['displayName', 'api', 'baseURL', 'apiKeyEnv', 'models', 'modelOverrides', 'defaultContextWindow', 'defaultMaxTokens', 'defaultInput', 'headers', 'compat', 'retryPolicy', 'reasoning', 'thinkingBudgets', 'streamIdleTimeoutMs', 'maxRequestImageBytes', 'maxRequestImages', 'imageOffloadCountQuantum', 'requestImagePixelBudget', 'requestImageMaxBytes', 'cacheRetention', 'transport', 'timeoutMs', 'websocketConnectTimeoutMs'])
50
+ if (Object.keys(profile).some((key) => !known.has(key))) throw new ConnectionError('MIGRATION_UNSUPPORTED', 'Legacy provider has unsupported fields')
51
+ if (typeof input.operationId !== 'string' || !input.operationId) throw new ConnectionError('INVALID_OPERATION', 'An operation ID is required')
52
+ const generation = createHash('sha256').update(input.operationId).update('\0').update(String(own.revision)).digest('hex')
53
+ const authIdentity = profile.apiKeyEnv ? { type: 'reference', ref: profile.apiKeyEnv } : { type: 'none' }
54
+ const { baseURL, apiKeyEnv, models, modelOverrides, ...policy } = profile
55
+ validatePolicy(policy)
56
+ const overrides = {}
57
+ for (const model of models ?? []) {
58
+ const { id, input, ...rest } = model
59
+ if (typeof id !== 'string' || !id || Object.keys(rest).some((key) => !['name', 'contextWindow', 'maxTokens', 'reasoningEfforts', 'compat'].includes(key))) throw new ConnectionError('MIGRATION_UNSUPPORTED', 'Legacy model has unsupported fields')
60
+ overrides[id] = { ...rest, ...(input === undefined ? {} : { inputModalities: input }) }
61
+ }
62
+ for (const [id, value] of Object.entries(modelOverrides ?? {})) {
63
+ const { input, ...rest } = value
64
+ if (Object.keys(rest).some((key) => !['name', 'contextWindow', 'maxTokens', 'reasoningEfforts', 'compat'].includes(key))) throw new ConnectionError('MIGRATION_UNSUPPORTED', 'Legacy model override has unsupported fields')
65
+ overrides[id] = { ...overrides[id], ...rest, ...(rest.compat && overrides[id]?.compat ? { compat: { ...overrides[id].compat, ...rest.compat } } : {}), ...(input === undefined ? {} : { inputModalities: input }) }
66
+ }
67
+ const migration = { phase: 'prepared', operationId: input.operationId, generation, legacyRevision: legacy.revision, fingerprint: summary.fingerprint, backupFingerprint: fingerprintOf(profile), backup: profile, provenance: 'legacy' }
68
+ await setOwn(settings, { baseURL, authIdentity, enabled: true, generation, operationId: input.operationId, policy, overrides, migration }, own.revision)
69
+ return migration
70
+ }
71
+ // Undo a prepared-but-not-cut-over migration. The legacy provider is still in
72
+ // place at this point, so only our own row is cleared; rollbackLegacy is for the
73
+ // post-cutover case where the legacy entry has to be restored.
74
+ export async function discardPrepared(settings) {
75
+ const { own } = rows(settings)
76
+ const migration = own?.value?.migration
77
+ if (!migration || migration.phase !== 'prepared') return false
78
+ if (own.value.generation !== migration.generation) throw new ConnectionError('MIGRATION_CONFLICT', 'Connection changed after migration; forward repair is required')
79
+ await settings.mutate(OWN, ['baseURL', 'authIdentity', 'generation', 'operationId', 'policy', 'overrides', 'migration'].map((key) => ({ op: 'unset', path: [key] })), own.revision)
80
+ return true
81
+ }
82
+ export async function cutoverLegacy(settings) {
83
+ let { own, legacy } = rows(settings)
84
+ let migration = own?.value?.migration
85
+ if (!migration || !['prepared', 'cutover'].includes(migration.phase)) return false
86
+ const current = legacy?.user?.providers?.[PROVIDER]
87
+ if (legacy?.base?.providers?.[PROVIDER]) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy provider exists in a base layer')
88
+ if (current && fingerprintOf(current) !== migration.fingerprint) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy provider changed during migration')
89
+ if (migration.phase === 'prepared') {
90
+ if (!current || legacy.revision !== migration.legacyRevision) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy provider changed before cutover')
91
+ migration = { ...migration, phase: 'cutover' }
92
+ await setOwn(settings, { migration }, own.revision)
93
+ ; ({ own, legacy } = rows(settings))
94
+ }
95
+ if (legacy?.user?.providers?.[PROVIDER]) await settings.mutate(LEGACY, [{ op: 'unset', path: ['providers', PROVIDER] }], legacy.revision)
96
+ ; ({ legacy } = rows(settings))
97
+ if (legacy?.value?.providers?.[PROVIDER]) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy provider remains after cutover')
98
+ return true
99
+ }
100
+ export async function finishLegacy(settings) {
101
+ const { own, legacy } = rows(settings)
102
+ if (own?.value?.migration?.phase !== 'cutover' || legacy?.value?.providers?.[PROVIDER]) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy cutover is not complete')
103
+ await setOwn(settings, { migration: { ...own.value.migration, phase: 'active' } }, own.revision)
104
+ }
105
+
106
+ export async function rollbackLegacy(settings, releaseRoute) {
107
+ let { own, legacy } = rows(settings)
108
+ const migration = own?.value?.migration
109
+ if (!migration || own.value.generation !== migration.generation) throw new ConnectionError('MIGRATION_CONFLICT', 'Connection changed after migration; forward repair is required')
110
+ let ownedRevision = own.revision
111
+ const checkOwner = () => {
112
+ const current = rows(settings).own
113
+ if (current?.revision !== ownedRevision || current.value?.generation !== migration.generation || current.value?.migration?.operationId !== migration.operationId || current.value?.migration?.generation !== migration.generation) throw new ConnectionError('MIGRATION_CONFLICT', 'Connection changed during rollback; forward repair is required')
114
+ }
115
+ if (legacy?.base?.providers?.[PROVIDER]) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy provider exists in a base layer')
116
+ if (migration.phase !== 'rollback') {
117
+ if (legacy?.value?.providers?.[PROVIDER] || legacy?.user?.providers?.[PROVIDER]) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy provider already exists')
118
+ await setOwn(settings, { migration: { ...migration, phase: 'rollback' } }, own.revision)
119
+ ownedRevision += 1
120
+ checkOwner()
121
+ }
122
+ await releaseRoute()
123
+ checkOwner()
124
+ ; ({ own, legacy } = rows(settings))
125
+ const existing = legacy?.user?.providers?.[PROVIDER]
126
+ if (legacy?.base?.providers?.[PROVIDER] || (existing && fingerprintOf(existing) !== migration.backupFingerprint) || (!existing && legacy?.value?.providers?.[PROVIDER])) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy provider appeared during rollback')
127
+ if (!existing) await settings.mutate(LEGACY, [{ op: 'set', path: ['providers', PROVIDER], value: migration.backup }], legacy.revision)
128
+ checkOwner()
129
+ ; ({ own, legacy } = rows(settings))
130
+ if (!legacy?.user?.providers?.[PROVIDER] || fingerprintOf(legacy.user.providers[PROVIDER]) !== migration.backupFingerprint) throw new ConnectionError('MIGRATION_CONFLICT', 'Legacy rollback could not be confirmed')
131
+ await settings.mutate(OWN, ['baseURL', 'authIdentity', 'generation', 'operationId', 'policy', 'overrides', 'migration'].map((key) => ({ op: 'unset', path: [key] })), ownedRevision)
132
+ }
package/src/policy.js CHANGED
@@ -1,42 +1,42 @@
1
- import { resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
2
- import { Config as NativePiAiConfig } from '@deepseek-ai/dsh-llm-pi-ai'
3
- import { ConnectionError } from './connection.js'
4
- import { credentialField } from './secrets.js'
5
-
6
- // The wire protocols this build can serve. The order is the presentation order:
7
- // the first entry is the default a fresh connection starts with.
8
- export const PROTOCOLS = ['openai-responses', 'openai-completions', 'anthropic-messages', 'google-generative-ai']
9
- export const DEFAULT_PROTOCOL = PROTOCOLS[0]
10
-
11
- const fields = new Set(['displayName', 'api', 'defaultContextWindow', 'defaultMaxTokens', 'defaultInput', 'headers', 'compat', 'retryPolicy', 'reasoning', 'thinkingBudgets', 'streamIdleTimeoutMs', 'maxRequestImageBytes', 'maxRequestImages', 'imageOffloadCountQuantum', 'requestImagePixelBudget', 'requestImageMaxBytes', 'cacheRetention', 'transport', 'timeoutMs', 'websocketConnectTimeoutMs'])
12
- const object = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
13
- const containsCredentials = (v) => object(v) && Object.entries(v).some(([k, x]) => credentialField(k) || containsCredentials(x)) || Array.isArray(v) && v.some(containsCredentials)
14
- export function protocolOf(policy) {
15
- return PROTOCOLS.includes(policy?.api) ? policy.api : DEFAULT_PROTOCOL
16
- }
17
- export function validatePolicy(policy = {}) {
18
- try {
19
- if (!object(policy) || containsCredentials(policy) || Object.keys(policy).some((k) => !fields.has(k))) throw 0
20
- for (const key of ['defaultContextWindow', 'defaultMaxTokens', 'maxRequestImageBytes', 'maxRequestImages', 'imageOffloadCountQuantum', 'requestImagePixelBudget', 'requestImageMaxBytes']) if (policy[key] !== undefined && (!Number.isSafeInteger(policy[key]) || policy[key] <= 0)) throw 0
21
- for (const key of ['timeoutMs', 'websocketConnectTimeoutMs']) if (policy[key] !== undefined && (!Number.isSafeInteger(policy[key]) || policy[key] < 0)) throw 0
22
- if (policy.streamIdleTimeoutMs !== undefined && (!Number.isFinite(policy.streamIdleTimeoutMs) || policy.streamIdleTimeoutMs <= 0 || policy.streamIdleTimeoutMs > 2147483647)) throw 0
23
- if (policy.imageOffloadCountQuantum > (policy.maxRequestImages ?? 50)) throw 0
24
- if (policy.defaultInput !== undefined && (!Array.isArray(policy.defaultInput) || !policy.defaultInput.length || policy.defaultInput.some((x) => !['text', 'image'].includes(x)))) throw 0
25
- if (policy.headers !== undefined && (!object(policy.headers) || Object.values(policy.headers).some((x) => typeof x !== 'string'))) throw 0
26
- if (policy.compat !== undefined && !object(policy.compat) || policy.thinkingBudgets !== undefined && (!object(policy.thinkingBudgets) || Object.values(policy.thinkingBudgets).some((x) => !Number.isSafeInteger(x) || x < 0))) throw 0
27
- if (policy.cacheRetention !== undefined && !['none', 'short', 'long'].includes(policy.cacheRetention)) throw 0
28
- if (policy.transport !== undefined && !['auto', 'sse', 'websocket', 'websocket-cached'].includes(policy.transport)) throw 0
29
- if (policy.reasoning !== undefined && !['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(policy.reasoning)) throw 0
30
- if (policy.displayName !== undefined && typeof policy.displayName !== 'string' || policy.api !== undefined && !PROTOCOLS.includes(policy.api)) throw 0
31
- if (policy.retryPolicy !== undefined && (!object(policy.retryPolicy) || policy.retryPolicy.backoff !== undefined && !object(policy.retryPolicy.backoff) || policy.retryPolicy.retryableCodes !== undefined && !Array.isArray(policy.retryPolicy.retryableCodes))) throw 0
32
- resolveRetryPolicy(policy.retryPolicy, 'CPA')
33
- const checked = NativePiAiConfig['~standard'].validate({
34
- providers: { 'cpa-policy-validation': { ...policy, api: protocolOf(policy), baseURL: 'https://policy.invalid/v1', models: [{ id: 'policy-validation' }] } },
35
- })
36
- if (!checked || typeof checked.then === 'function' || checked.issues) throw 0
37
- const parsed = checked.value.providers.get()['cpa-policy-validation']
38
- for (const [key, value] of Object.entries(policy.compat ?? {})) {
39
- if (JSON.stringify(parsed.compat?.[key]) !== JSON.stringify(value)) throw 0
40
- }
41
- } catch { throw new ConnectionError('INVALID_POLICY', 'Invalid or unsupported public policy') }
42
- }
1
+ import { resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
2
+ import { Config as NativePiAiConfig } from '@deepseek-ai/dsh-llm-pi-ai'
3
+ import { ConnectionError } from './connection.js'
4
+ import { credentialField } from './secrets.js'
5
+
6
+ // The wire protocols this build can serve. The order is the presentation order:
7
+ // the first entry is the default a fresh connection starts with.
8
+ export const PROTOCOLS = ['openai-responses', 'openai-completions', 'anthropic-messages', 'google-generative-ai']
9
+ export const DEFAULT_PROTOCOL = PROTOCOLS[0]
10
+
11
+ const fields = new Set(['displayName', 'api', 'defaultContextWindow', 'defaultMaxTokens', 'defaultInput', 'headers', 'compat', 'retryPolicy', 'reasoning', 'thinkingBudgets', 'streamIdleTimeoutMs', 'maxRequestImageBytes', 'maxRequestImages', 'imageOffloadCountQuantum', 'requestImagePixelBudget', 'requestImageMaxBytes', 'cacheRetention', 'transport', 'timeoutMs', 'websocketConnectTimeoutMs'])
12
+ const object = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
13
+ const containsCredentials = (v) => object(v) && Object.entries(v).some(([k, x]) => credentialField(k) || containsCredentials(x)) || Array.isArray(v) && v.some(containsCredentials)
14
+ export function protocolOf(policy) {
15
+ return PROTOCOLS.includes(policy?.api) ? policy.api : DEFAULT_PROTOCOL
16
+ }
17
+ export function validatePolicy(policy = {}) {
18
+ try {
19
+ if (!object(policy) || containsCredentials(policy) || Object.keys(policy).some((k) => !fields.has(k))) throw 0
20
+ for (const key of ['defaultContextWindow', 'defaultMaxTokens', 'maxRequestImageBytes', 'maxRequestImages', 'imageOffloadCountQuantum', 'requestImagePixelBudget', 'requestImageMaxBytes']) if (policy[key] !== undefined && (!Number.isSafeInteger(policy[key]) || policy[key] <= 0)) throw 0
21
+ for (const key of ['timeoutMs', 'websocketConnectTimeoutMs']) if (policy[key] !== undefined && (!Number.isSafeInteger(policy[key]) || policy[key] < 0)) throw 0
22
+ if (policy.streamIdleTimeoutMs !== undefined && (!Number.isFinite(policy.streamIdleTimeoutMs) || policy.streamIdleTimeoutMs <= 0 || policy.streamIdleTimeoutMs > 2147483647)) throw 0
23
+ if (policy.imageOffloadCountQuantum > (policy.maxRequestImages ?? 50)) throw 0
24
+ if (policy.defaultInput !== undefined && (!Array.isArray(policy.defaultInput) || !policy.defaultInput.length || policy.defaultInput.some((x) => !['text', 'image'].includes(x)))) throw 0
25
+ if (policy.headers !== undefined && (!object(policy.headers) || Object.values(policy.headers).some((x) => typeof x !== 'string'))) throw 0
26
+ if (policy.compat !== undefined && !object(policy.compat) || policy.thinkingBudgets !== undefined && (!object(policy.thinkingBudgets) || Object.values(policy.thinkingBudgets).some((x) => !Number.isSafeInteger(x) || x < 0))) throw 0
27
+ if (policy.cacheRetention !== undefined && !['none', 'short', 'long'].includes(policy.cacheRetention)) throw 0
28
+ if (policy.transport !== undefined && !['auto', 'sse', 'websocket', 'websocket-cached'].includes(policy.transport)) throw 0
29
+ if (policy.reasoning !== undefined && !['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(policy.reasoning)) throw 0
30
+ if (policy.displayName !== undefined && typeof policy.displayName !== 'string' || policy.api !== undefined && !PROTOCOLS.includes(policy.api)) throw 0
31
+ if (policy.retryPolicy !== undefined && (!object(policy.retryPolicy) || policy.retryPolicy.backoff !== undefined && !object(policy.retryPolicy.backoff) || policy.retryPolicy.retryableCodes !== undefined && !Array.isArray(policy.retryPolicy.retryableCodes))) throw 0
32
+ resolveRetryPolicy(policy.retryPolicy, 'CPA')
33
+ const checked = NativePiAiConfig['~standard'].validate({
34
+ providers: { 'cpa-policy-validation': { ...policy, api: protocolOf(policy), baseURL: 'https://policy.invalid/v1', models: [{ id: 'policy-validation' }] } },
35
+ })
36
+ if (!checked || typeof checked.then === 'function' || checked.issues) throw 0
37
+ const parsed = checked.value.providers.get()['cpa-policy-validation']
38
+ for (const [key, value] of Object.entries(policy.compat ?? {})) {
39
+ if (JSON.stringify(parsed.compat?.[key]) !== JSON.stringify(value)) throw 0
40
+ }
41
+ } catch { throw new ConnectionError('INVALID_POLICY', 'Invalid or unsupported public policy') }
42
+ }
package/src/secrets.js CHANGED
@@ -1 +1 @@
1
- export const credentialField = (name) => /^(?:(?:x[-_])?api[-_]?key|(?:[\w]+[-_])*authorization|cookie|set[-_]?cookie|password|secret|token|key|access[-_]?token|refresh[-_]?token|credential)$/i.test(name)
1
+ export const credentialField = (name) => /^(?:(?:x[-_])?api[-_]?key|(?:[\w]+[-_])*authorization|cookie|set[-_]?cookie|password|secret|token|key|access[-_]?token|refresh[-_]?token|credential)$/i.test(name)
package/src/transport.js CHANGED
@@ -1,114 +1,114 @@
1
- import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy'
2
- import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy'
3
- import { anthropicMessagesApi } from '@earendil-works/pi-ai/api/anthropic-messages.lazy'
4
- import { googleGenerativeAIApi } from '@earendil-works/pi-ai/api/google-generative-ai.lazy'
5
- import { PROTOCOLS } from './policy.js'
6
-
7
- const FACTORIES = {
8
- 'openai-responses': openAIResponsesApi,
9
- 'openai-completions': openAICompletionsApi,
10
- 'anthropic-messages': anthropicMessagesApi,
11
- 'google-generative-ai': googleGenerativeAIApi,
12
- }
13
-
14
- // Each wire protocol ends its SSE stream differently. The scanner only feeds the
15
- // diagnostics panel, so an unknown protocol degrades to "no terminal event seen"
16
- // instead of guessing.
17
- const TERMINALS = {
18
- 'openai-responses': (line) => {
19
- const match = /^(?:event:\s*|data:\s*\{"type"\s*:\s*")(response\.(?:completed|failed|incomplete))/.exec(line)
20
- return match ? match[1].replace('response.', 'sse-') : undefined
21
- },
22
- 'openai-completions': (line) => {
23
- if (/^data:\s*\[DONE\]/.test(line)) return 'sse-completed'
24
- return /^data:\s*\{/.test(line) && /"error"\s*:/.test(line) ? 'sse-failed' : undefined
25
- },
26
- 'anthropic-messages': (line) => {
27
- const match = /^(?:event:\s*|data:\s*\{[^\n]*"type"\s*:\s*")(message_stop|error)/.exec(line)
28
- if (!match) return undefined
29
- return match[1] === 'error' ? 'sse-failed' : 'sse-completed'
30
- },
31
- 'google-generative-ai': (line) => {
32
- const match = /^(?:event:\s*|data:\s*\{[^\n]*"type"\s*:\s*")(result|error)/.exec(line)
33
- if (!match) return undefined
34
- return match[1] === 'error' ? 'sse-failed' : 'sse-completed'
35
- },
36
- }
37
-
38
- export function resolveProtocol(protocol) {
39
- return PROTOCOLS.includes(protocol) ? protocol : 'openai-responses'
40
- }
41
-
42
- export function terminalStage(protocol, line) {
43
- return TERMINALS[resolveProtocol(protocol)](line)
44
- }
45
-
46
- export function createTransport(protocol, baseURL, diagnostics, fetchImpl = globalThis.fetch, keyless = false) {
47
- const api = resolveProtocol(protocol)
48
- const origin = new URL(baseURL).origin
49
- const native = FACTORIES[api]()
50
- const terminalOf = TERMINALS[api]
51
- // Google streams carry no sentinel, so a clean flush is a completed stream.
52
- const flushStage = api === 'google-generative-ai' ? 'sse-completed' : 'sse-incomplete'
53
- const record = (entry) => {
54
- diagnostics.push({ at: Date.now(), ...entry })
55
- if (diagnostics.length > 50) diagnostics.splice(0, diagnostics.length - 50)
56
- }
57
- const scopedFetch = async (input, init = {}) => {
58
- const url = input instanceof Request ? input.url : String(input)
59
- if (new URL(url).origin !== origin) throw new Error('CPA request origin changed')
60
- try {
61
- const headers = new Headers(input instanceof Request ? input.headers : undefined)
62
- if (init.headers) new Headers(init.headers).forEach((value, key) => headers.set(key, value))
63
- if (keyless) headers.delete('authorization')
64
- const response = await fetchImpl(input, { ...init, headers, redirect: 'error' })
65
- const requestId = response.headers.get('x-request-id')
66
- record({ stage: 'http', status: response.status, ...(/^[A-Za-z0-9._:-]{1,128}$/.test(requestId ?? '') ? { requestId } : {}) })
67
- if (!response.body || !response.headers.get('content-type')?.toLowerCase().includes('text/event-stream')) return response
68
- let pending = ''
69
- let terminal = false
70
- const signal = init.signal ?? (input instanceof Request ? input.signal : undefined)
71
- const onAbort = () => { if (!terminal) { terminal = true; record({ stage: signal?.reason?.name === 'TimeoutError' ? 'timeout' : 'abort' }) } }
72
- if (signal?.aborted) onAbort()
73
- else signal?.addEventListener('abort', onAbort, { once: true })
74
- const cleanup = () => signal?.removeEventListener('abort', onAbort)
75
- const decoder = new TextDecoder()
76
- const inspect = (text) => {
77
- pending += text
78
- const lines = pending.split(/\r?\n/)
79
- pending = lines.pop().slice(-1024)
80
- for (const line of lines) {
81
- const stage = terminalOf(line)
82
- if (stage && !terminal) { terminal = true; cleanup(); record({ stage }) }
83
- }
84
- }
85
- const body = response.body.pipeThrough(new TransformStream({
86
- transform(chunk, controller) { inspect(decoder.decode(chunk, { stream: true })); controller.enqueue(chunk) },
87
- flush() { inspect(decoder.decode() + '\n'); cleanup(); if (!terminal) record({ stage: flushStage }) },
88
- }))
89
- return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers })
90
- } catch (error) {
91
- record({ stage: error?.name === 'TimeoutError' ? 'timeout' : error?.name === 'AbortError' ? 'abort' : 'before-http' })
92
- throw error
93
- }
94
- }
95
- const withPolicy = (options) => ({
96
- ...options,
97
- maxRetries: 0,
98
- fetch: scopedFetch,
99
- onPayload(payload, model) {
100
- const applyPolicy = (value) => {
101
- const updated = { ...value }
102
- if (Array.isArray(value.tools)) updated.tools = value.tools.map((tool) => tool.type === 'function' ? { ...tool, strict: false } : tool)
103
- record({ stage: 'payload', toolCount: Array.isArray(value.tools) ? value.tools.length : 0 })
104
- return updated
105
- }
106
- const original = options?.onPayload?.(payload, model)
107
- return original instanceof Promise ? original.then((value) => applyPolicy(value ?? payload)) : applyPolicy(original ?? payload)
108
- },
109
- })
110
- return {
111
- stream: (model, context, options) => native.stream(model, context, withPolicy(options)),
112
- streamSimple: (model, context, options) => native.streamSimple(model, context, withPolicy(options)),
113
- }
114
- }
1
+ import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy'
2
+ import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy'
3
+ import { anthropicMessagesApi } from '@earendil-works/pi-ai/api/anthropic-messages.lazy'
4
+ import { googleGenerativeAIApi } from '@earendil-works/pi-ai/api/google-generative-ai.lazy'
5
+ import { PROTOCOLS } from './policy.js'
6
+
7
+ const FACTORIES = {
8
+ 'openai-responses': openAIResponsesApi,
9
+ 'openai-completions': openAICompletionsApi,
10
+ 'anthropic-messages': anthropicMessagesApi,
11
+ 'google-generative-ai': googleGenerativeAIApi,
12
+ }
13
+
14
+ // Each wire protocol ends its SSE stream differently. The scanner only feeds the
15
+ // diagnostics panel, so an unknown protocol degrades to "no terminal event seen"
16
+ // instead of guessing.
17
+ const TERMINALS = {
18
+ 'openai-responses': (line) => {
19
+ const match = /^(?:event:\s*|data:\s*\{"type"\s*:\s*")(response\.(?:completed|failed|incomplete))/.exec(line)
20
+ return match ? match[1].replace('response.', 'sse-') : undefined
21
+ },
22
+ 'openai-completions': (line) => {
23
+ if (/^data:\s*\[DONE\]/.test(line)) return 'sse-completed'
24
+ return /^data:\s*\{/.test(line) && /"error"\s*:/.test(line) ? 'sse-failed' : undefined
25
+ },
26
+ 'anthropic-messages': (line) => {
27
+ const match = /^(?:event:\s*|data:\s*\{[^\n]*"type"\s*:\s*")(message_stop|error)/.exec(line)
28
+ if (!match) return undefined
29
+ return match[1] === 'error' ? 'sse-failed' : 'sse-completed'
30
+ },
31
+ 'google-generative-ai': (line) => {
32
+ const match = /^(?:event:\s*|data:\s*\{[^\n]*"type"\s*:\s*")(result|error)/.exec(line)
33
+ if (!match) return undefined
34
+ return match[1] === 'error' ? 'sse-failed' : 'sse-completed'
35
+ },
36
+ }
37
+
38
+ export function resolveProtocol(protocol) {
39
+ return PROTOCOLS.includes(protocol) ? protocol : 'openai-responses'
40
+ }
41
+
42
+ export function terminalStage(protocol, line) {
43
+ return TERMINALS[resolveProtocol(protocol)](line)
44
+ }
45
+
46
+ export function createTransport(protocol, baseURL, diagnostics, fetchImpl = globalThis.fetch, keyless = false) {
47
+ const api = resolveProtocol(protocol)
48
+ const origin = new URL(baseURL).origin
49
+ const native = FACTORIES[api]()
50
+ const terminalOf = TERMINALS[api]
51
+ // Google streams carry no sentinel, so a clean flush is a completed stream.
52
+ const flushStage = api === 'google-generative-ai' ? 'sse-completed' : 'sse-incomplete'
53
+ const record = (entry) => {
54
+ diagnostics.push({ at: Date.now(), ...entry })
55
+ if (diagnostics.length > 50) diagnostics.splice(0, diagnostics.length - 50)
56
+ }
57
+ const scopedFetch = async (input, init = {}) => {
58
+ const url = input instanceof Request ? input.url : String(input)
59
+ if (new URL(url).origin !== origin) throw new Error('CPA request origin changed')
60
+ try {
61
+ const headers = new Headers(input instanceof Request ? input.headers : undefined)
62
+ if (init.headers) new Headers(init.headers).forEach((value, key) => headers.set(key, value))
63
+ if (keyless) headers.delete('authorization')
64
+ const response = await fetchImpl(input, { ...init, headers, redirect: 'error' })
65
+ const requestId = response.headers.get('x-request-id')
66
+ record({ stage: 'http', status: response.status, ...(/^[A-Za-z0-9._:-]{1,128}$/.test(requestId ?? '') ? { requestId } : {}) })
67
+ if (!response.body || !response.headers.get('content-type')?.toLowerCase().includes('text/event-stream')) return response
68
+ let pending = ''
69
+ let terminal = false
70
+ const signal = init.signal ?? (input instanceof Request ? input.signal : undefined)
71
+ const onAbort = () => { if (!terminal) { terminal = true; record({ stage: signal?.reason?.name === 'TimeoutError' ? 'timeout' : 'abort' }) } }
72
+ if (signal?.aborted) onAbort()
73
+ else signal?.addEventListener('abort', onAbort, { once: true })
74
+ const cleanup = () => signal?.removeEventListener('abort', onAbort)
75
+ const decoder = new TextDecoder()
76
+ const inspect = (text) => {
77
+ pending += text
78
+ const lines = pending.split(/\r?\n/)
79
+ pending = lines.pop().slice(-1024)
80
+ for (const line of lines) {
81
+ const stage = terminalOf(line)
82
+ if (stage && !terminal) { terminal = true; cleanup(); record({ stage }) }
83
+ }
84
+ }
85
+ const body = response.body.pipeThrough(new TransformStream({
86
+ transform(chunk, controller) { inspect(decoder.decode(chunk, { stream: true })); controller.enqueue(chunk) },
87
+ flush() { inspect(decoder.decode() + '\n'); cleanup(); if (!terminal) record({ stage: flushStage }) },
88
+ }))
89
+ return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers })
90
+ } catch (error) {
91
+ record({ stage: error?.name === 'TimeoutError' ? 'timeout' : error?.name === 'AbortError' ? 'abort' : 'before-http' })
92
+ throw error
93
+ }
94
+ }
95
+ const withPolicy = (options) => ({
96
+ ...options,
97
+ maxRetries: 0,
98
+ fetch: scopedFetch,
99
+ onPayload(payload, model) {
100
+ const applyPolicy = (value) => {
101
+ const updated = { ...value }
102
+ if (Array.isArray(value.tools)) updated.tools = value.tools.map((tool) => tool.type === 'function' ? { ...tool, strict: false } : tool)
103
+ record({ stage: 'payload', toolCount: Array.isArray(value.tools) ? value.tools.length : 0 })
104
+ return updated
105
+ }
106
+ const original = options?.onPayload?.(payload, model)
107
+ return original instanceof Promise ? original.then((value) => applyPolicy(value ?? payload)) : applyPolicy(original ?? payload)
108
+ },
109
+ })
110
+ return {
111
+ stream: (model, context, options) => native.stream(model, context, withPolicy(options)),
112
+ streamSimple: (model, context, options) => native.streamSimple(model, context, withPolicy(options)),
113
+ }
114
+ }