dsh-lcx-codex 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/lib/client.js ADDED
@@ -0,0 +1,248 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-lcx-codex',
3
+ factory: (require) => {
4
+ const module = { exports: {} }
5
+ const exports = module.exports
6
+ const React = require('react')
7
+ const { createSnapshotStore } = require('@deepseek-ai/dsh-client-runtime/client')
8
+
9
+ const NAMESPACE = 'lcx-codex'
10
+ const DEFAULTS = {
11
+ enabled: false,
12
+ webSearch: false,
13
+ alphaSearch: false,
14
+ remoteCompaction: false,
15
+ fallbackToBasicCompaction: true,
16
+ provider: 'lcx',
17
+ baseURL: 'https://api.lcxbot.com/v1',
18
+ apiKeyEnv: 'LCX_API_KEY',
19
+ model: 'gpt-5.6-sol',
20
+ compactTransport: 'native-v2',
21
+ }
22
+
23
+ const css = `
24
+ .lcx-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}
25
+ .lcx-card-head{width:100%;display:flex;align-items:center;gap:12px;padding:14px 16px;border:0;background:transparent;color:inherit;text-align:left;font:inherit;cursor:pointer;border-radius:12px}
26
+ .lcx-card-head:hover{background:var(--dsw-alias-interactive-bg-hover)}
27
+ .lcx-card-title{display:flex;flex-direction:column;gap:4px;flex:1;min-width:0}
28
+ .lcx-card-title strong{font-size:15px}.lcx-card-title span,.lcx-help{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
29
+ .lcx-card-body{border-top:1px solid var(--dsw-alias-border-l2);padding:14px 16px 10px}
30
+ .lcx-row{display:flex;align-items:flex-start;gap:9px;padding:9px 0}.lcx-row input[type=checkbox]{margin-top:3px;accent-color:var(--dsw-alias-brand-primary)}
31
+ .lcx-row label{font-size:13px;color:var(--dsw-alias-label-primary);line-height:19px}.lcx-row small{display:block;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:17px}
32
+ .lcx-fields{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:8px}.lcx-field{display:flex;flex-direction:column;gap:5px}.lcx-field label{font-size:12px;color:var(--dsw-alias-label-secondary)}
33
+ .lcx-input{width:100%;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:7px 9px;font:inherit;font-size:12px}.lcx-input:disabled{color:var(--dsw-alias-label-tertiary)}
34
+ .lcx-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;border-top:1px solid var(--dsw-alias-border-l2);padding:12px 0 4px}.lcx-footer button{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 14px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;cursor:pointer}.lcx-footer button.primary{background:var(--dsw-alias-state-business-primary);border-color:transparent;color:var(--dsw-alias-label-primary-inverted)}.lcx-footer button:disabled{opacity:.5;cursor:default}.lcx-error{flex:1;color:var(--dsw-alias-state-error-primary);font-size:12px}
35
+ @media(max-width:640px){.lcx-fields{grid-template-columns:1fr}}
36
+ `
37
+ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css="dsh-lcx-codex"]') === null) {
38
+ const tag = document.createElement('style')
39
+ tag.dataset.plugin = 'dsh-lcx-codex'
40
+ tag.dataset.pluginCss = 'dsh-lcx-codex'
41
+ tag.textContent = css
42
+ document.head.appendChild(tag)
43
+ }
44
+
45
+ const copy = {
46
+ zh: {
47
+ title: 'Responses / Codex 能力',
48
+ description: '可选的 hosted Web Search 与 Responses 原生远程压缩。',
49
+ enabled: '启用 LCX/Codex 能力',
50
+ enabledHelp: '关闭时不改变当前模型、搜索提供方或压缩流程。',
51
+ webSearch: '启用 Responses Hosted 搜索(websearch_gpt)',
52
+ webSearchHelp: '开启后,模型可调用 websearch_gpt;插件通过 Responses /responses + web_search 执行结构化 hosted 搜索并返回来源。',
53
+ alphaSearch: '启用 Alpha command 搜索(websearch_alpha)',
54
+ alphaSearchHelp: '仅当当前 endpoint、provider、model 和 schema 的 capability probe 已通过时注册;PAT 模拟或未知能力不会暴露该工具。',
55
+ compaction: '启用 GPT 专属原生远程压缩',
56
+ compactionHelp: '只适用于兼容 Responses 的 GPT 路由;使用与 Sub2API 压缩测试一致的 native V2(/responses + compaction_trigger),保存完整 output 并在后续请求中回放。Legacy compact transport 已不支持,相关配置会被拒绝。',
57
+ fallback: 'native 压缩失败时回退 DSH 普通压缩',
58
+ fallbackHelp: '推荐开启:首次远程压缩失败且尚未生成 checkpoint 时,继续使用 DSH 原有压缩。已有 native checkpoint 的回放失败仍会明确报错。',
59
+ endpoint: '旧版回退 Responses 地址',
60
+ model: '默认 Hosted / Alpha 模型',
61
+ save: '保存',
62
+ saving: '保存中…',
63
+ discard: '放弃修改',
64
+ unsaved: '未保存',
65
+ readOnly: '当前部署的设置为只读。',
66
+ unavailable: '此插件的设置服务暂不可用。',
67
+ saveFailed: '保存失败,请重试。',
68
+ hint: '请求优先复用当前 DSH openai-responses 模型的地址与凭据;这里的地址仅用于旧版直连兼容。Hosted 与 Alpha 不会互相静默降级。',
69
+ },
70
+ en: {
71
+ title: 'Responses / Codex capabilities',
72
+ description: 'Optional hosted Web Search and native Responses compaction.',
73
+ enabled: 'Enable LCX/Codex capabilities',
74
+ enabledHelp: 'When off, the current model, search provider, and compaction flow are unchanged.',
75
+ webSearch: 'Enable Responses Hosted search (websearch_gpt)',
76
+ webSearchHelp: 'When on, the model can call websearch_gpt through Responses /responses + web_search with structured hosted-search controls and citations.',
77
+ alphaSearch: 'Enable Alpha command search (websearch_alpha)',
78
+ alphaSearchHelp: 'Registered only after a capability probe matches the current endpoint, provider, model, and schema. PAT emulation and unknown capabilities remain hidden.',
79
+ compaction: 'Enable GPT-only native remote compaction',
80
+ compactionHelp: 'Works only on a compatible Responses GPT route; uses the native V2 request used by Sub2API compact tests (/responses + compaction_trigger), stores the complete output, and replays it. Legacy compact transport is unsupported and rejected.',
81
+ fallback: 'Fall back to normal DSH compaction on native failure',
82
+ fallbackHelp: 'Recommended: if the first native request fails before a checkpoint is saved, use the existing DSH compaction. Replay failures for an existing native checkpoint still report an error.',
83
+ endpoint: 'Legacy fallback Responses endpoint',
84
+ model: 'Default Hosted / Alpha model',
85
+ save: 'Save',
86
+ saving: 'Saving…',
87
+ discard: 'Discard',
88
+ unsaved: 'Unsaved',
89
+ readOnly: 'This deployment stores settings read-only.',
90
+ unavailable: 'This plugin settings service is unavailable.',
91
+ saveFailed: 'Save failed; please try again.',
92
+ hint: 'Requests prefer the active DSH openai-responses model route and credential; this endpoint is only a legacy direct-route fallback. Hosted and Alpha never silently fall back to each other.',
93
+ },
94
+ }
95
+
96
+ function language() {
97
+ return String(globalThis.navigator?.language ?? '').toLowerCase().startsWith('zh') ? 'zh' : 'en'
98
+ }
99
+
100
+ function valueFrom(snapshot) {
101
+ return { ...DEFAULTS, ...(snapshot.value ?? {}) }
102
+ }
103
+
104
+ class SettingsController {
105
+ constructor(scope) {
106
+ this.scope = scope
107
+ this.draft = null
108
+ this.dirty = false
109
+ this.saving = false
110
+ this.failed = false
111
+ this.listeners = new Set()
112
+ this.store = createSnapshotStore(this.projection())
113
+ this.stop = scope.subscribe(() => {
114
+ if (!this.dirty) this.draft = null
115
+ this.publish()
116
+ })
117
+ }
118
+
119
+ snapshot() { return this.scope.getSnapshot() }
120
+ value() { return valueFrom(this.snapshot()) }
121
+ draftValue() { return { ...this.value(), ...(this.draft ?? {}) } }
122
+ field(name) { return this.draftValue()[name] }
123
+ overridden(name) { return Object.prototype.hasOwnProperty.call(this.snapshot().user ?? {}, name) }
124
+ projection() {
125
+ const snapshot = this.snapshot()
126
+ return {
127
+ available: snapshot.status === 'ready',
128
+ writable: snapshot.writable,
129
+ dirty: this.dirty,
130
+ saving: this.saving,
131
+ failed: this.failed,
132
+ enabled: { value: Boolean(this.field('enabled')), overridden: this.overridden('enabled') },
133
+ webSearch: { value: Boolean(this.field('webSearch')), overridden: this.overridden('webSearch') },
134
+ alphaSearch: { value: Boolean(this.field('alphaSearch')), overridden: this.overridden('alphaSearch') },
135
+ remoteCompaction: { value: Boolean(this.field('remoteCompaction')), overridden: this.overridden('remoteCompaction') },
136
+ fallbackToBasicCompaction: { value: this.field('fallbackToBasicCompaction') !== false, overridden: this.overridden('fallbackToBasicCompaction') },
137
+ baseURL: String(this.field('baseURL') ?? ''),
138
+ model: String(this.field('model') ?? ''),
139
+ hint: language() === 'zh' ? copy.zh.hint : copy.en.hint,
140
+ }
141
+ }
142
+ bind() { return this.store }
143
+ subscribe(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener) }
144
+ publish() {
145
+ this.store.set(this.projection())
146
+ for (const listener of this.listeners) listener()
147
+ }
148
+ edit(field, value) {
149
+ this.draft = { ...(this.draft ?? this.value()), [field]: value }
150
+ this.dirty = true
151
+ this.failed = false
152
+ this.publish()
153
+ }
154
+ discard() {
155
+ this.draft = null
156
+ this.dirty = false
157
+ this.failed = false
158
+ this.publish()
159
+ }
160
+ async save() {
161
+ if (!this.dirty || this.saving || !this.snapshot().writable) return
162
+ const next = this.draftValue()
163
+ const previous = this.value()
164
+ const changes = Object.keys(next).filter((field) => next[field] !== previous[field])
165
+ this.saving = true
166
+ this.failed = false
167
+ this.publish()
168
+ try {
169
+ for (const field of changes) await this.scope.set(field, next[field])
170
+ this.draft = null
171
+ this.dirty = false
172
+ } catch {
173
+ this.failed = true
174
+ } finally {
175
+ this.saving = false
176
+ this.publish()
177
+ }
178
+ }
179
+ inject() { return { hooks: { lcxCard: this.bind() }, ...this.actions() } }
180
+ actions() {
181
+ return {
182
+ edit: (field, value) => this.edit(field, value),
183
+ save: () => { void this.save() },
184
+ discard: () => this.discard(),
185
+ }
186
+ }
187
+ }
188
+
189
+ function CheckRow({ id, label, help, checked, disabled, onChange }) {
190
+ return React.createElement('div', { className: 'lcx-row' },
191
+ React.createElement('input', { id, type: 'checkbox', checked, disabled, onChange: (event) => onChange(event.target.checked) }),
192
+ React.createElement('label', { htmlFor: id }, label, React.createElement('small', null, help)),
193
+ )
194
+ }
195
+
196
+ function LcxCard(props) {
197
+ const t = copy[language()]
198
+ const state = props.useLcxCard((snapshot) => snapshot)
199
+ const [open, setOpen] = React.useState(false)
200
+ if (!state.available) return null
201
+ const disabled = !state.writable || state.saving
202
+ return React.createElement('li', { className: 'lcx-card' },
203
+ React.createElement('button', { className: 'lcx-card-head', type: 'button', 'aria-expanded': open, onClick: () => setOpen(!open) },
204
+ React.createElement('span', { className: 'lcx-card-title' }, React.createElement('strong', null, t.title), React.createElement('span', null, t.description)),
205
+ state.dirty ? React.createElement('span', null, t.unsaved) : null,
206
+ React.createElement('span', null, open ? '⌃' : '⌄'),
207
+ ),
208
+ open ? React.createElement('div', { className: 'lcx-card-body' },
209
+ React.createElement(CheckRow, { id: 'lcx-enabled', label: t.enabled, help: t.enabledHelp, checked: state.enabled.value, disabled, onChange: (value) => props.edit('enabled', value) }),
210
+ React.createElement(CheckRow, { id: 'lcx-web-search', label: t.webSearch, help: t.webSearchHelp, checked: state.webSearch.value, disabled: disabled || !state.enabled.value, onChange: (value) => props.edit('webSearch', value) }),
211
+ React.createElement(CheckRow, { id: 'lcx-alpha-search', label: t.alphaSearch, help: t.alphaSearchHelp, checked: state.alphaSearch.value, disabled: disabled || !state.enabled.value, onChange: (value) => props.edit('alphaSearch', value) }),
212
+ React.createElement(CheckRow, { id: 'lcx-compaction', label: t.compaction, help: t.compactionHelp, checked: state.remoteCompaction.value, disabled: disabled || !state.enabled.value, onChange: (value) => props.edit('remoteCompaction', value) }),
213
+ React.createElement(CheckRow, { id: 'lcx-fallback', label: t.fallback, help: t.fallbackHelp, checked: state.fallbackToBasicCompaction.value, disabled: disabled || !state.enabled.value || !state.remoteCompaction.value, onChange: (value) => props.edit('fallbackToBasicCompaction', value) }),
214
+ React.createElement('div', { className: 'lcx-fields' },
215
+ React.createElement('div', { className: 'lcx-field' }, React.createElement('label', { htmlFor: 'lcx-endpoint' }, t.endpoint), React.createElement('input', { id: 'lcx-endpoint', className: 'lcx-input', value: state.baseURL, disabled, onChange: (event) => props.edit('baseURL', event.target.value) })),
216
+ React.createElement('div', { className: 'lcx-field' }, React.createElement('label', { htmlFor: 'lcx-model' }, t.model), React.createElement('input', { id: 'lcx-model', className: 'lcx-input', value: state.model, disabled, onChange: (event) => props.edit('model', event.target.value) })),
217
+ ),
218
+ React.createElement('p', { className: 'lcx-help' }, state.hint),
219
+ React.createElement('div', { className: 'lcx-footer' },
220
+ state.failed ? React.createElement('span', { className: 'lcx-error' }, t.saveFailed) : null,
221
+ React.createElement('button', { type: 'button', disabled: !state.dirty || disabled, onClick: props.discard }, t.discard),
222
+ React.createElement('button', { className: 'primary', type: 'button', disabled: !state.dirty || disabled, onClick: props.save }, state.saving ? t.saving : t.save),
223
+ ),
224
+ ) : null,
225
+ )
226
+ }
227
+
228
+ const inject = ['slots', 'locale', 'settingsScope']
229
+
230
+ function apply(ctx) {
231
+ const slots = ctx.slots ?? ctx.get('slots')
232
+ const scopeService = ctx.settingsScope ?? ctx.get('settingsScope')
233
+ if (slots === undefined || scopeService === undefined) return
234
+ const scope = scopeService.bind({ namespace: NAMESPACE })
235
+ const controller = new SettingsController(scope)
236
+ slots.inject('settings.plugin.item', () => slots.register({
237
+ name: 'settings.plugin.item',
238
+ key: NAMESPACE,
239
+ inject: () => controller.inject(),
240
+ }, LcxCard))
241
+ ctx.effect(() => () => controller.stop(), 'lcx-codex: settings card')
242
+ }
243
+
244
+ exports.apply = apply
245
+ exports.inject = inject
246
+ return module.exports
247
+ },
248
+ })
@@ -0,0 +1,305 @@
1
+ import { normalizeCompactionResponse } from './compact.js'
2
+ import { fetchSseWithRetry } from './transport.js'
3
+
4
+ const REMOTE_COMPACTION_V2_FEATURE = 'remote_compaction_v2'
5
+ const DEFAULT_MAX_SSE_BYTES = 8 * 1024 * 1024
6
+
7
+ function isObject(value) {
8
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
9
+ }
10
+
11
+ function protocolError(message, code = 'LCX_COMPACT_INVALID_SSE') {
12
+ const error = new Error(message)
13
+ error.code = code
14
+ return error
15
+ }
16
+
17
+ function outputItemIdentity(event) {
18
+ const outputIndex = Number.isInteger(event?.output_index) && event.output_index >= 0
19
+ ? `index:${event.output_index}`
20
+ : undefined
21
+ const itemIds = [event?.item?.id, event?.item_id]
22
+ .filter((value) => typeof value === 'string' && value.length > 0)
23
+ .map((value) => `id:${value}`)
24
+ const uniqueItemIds = [...new Set(itemIds)]
25
+ if (uniqueItemIds.length > 1) {
26
+ throw protocolError('Native compaction SSE event contains conflicting item identities')
27
+ }
28
+ return { outputIndex, itemId: uniqueItemIds[0] }
29
+ }
30
+
31
+ function mergeOutputItem(event, items, recordsByIndex, recordsById) {
32
+ if (!isObject(event?.item)) return
33
+ const { outputIndex, itemId } = outputItemIdentity(event)
34
+ const mappedRecords = new Set()
35
+ if (outputIndex && recordsByIndex.has(outputIndex)) mappedRecords.add(recordsByIndex.get(outputIndex))
36
+ if (itemId && recordsById.has(itemId)) mappedRecords.add(recordsById.get(itemId))
37
+ if (mappedRecords.size > 1) {
38
+ throw protocolError('Native compaction SSE event maps output_index and item id to different items')
39
+ }
40
+
41
+ let record = mappedRecords.values().next().value
42
+ if (!record && event.type === 'response.output_item.done') {
43
+ const pending = items.filter((candidate) => !candidate.done &&
44
+ (!candidate.item.type || !event.item.type || candidate.item.type === event.item.type))
45
+ if (pending.length === 1) record = pending[0]
46
+ else if (pending.length > 1) {
47
+ throw protocolError('Native compaction SSE output item has ambiguous aliases')
48
+ }
49
+ }
50
+ if (!record) {
51
+ record = { item: {}, outputIndex: undefined, itemId: undefined, done: false }
52
+ items.push(record)
53
+ }
54
+
55
+ if (outputIndex && record.outputIndex && outputIndex !== record.outputIndex) {
56
+ throw protocolError('Native compaction SSE output_index changed for one item')
57
+ }
58
+ if (itemId && record.itemId && itemId !== record.itemId) {
59
+ throw protocolError('Native compaction SSE item id changed for one item')
60
+ }
61
+ if (outputIndex && recordsByIndex.has(outputIndex) && recordsByIndex.get(outputIndex) !== record) {
62
+ throw protocolError('Native compaction SSE output_index is reused by different items')
63
+ }
64
+ if (itemId && recordsById.has(itemId) && recordsById.get(itemId) !== record) {
65
+ throw protocolError('Native compaction SSE item id is reused by different items')
66
+ }
67
+
68
+ const previousEncryptedContent = typeof record.item.encrypted_content === 'string' && record.item.encrypted_content.length > 0
69
+ ? record.item.encrypted_content
70
+ : undefined
71
+ Object.assign(record.item, structuredClone(event.item))
72
+ if ((!record.item.encrypted_content || typeof record.item.encrypted_content !== 'string') && previousEncryptedContent) {
73
+ record.item.encrypted_content = previousEncryptedContent
74
+ }
75
+ if (outputIndex) {
76
+ record.outputIndex = outputIndex
77
+ recordsByIndex.set(outputIndex, record)
78
+ }
79
+ if (itemId) {
80
+ record.itemId = itemId
81
+ recordsById.set(itemId, record)
82
+ }
83
+ if (event.type === 'response.output_item.done') record.done = true
84
+ }
85
+
86
+ function validCompletedOutput(output) {
87
+ if (!Array.isArray(output)) return false
88
+ try {
89
+ normalizeCompactionResponse({ object: 'response.compaction', output })
90
+ return true
91
+ } catch {
92
+ return false
93
+ }
94
+ }
95
+
96
+ function usageFrom(response) {
97
+ const usage = response?.usage
98
+ if (!isObject(usage)) return undefined
99
+ const totalInputTokens = Number(usage.input_tokens ?? 0)
100
+ const outputTokens = Number(usage.output_tokens ?? 0)
101
+ const cacheReadValue = Number(usage.input_tokens_details?.cached_tokens ?? usage.input_token_details?.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? 0)
102
+ const cacheWriteValue = Number(usage.input_tokens_details?.cache_write_tokens ?? usage.input_token_details?.cache_write_tokens ?? usage.prompt_tokens_details?.cache_write_tokens ?? 0)
103
+ const cacheReadTokens = Number.isFinite(cacheReadValue) && cacheReadValue > 0 ? cacheReadValue : 0
104
+ const cacheWriteTokens = Number.isFinite(cacheWriteValue) && cacheWriteValue > 0 ? cacheWriteValue : 0
105
+ const inputTokens = Number.isFinite(totalInputTokens)
106
+ ? Math.max(0, totalInputTokens - cacheReadTokens - cacheWriteTokens)
107
+ : 0
108
+ const normalizedOutputTokens = Number.isFinite(outputTokens) && outputTokens > 0 ? outputTokens : 0
109
+ if (![inputTokens, cacheReadTokens, cacheWriteTokens, normalizedOutputTokens].some((value) => value > 0)) return undefined
110
+ return {
111
+ inputTokens,
112
+ outputTokens: normalizedOutputTokens,
113
+ ...(cacheReadTokens > 0 ? { cacheReadTokens } : {}),
114
+ ...(cacheWriteTokens > 0 ? { cacheWriteTokens } : {}),
115
+ ...(Number.isFinite(usage.output_token_details?.reasoning_tokens) && usage.output_token_details.reasoning_tokens > 0
116
+ ? { reasoningTokens: Number(usage.output_token_details.reasoning_tokens) }
117
+ : {}),
118
+ }
119
+ }
120
+
121
+ function validToolDeclaration(tool) {
122
+ if (!isObject(tool)) return false
123
+ if (tool.type === undefined) return typeof tool.name === 'string' && tool.name.length > 0
124
+ if (typeof tool.type !== 'string' || tool.type.length === 0) return false
125
+ if (tool.type === 'function' || tool.type === 'custom') {
126
+ const name = tool.name ?? tool.function?.name
127
+ return typeof name === 'string' && name.length > 0
128
+ }
129
+ return true
130
+ }
131
+
132
+ export function responsesTools(tools) {
133
+ if (tools === undefined) return undefined
134
+ if (!Array.isArray(tools) || !tools.every(validToolDeclaration)) {
135
+ throw protocolError('Native compaction tools contain an invalid declaration', 'LCX_COMPACT_INVALID_TOOLS')
136
+ }
137
+ return tools.map((tool) => {
138
+ if (tool.type !== undefined) return structuredClone(tool)
139
+ return {
140
+ type: 'function',
141
+ ...structuredClone(tool),
142
+ description: typeof tool.description === 'string' ? tool.description : '',
143
+ parameters: tool.parameters && typeof tool.parameters === 'object' ? structuredClone(tool.parameters) : { type: 'object', properties: {} },
144
+ strict: false,
145
+ }
146
+ })
147
+ }
148
+
149
+ export function mergeFeatureHeader(headers) {
150
+ const result = { ...(headers ?? {}) }
151
+ const key = Object.keys(result).find((name) => name.toLowerCase() === 'x-codex-beta-features')
152
+ const existing = key ? String(result[key] ?? '').trim() : ''
153
+ const features = existing.split(',').map((value) => value.trim()).filter(Boolean)
154
+ if (!features.includes(REMOTE_COMPACTION_V2_FEATURE)) features.push(REMOTE_COMPACTION_V2_FEATURE)
155
+ result[key ?? 'x-codex-beta-features'] = features.join(',')
156
+ return result
157
+ }
158
+
159
+ export function buildNativeCompactionBody({ model, input, instructions, promptCacheKey, tools }) {
160
+ if (!Array.isArray(input)) throw protocolError('Native compaction input must be an array', 'LCX_COMPACT_INVALID_INPUT')
161
+ if (input.some((item) => isObject(item) && item.type === 'compaction_trigger')) {
162
+ throw protocolError('Native compaction input already contains compaction_trigger', 'LCX_COMPACT_DUPLICATE_TRIGGER')
163
+ }
164
+ const nativeTools = responsesTools(tools)
165
+ return {
166
+ model,
167
+ input: [...structuredClone(input), { type: 'compaction_trigger' }],
168
+ stream: true,
169
+ store: false,
170
+ ...(nativeTools !== undefined ? { tools: nativeTools } : {}),
171
+ ...(instructions !== undefined ? { instructions: structuredClone(instructions) } : {}),
172
+ ...(promptCacheKey !== undefined && promptCacheKey !== null ? { prompt_cache_key: String(promptCacheKey) } : {}),
173
+ }
174
+ }
175
+
176
+ function parseSseEvent(dataLines) {
177
+ if (dataLines.length === 0) return undefined
178
+ const data = dataLines.join('\n')
179
+ if (data === '[DONE]') return undefined
180
+ try {
181
+ const parsed = JSON.parse(data)
182
+ if (!isObject(parsed)) throw new Error('SSE event is not an object')
183
+ return parsed
184
+ } catch (error) {
185
+ throw protocolError(`Native compaction returned malformed SSE JSON: ${String(error)}`)
186
+ }
187
+ }
188
+
189
+ export async function parseNativeCompactionSse(response, options = {}) {
190
+ if (!response?.body) throw protocolError('Native compaction response did not include an SSE body', 'LCX_INVALID_SSE')
191
+ const maxBytes = options.maxResponseBytes ?? DEFAULT_MAX_SSE_BYTES
192
+ const reader = response.body.getReader()
193
+ const decoder = new TextDecoder()
194
+ const eventItems = []
195
+ const recordsByIndex = new Map()
196
+ const recordsById = new Map()
197
+ let completedResponse
198
+ let completedOutput
199
+ let pending = ''
200
+ let dataLines = []
201
+ let bytes = 0
202
+ let sawTerminal = false
203
+
204
+ const dispatch = () => {
205
+ const event = parseSseEvent(dataLines)
206
+ dataLines = []
207
+ if (!event) return
208
+ if (event.type === 'error' || event.type === 'response.failed' || event.type === 'response.incomplete') {
209
+ const message = event.error?.message ?? event.response?.error?.message ?? `Native compaction ended with ${String(event.type)}`
210
+ throw protocolError(message, 'LCX_COMPACT_UPSTREAM_ERROR')
211
+ }
212
+ if (event.type === 'response.output_item.added' || event.type === 'response.output_item.done') {
213
+ mergeOutputItem(event, eventItems, recordsByIndex, recordsById)
214
+ }
215
+ if (event.type === 'response.completed') {
216
+ sawTerminal = true
217
+ completedResponse = isObject(event.response) ? event.response : {}
218
+ completedOutput = Array.isArray(completedResponse.output) ? structuredClone(completedResponse.output) : undefined
219
+ }
220
+ }
221
+
222
+ try {
223
+ while (true) {
224
+ if (options.signal?.aborted) throw options.signal.reason ?? new Error('request aborted')
225
+ const result = await reader.read()
226
+ if (result.done) break
227
+ bytes += result.value.byteLength
228
+ if (bytes > maxBytes) throw protocolError(`Native compaction SSE response exceeds ${maxBytes} bytes`, 'LCX_RESPONSE_TOO_LARGE')
229
+ pending += decoder.decode(result.value, { stream: true })
230
+ let newline
231
+ while ((newline = pending.indexOf('\n')) >= 0) {
232
+ const line = pending.slice(0, newline).replace(/\r$/u, '')
233
+ pending = pending.slice(newline + 1)
234
+ if (line === '') dispatch()
235
+ else if (!line.startsWith(':')) {
236
+ if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /u, ''))
237
+ }
238
+ }
239
+ }
240
+ pending += decoder.decode()
241
+ if (pending.length > 0) {
242
+ const line = pending.replace(/\r$/u, '')
243
+ if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /u, ''))
244
+ }
245
+ dispatch()
246
+ } finally {
247
+ await reader.cancel().catch(() => undefined)
248
+ }
249
+
250
+ if (!sawTerminal || !completedResponse) {
251
+ throw protocolError('Native compaction SSE ended without response.completed', 'LCX_COMPACT_INCOMPLETE_SSE')
252
+ }
253
+
254
+ if (completedResponse.object !== undefined && !['response.compaction', 'response'].includes(completedResponse.object)) {
255
+ throw protocolError(`Native compaction returned unexpected response object: ${String(completedResponse.object)}`, 'LCX_COMPACT_INVALID_RESPONSE')
256
+ }
257
+ const mergedEventOutput = eventItems.map((record) => record.item)
258
+ const output = validCompletedOutput(completedOutput) ? completedOutput : mergedEventOutput
259
+ const normalized = normalizeCompactionResponse({
260
+ object: completedResponse.object ?? 'response.compaction',
261
+ id: typeof completedResponse.id === 'string' ? completedResponse.id : undefined,
262
+ output,
263
+ })
264
+ return {
265
+ ...normalized,
266
+ usage: usageFrom(completedResponse),
267
+ }
268
+ }
269
+
270
+ export async function requestNativeCompaction({
271
+ baseURL,
272
+ model,
273
+ input,
274
+ instructions,
275
+ promptCacheKey,
276
+ idempotencyKey,
277
+ tools,
278
+ headers,
279
+ signal,
280
+ timeoutMs,
281
+ maxAttempts,
282
+ maxResponseBytes,
283
+ }) {
284
+ const body = buildNativeCompactionBody({ model, input, instructions, promptCacheKey, tools })
285
+ const requestHeaders = { ...(headers ?? {}) }
286
+ if (idempotencyKey !== undefined && idempotencyKey !== null && !Object.keys(requestHeaders).some((name) => name.toLowerCase() === 'idempotency-key')) {
287
+ requestHeaders['idempotency-key'] = String(idempotencyKey)
288
+ }
289
+ const featureHeaders = mergeFeatureHeader(requestHeaders)
290
+ return fetchSseWithRetry(
291
+ `${String(baseURL).replace(/\/+$/u, '')}/responses`,
292
+ body,
293
+ featureHeaders,
294
+ signal,
295
+ timeoutMs,
296
+ {
297
+ maxAttempts,
298
+ maxResponseBytes,
299
+ consume: (response, consumeOptions = {}) => parseNativeCompactionSse(response, {
300
+ signal: consumeOptions.requestSignal ?? signal,
301
+ maxResponseBytes,
302
+ }),
303
+ },
304
+ )
305
+ }