@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/README.md +40 -39
- package/README_EN.md +40 -39
- package/client.js +517 -517
- package/cordis.patch.yml +5 -5
- package/details/en.md +9 -9
- package/details/ru.md +9 -9
- package/details/zh.md +9 -9
- package/locale/en.json +7 -7
- package/locale/ru.json +7 -7
- package/locale/zh.json +7 -7
- package/package.json +85 -85
- package/src/adapter.js +68 -68
- package/src/catalog-store.js +62 -62
- package/src/catalog.js +129 -129
- package/src/connection-store.js +98 -98
- package/src/connection.js +114 -114
- package/src/gateway.js +411 -411
- package/src/index.js +513 -513
- package/src/migration.js +132 -132
- package/src/policy.js +42 -42
- package/src/secrets.js +1 -1
- package/src/transport.js +114 -114
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
|
+
}
|