dsh-lcx-codex 0.4.0-rc.8 → 0.4.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/ARCHITECTURE.md +6 -2
- package/CHANGELOG.md +76 -0
- package/README.md +1 -1
- package/lib/compact-v2.js +45 -6
- package/lib/dsh-responses.js +175 -96
- package/lib/index.js +158 -62
- package/lib/native-checkpoint.js +1 -1
- package/lib/responses-replay.js +158 -68
- package/lib/route.js +96 -11
- package/lib/service-mutex.js +72 -0
- package/lib/transport.js +11 -10
- package/lib/web-search-alpha.js +1 -1
- package/lib/web-search-capability.js +73 -11
- package/lib/web-search-ref-store.js +68 -8
- package/package.json +4 -1
package/ARCHITECTURE.md
CHANGED
|
@@ -9,7 +9,7 @@ DSH 0.1.1-rc.2 `compaction-basic` defaults to a `0.8` pressure threshold and, on
|
|
|
9
9
|
- provider-confirmed context overflow continues to use DSH's original `context-overflow` recovery unchanged;
|
|
10
10
|
- manual `/compact` remains unchanged.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Agent presets may isolate `compaction` and `toolResultPruner` inside entry-local Cordis realms. DSH 0.1.1-rc.2 explicitly documents that these preset services are invisible to both the host and ordinary `agent.ctx`; host-side code must address them through `agentPresets.serviceFor(agent, name)`. LCX therefore observes agent lifecycle events globally, resolves each Agent's real preset-local compaction/pruner through that public resolver, and patches the concrete compaction instance. A root `ctx.inject(['compaction'], ...)` hook remains only for non-preset/non-isolated deployments. Concrete Cordis service identity is used for de-duplication. DSH's own pre-step listener dynamically dispatches `this.compactIfNeeded()` at event time, and the wrapper is restored on plugin cleanup.
|
|
13
13
|
|
|
14
14
|
## rc.6 search timeout coordination
|
|
15
15
|
|
|
@@ -17,12 +17,16 @@ The pressure wrapper is installed on the actual agent-scoped compaction instance
|
|
|
17
17
|
|
|
18
18
|
# Architecture Notes — 0.4 Native Session Refactor / rc.6 Pressure Coordination
|
|
19
19
|
|
|
20
|
+
## rc.11 Native cache identity
|
|
21
|
+
|
|
22
|
+
Native compaction and same-route replay reuse the active DSH/Pi conversation cache identity: the clamped session id is the `prompt_cache_key`, provider `cacheRetention` is respected, and `long` may emit `prompt_cache_retention: 24h` when supported. `cacheRetention: none` omits Native prompt-cache/session affinity. Ordinary Hosted Search remains intentionally isolated under `dsh-lcx-search:<route hash>` so search traffic cannot share the main conversation request/cache namespace.
|
|
23
|
+
|
|
20
24
|
## Design invariants
|
|
21
25
|
|
|
22
26
|
1. **DSH owns compaction policy.** LCX never independently decides threshold, compact range, pruning, transaction boundaries or overflow retries.
|
|
23
27
|
2. **Native success performs one compaction model request.** Basic summary is a failure fallback, not a parallel portable-copy generator.
|
|
24
28
|
3. **DSH session log is the new checkpoint source of truth.** Opaque Native V2 state lives in `compaction/summary.rawOutput`; v3 sidecar access is legacy read-only.
|
|
25
|
-
4. **Opaque state
|
|
29
|
+
4. **Opaque state is same-session only.** Provider, model, base URL and exact `sourceSessionId === currentSessionId` gate Native opaque replay. Verified parent/child ancestry authorizes portable migration only; a fork never sends the parent's opaque checkpoint state.
|
|
26
30
|
5. **Route migration is transparent and transient.** Reconstruct shadowed DSH messages and hand them to the normal adapter; do not persist a second portable history copy.
|
|
27
31
|
6. **Ordinary search has one model tool.** `web_search` is ordinary search; `websearch_gpt_advanced` exists only for parameters absent from `WebSearchRequest`; Alpha remains its own stateful protocol.
|
|
28
32
|
7. **Provider-native wire code is isolated.** Direct `/responses` SSE code is limited to Native V2 compaction/replay and Hosted Search protocol calls.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,81 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 - 2026-08-23
|
|
4
|
+
|
|
5
|
+
### Stable promotion
|
|
6
|
+
|
|
7
|
+
- Promote the fully validated `0.4.0-rc.13` runtime to the stable `0.4.0` line with no functional/runtime code changes.
|
|
8
|
+
- Make the rc.13 Native V2 hardening, canonical Responses replay/serialization fixes, session isolation, cache continuity, and concurrency/lifecycle fixes the recommended stable release.
|
|
9
|
+
- Preserve `0.3.4` only as historical stable state; `0.4.0` becomes the default npm `latest` after publication.
|
|
10
|
+
|
|
11
|
+
### Validation
|
|
12
|
+
|
|
13
|
+
- Runtime-sensitive source is required to remain content-identical to the already live-tested rc.13 release.
|
|
14
|
+
- Full suite: 58/58 passed; DSH schema validation: 4/4 passed.
|
|
15
|
+
- Real rc.13 DSH acceptance already covers automatic Native pressure Compact, replay/cache re-warm, restart/resume, real GUI `/compact`, parent/child opaque isolation, and dual-session same-preset ServiceMutex failure cleanup.
|
|
16
|
+
- Stable promotion adds no new runtime behavior; publication is a version/documentation promotion only.
|
|
17
|
+
|
|
18
|
+
## 0.4.0-rc.13 - 2026-08-23
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Restrict opaque Native checkpoint replay to the exact source DSH session. Parent/child ancestry remains valid only for portable migration, so a fork never sends the parent session's opaque Native state.
|
|
23
|
+
- Reconcile Responses replay deltas and terminal output by stable item/call identity instead of `output_index` alone, preventing changed-index duplicate/empty text blocks and keeping terminal-only function calls balanced.
|
|
24
|
+
- Replace the simplified Native message/tool serializer with the public `@earendil-works/pi-ai@0.82.1` OpenAI Responses converters, preserving reasoning/message identities, tool linkage, constrained-tool semantics, deferred-tool transcript semantics, and DSH image boundaries.
|
|
25
|
+
- Match ordinary Pi `openai-responses` system-prompt placement: Native compact/replay now carries the developer/system prelude inside the canonical input prefix rather than introducing a separate top-level `instructions` prefix difference.
|
|
26
|
+
- Restore strict validation of DSH Pi replay-state envelopes before reusing native signatures; mismatched replay metadata degrades to a portable foreign-assistant projection instead of injecting stale provider-native identity.
|
|
27
|
+
- Treat Pi canonical role-only developer/user items as durable retained history and keep image persistence on `dsh_image_attachment` references rather than request image payloads.
|
|
28
|
+
- Use Pi/model or explicitly configured DSH Responses compatibility only; unknown custom routes no longer assume strict tools, grammar tools, or tool-search support.
|
|
29
|
+
- Mirror DSH/OpenAI Responses generation controls into Native compact/replay (`reasoning` + encrypted-reasoning include, temperature, and max output tokens), preventing the real Terra `xhigh` compact path from dropping the envelope used by ordinary Pi requests.
|
|
30
|
+
- Source automatic-compaction generation controls from the matching session request header rather than the Basic compaction summarizer request, so the selected conversation effort (for example Terra `xhigh`) reaches the Native provider request.
|
|
31
|
+
- Declare the DSH `sessions` service as an explicit Cordis runtime injection because checkpoint/replay and automatic-compaction generation parity read the live session/request header.
|
|
32
|
+
- Maintain a deterministic per-session request-header cache from DSH `session/event`; `compaction/start` synchronously refreshes it from the live Session before the Native request, avoiding reliance on async-context propagation for generation parity.
|
|
33
|
+
- Register that cross-session `session/event` observer with Cordis `{ global: true }`, matching DSH system-wide observers so Agent-carrier session events reach the standing plugin scope.
|
|
34
|
+
- Snapshot the exact `agentArg.session.requestHeader()` inside the Native-first pressure wrapper immediately before delegating to Basic compaction; this is the deterministic pressure-path source of generation controls, with the global session-event cache retained only as a secondary path.
|
|
35
|
+
- Seed the request-header cache from already-live sessions at plugin installation, seed newly announced resume/fork sessions, and evict disposed session entries so manual/non-pressure compaction remains restart-safe without retaining stale headers.
|
|
36
|
+
- Match the explicit Remote V2 tool-control contract used by current Codex and mature Responses compaction implementations: Native compact/replay send `tool_choice: "auto"` and `parallel_tool_calls: true` on the plain `openai-responses` route.
|
|
37
|
+
- Preserve only bounded provider machine diagnostics (`code`, `type`, `param`) from `response.failed`; the safe identifiers are included in the generic failure message/log so DSH compaction history can diagnose failures, while provider messages/bodies remain excluded.
|
|
38
|
+
- Persist Pi replay envelope v2 on successful Native replay finish so DSH retains response id/stop reason plus text/reasoning native metadata across post-compact turns.
|
|
39
|
+
- Preserve normal Pi OpenAI function-call identity as `call_id|item_id` for replay deltas and completed tool-call blocks, preventing loss of the provider `fc_*` item id on the next canonical request.
|
|
40
|
+
- Treat the session header `config` as the effective ordinary-request envelope even when DSH marks a value in `adapterDefaults`; those materialized defaults (for example Terra `xhigh` and `maxTokens`) are still sent by normal Pi and therefore must be mirrored by Native compact/replay.
|
|
41
|
+
|
|
42
|
+
### Validation
|
|
43
|
+
|
|
44
|
+
- `tests/rc13-regressions.test.mjs`: 15/15 passed.
|
|
45
|
+
- Full local suite: 58/58 passed; DSH schema validation: 4/4 passed; `git diff --check` passed.
|
|
46
|
+
- Installed DSH/NewAPI acceptance passed on Terra xhigh: automatic 90% Native V2 compact, continuous replay/cache re-warm, DSH Web restart/resume, real GUI `/compact` plus continuation, and parent→child fork portable isolation all completed without pre-Native stock prune or cross-session opaque replay.
|
|
47
|
+
- This remains a local release candidate only; no npm/GitHub publication or tag is implied.
|
|
48
|
+
|
|
49
|
+
## 0.4.0-rc.12 - 2026-08-22
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- Serialize every `compactIfNeeded()` call per concrete preset compaction service so same-generation sessions cannot observe each other’s temporary Native-first pruner/config state; queued calls are abortable and plugin cleanup drains active owners before restoring the original method.
|
|
54
|
+
- Align Native `openai-responses` session affinity with the active Pi adapter: default OpenAI-format routes use `session_id` plus `x-client-request-id`, while OpenRouter-format routes use `x-session-id`; explicit affinity headers remain authoritative.
|
|
55
|
+
- Restrict Basic fallback to allowlisted retryable first-checkpoint failures and fail closed once a Native/legacy checkpoint already exists.
|
|
56
|
+
- Require a real `response.completed` terminal event with `status=completed` for Native compaction/replay and reject orphan `function_call_output` items.
|
|
57
|
+
- Set credential-bearing fetches to `redirect: error` and keep provider response bodies/messages out of surfaced/logged transport errors.
|
|
58
|
+
|
|
59
|
+
## 0.4.0-rc.11 - 2026-08-22
|
|
60
|
+
|
|
61
|
+
### Fixed
|
|
62
|
+
|
|
63
|
+
- Restore ERR-051 cache/session affinity semantics that regressed in the rc.8 refactor: Native V2 compaction and same-route replay now use the DSH/Pi conversation session id as the clamped `prompt_cache_key` instead of a route fingerprint, inherit the active provider `cacheRetention`, emit `prompt_cache_retention: 24h` only for supported `long` retention, and omit Native cache affinity when retention is `none`.
|
|
64
|
+
- Keep ordinary Hosted Search on its intentionally separate `dsh-lcx-search:<route hash>` namespace.
|
|
65
|
+
|
|
66
|
+
## 0.4.0-rc.10 - 2026-08-22
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
70
|
+
- Fix Native-first pressure coordination for DSH 0.1.1-rc.2 Agent presets by using the public `agentPresets.serviceFor(agent, name)` resolver for preset-local `compaction` and `toolResultPruner` instances. This replaces the rc.9 assumption that ordinary `agent.ctx` lookup could see entry-local isolated services.
|
|
71
|
+
|
|
72
|
+
## 0.4.0-rc.9 - 2026-08-22
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
|
|
76
|
+
- Restore the Alpha capability/ref-store interfaces consumed by `lib/index.js`; the rc.8 release package could pass its unit tests but fail immediately at module import with missing `AlphaCapabilityStore` / `AlphaRefStore` exports.
|
|
77
|
+
- Add package-entry import regression coverage so CI fails when the server entry point and internal module exports drift out of sync.
|
|
78
|
+
- Restore Native-first pressure coordination for isolated agent presets using DSH 0.1.1-rc.2's public `agentPresets.serviceFor(agent, name)` resolver. Preset-local `compaction` / `toolResultPruner` services are not visible through ordinary host or `agent.ctx` lookup; the plugin now addresses the actual per-Agent instances before applying the 90% Native / 95% emergency policy. A root service-lifecycle hook remains for non-preset deployments.
|
|
3
79
|
## 0.4.0-rc.8
|
|
4
80
|
|
|
5
81
|
- Rebase the plugin on DSH `0.1.1-rc.2`; older DSH releases are no longer a supported runtime target.
|
package/README.md
CHANGED
|
@@ -209,7 +209,7 @@ dsh plugin --profile web add dsh-lcx-codex@next
|
|
|
209
209
|
|
|
210
210
|
```powershell
|
|
211
211
|
dsh plugin --profile web remove dsh-lcx-codex
|
|
212
|
-
dsh plugin --profile web add .\dsh-lcx-codex-0.4.0
|
|
212
|
+
dsh plugin --profile web add .\dsh-lcx-codex-0.4.0.tgz
|
|
213
213
|
dsh web
|
|
214
214
|
```
|
|
215
215
|
|
package/lib/compact-v2.js
CHANGED
|
@@ -5,6 +5,10 @@ export const REMOTE_COMPACTION_V2_FEATURE = 'remote_compaction_v2'
|
|
|
5
5
|
|
|
6
6
|
function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) }
|
|
7
7
|
function fail(message, code = 'LCX_COMPACT_INVALID_RESPONSE') { const e = new Error(message); e.code = code; return e }
|
|
8
|
+
function safeMachineField(value) {
|
|
9
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 96) return undefined
|
|
10
|
+
return /^[A-Za-z0-9_.:\[\]-]+$/u.test(value) ? value : undefined
|
|
11
|
+
}
|
|
8
12
|
|
|
9
13
|
export function mergeFeatureHeader(headers = {}) {
|
|
10
14
|
const result = { ...headers }
|
|
@@ -15,7 +19,24 @@ export function mergeFeatureHeader(headers = {}) {
|
|
|
15
19
|
return result
|
|
16
20
|
}
|
|
17
21
|
|
|
18
|
-
export function
|
|
22
|
+
export function responsesGenerationEnvelope({ reasoningEffort, temperature, maxTokens } = {}) {
|
|
23
|
+
const result = {}
|
|
24
|
+
if (reasoningEffort !== undefined && reasoningEffort !== 'off') {
|
|
25
|
+
result.reasoning = { effort: String(reasoningEffort), summary: 'auto' }
|
|
26
|
+
result.include = ['reasoning.encrypted_content']
|
|
27
|
+
}
|
|
28
|
+
if (temperature !== undefined) {
|
|
29
|
+
if (!Number.isFinite(temperature)) throw fail('Responses temperature must be finite', 'LCX_COMPACT_INVALID_INPUT')
|
|
30
|
+
result.temperature = Number(temperature)
|
|
31
|
+
}
|
|
32
|
+
if (maxTokens !== undefined) {
|
|
33
|
+
if (!Number.isSafeInteger(maxTokens) || maxTokens <= 0) throw fail('Responses maxTokens must be a positive safe integer', 'LCX_COMPACT_INVALID_INPUT')
|
|
34
|
+
result.max_output_tokens = Math.max(16, maxTokens)
|
|
35
|
+
}
|
|
36
|
+
return result
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function buildNativeCompactionBody({ model, input, instructions, tools, promptCacheKey, promptCacheRetention, reasoningEffort, temperature, maxTokens }) {
|
|
19
40
|
if (!Array.isArray(input)) throw fail('native compaction input must be an array', 'LCX_COMPACT_INVALID_INPUT')
|
|
20
41
|
if (input.some((item) => item?.type === 'compaction_trigger')) throw fail('native compaction input already contains compaction_trigger', 'LCX_COMPACT_DUPLICATE_TRIGGER')
|
|
21
42
|
const nativeTools = responsesTools(tools)
|
|
@@ -24,9 +45,13 @@ export function buildNativeCompactionBody({ model, input, instructions, tools, p
|
|
|
24
45
|
input: [...structuredClone(input), { type: 'compaction_trigger' }],
|
|
25
46
|
stream: true,
|
|
26
47
|
store: false,
|
|
48
|
+
tool_choice: 'auto',
|
|
49
|
+
parallel_tool_calls: true,
|
|
27
50
|
...(instructions !== undefined ? { instructions } : {}),
|
|
28
51
|
...(nativeTools !== undefined ? { tools: nativeTools } : {}),
|
|
29
52
|
...(promptCacheKey ? { prompt_cache_key: String(promptCacheKey) } : {}),
|
|
53
|
+
...(promptCacheRetention ? { prompt_cache_retention: String(promptCacheRetention) } : {}),
|
|
54
|
+
...responsesGenerationEnvelope({ reasoningEffort, temperature, maxTokens }),
|
|
30
55
|
}
|
|
31
56
|
}
|
|
32
57
|
|
|
@@ -67,6 +92,7 @@ function validateOutput(output) {
|
|
|
67
92
|
}
|
|
68
93
|
}
|
|
69
94
|
for (const id of calls) if (!results.has(id)) throw fail(`native compaction output has orphan function_call ${id}`)
|
|
95
|
+
for (const id of results) if (!calls.has(id)) throw fail(`native compaction output has orphan function_call_output ${id}`)
|
|
70
96
|
return { output: structuredClone(output), compaction: structuredClone(compact) }
|
|
71
97
|
}
|
|
72
98
|
|
|
@@ -89,14 +115,27 @@ export async function parseNativeCompactionSse(response, options = {}) {
|
|
|
89
115
|
await consumeSse(response, (event) => {
|
|
90
116
|
if (!isObject(event)) return
|
|
91
117
|
if (event.type === 'error' || event.type === 'response.failed' || event.type === 'response.incomplete') {
|
|
92
|
-
const
|
|
93
|
-
|
|
118
|
+
const upstream = event.type === 'response.failed' ? event.response?.error : event.error
|
|
119
|
+
const providerCode = safeMachineField(upstream?.code)
|
|
120
|
+
const providerType = safeMachineField(upstream?.type)
|
|
121
|
+
const providerParam = safeMachineField(upstream?.param)
|
|
122
|
+
const diagnostics = [
|
|
123
|
+
providerCode ? `providerCode=${providerCode}` : '',
|
|
124
|
+
providerType ? `providerType=${providerType}` : '',
|
|
125
|
+
providerParam ? `providerParam=${providerParam}` : '',
|
|
126
|
+
].filter(Boolean).join(' ')
|
|
127
|
+
const e = fail(`native compaction ended with ${event.type}${diagnostics ? ` ${diagnostics}` : ''}`, 'LCX_COMPACT_UPSTREAM_ERROR')
|
|
128
|
+
if (Number.isInteger(upstream?.status ?? event.error?.status)) e.status = upstream?.status ?? event.error?.status
|
|
129
|
+
if (providerCode) e.providerCode = providerCode
|
|
130
|
+
if (providerType) e.providerType = providerType
|
|
131
|
+
if (providerParam) e.providerParam = providerParam
|
|
94
132
|
throw e
|
|
95
133
|
}
|
|
96
134
|
if (event.type === 'response.output_item.added' || event.type === 'response.output_item.done') mergeItem(event)
|
|
97
|
-
if (event.type === 'response.completed'
|
|
135
|
+
if (event.type === 'response.completed') terminal = event.response
|
|
98
136
|
}, options)
|
|
99
137
|
if (!isObject(terminal)) throw fail('native compaction stream ended without response.completed', 'LCX_COMPACT_INCOMPLETE_SSE')
|
|
138
|
+
if (terminal.status !== 'completed') throw fail('native compaction response.completed did not carry completed status', 'LCX_COMPACT_INCOMPLETE_SSE')
|
|
100
139
|
const eventItems = [...new Set([...byIndex.values(), ...byId.values()])]
|
|
101
140
|
const output = Array.isArray(terminal.output) && terminal.output.length > 0 ? terminal.output : eventItems
|
|
102
141
|
const normalized = validateOutput(output)
|
|
@@ -108,8 +147,8 @@ export async function parseNativeCompactionSse(response, options = {}) {
|
|
|
108
147
|
}
|
|
109
148
|
}
|
|
110
149
|
|
|
111
|
-
export async function requestNativeCompaction({ baseURL, model, input, instructions, tools, promptCacheKey, idempotencyKey, headers, signal, timeoutMs, maxAttempts, maxResponseBytes }) {
|
|
112
|
-
const body = buildNativeCompactionBody({ model, input, instructions, tools, promptCacheKey })
|
|
150
|
+
export async function requestNativeCompaction({ baseURL, model, input, instructions, tools, promptCacheKey, promptCacheRetention, reasoningEffort, temperature, maxTokens, idempotencyKey, headers, signal, timeoutMs, maxAttempts, maxResponseBytes }) {
|
|
151
|
+
const body = buildNativeCompactionBody({ model, input, instructions, tools, promptCacheKey, promptCacheRetention, reasoningEffort, temperature, maxTokens })
|
|
113
152
|
const requestHeaders = mergeFeatureHeader({ ...headers, ...(idempotencyKey ? { 'idempotency-key': String(idempotencyKey) } : {}) })
|
|
114
153
|
return fetchSseWithRetry(`${String(baseURL).replace(/\/+$/u, '')}/responses`, body, requestHeaders, signal, timeoutMs, {
|
|
115
154
|
maxAttempts,
|
package/lib/dsh-responses.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { offloadRequestImagesWithPolicy } from '@deepseek-ai/dsh-llm'
|
|
2
|
+
import { convertResponsesMessages, convertResponsesTools } from '@earendil-works/pi-ai/api/openai-responses-shared'
|
|
3
|
+
import { createGrammarToolInputProperties } from '@earendil-works/pi-ai/api/constrained-sampling'
|
|
4
|
+
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
|
|
2
5
|
const DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024
|
|
3
6
|
const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 2048 * 2048
|
|
4
7
|
const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024
|
|
@@ -12,14 +15,6 @@ function error(message, code) {
|
|
|
12
15
|
return value
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
function textOf(blocks) {
|
|
16
|
-
return (Array.isArray(blocks) ? blocks : []).filter((b) => b?.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('')
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function hasImage(blocks) {
|
|
20
|
-
return (Array.isArray(blocks) ? blocks : []).some((block) => block?.type === 'image' || (block?.type === 'tool-result' && hasImage(block.content)))
|
|
21
|
-
}
|
|
22
|
-
|
|
23
18
|
export async function resolveModelImageSupport(ctx, route, signal) {
|
|
24
19
|
const llm = ctx?.get?.('llm') ?? ctx?.llm
|
|
25
20
|
if (typeof llm?.resolveModelInfo !== 'function') return 'unknown'
|
|
@@ -58,111 +53,195 @@ async function imagePart(block, ctx, options, imageMap) {
|
|
|
58
53
|
if (encodedBytes > options.maxRequestImageBytes) throw error('one image exceeds the configured LCX request image bound', 'LCX_COMPACT_IMAGE_TOO_LARGE')
|
|
59
54
|
const imageUrl = `data:${image.mediaType};base64,${image.data.toString('base64')}`
|
|
60
55
|
imageMap.set(imageUrl, structuredClone(image.ref))
|
|
61
|
-
return { type: 'input_image', image_url: imageUrl }
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function
|
|
65
|
-
if (
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
56
|
+
return { type: 'input_image', detail: 'auto', image_url: imageUrl }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function piImagePart(block, ctx, options, imageMap) {
|
|
60
|
+
if (options.imageSupport === 'unsupported') return { type: 'text', text: '[image omitted because the target model does not support image input]' }
|
|
61
|
+
const image = await attachmentResolver(ctx, options)(block, options.signal)
|
|
62
|
+
const encodedBytes = Math.ceil(image.data.byteLength / 3) * 4
|
|
63
|
+
if (encodedBytes > options.maxRequestImageBytes) throw error('one image exceeds the configured LCX request image bound', 'LCX_COMPACT_IMAGE_TOO_LARGE')
|
|
64
|
+
const data = image.data.toString('base64')
|
|
65
|
+
imageMap.set(`data:${image.mediaType};base64,${data}`, structuredClone(image.ref))
|
|
66
|
+
return { type: 'image', data, mimeType: image.mediaType }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseArguments(value) {
|
|
70
|
+
if (typeof value !== 'string') return value && typeof value === 'object' ? structuredClone(value) : {}
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(value)
|
|
73
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
|
74
|
+
} catch { return {} }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function emptyUsage() {
|
|
78
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function invalidReplay(message) { return error(`invalid pi-ai replay state: ${message}`, 'LCX_COMPACT_INVALID_REPLAY_STATE') }
|
|
82
|
+
function unsupportedContent(type) { return error(`LCX Compact cannot safely serialize DSH message content type: ${String(type)}`, 'LCX_CHECKPOINT_PORTABLE_UNSUPPORTED_CONTENT') }
|
|
83
|
+
function replayBlockType(type) { if (type === 'text') return 'text'; if (type === 'reasoning') return 'reasoning'; if (type === 'tool-call') return 'tool-call'; return undefined }
|
|
84
|
+
|
|
85
|
+
export function readDshPiReplayState(value) {
|
|
86
|
+
if (!isObject(value)) throw invalidReplay('expected a replay envelope')
|
|
87
|
+
const response = value.response
|
|
88
|
+
if (!isObject(response)) throw invalidReplay('expected a response object')
|
|
89
|
+
if (response.kind !== 'pi-ai') throw invalidReplay('unknown state kind')
|
|
90
|
+
if (response.version !== 2) throw invalidReplay(`unsupported version ${String(response.version)}`)
|
|
91
|
+
for (const key of ['api', 'provider', 'model']) if (typeof response[key] !== 'string' || response[key].length === 0) throw invalidReplay(`${key} must be a non-empty string`)
|
|
92
|
+
if (!['stop', 'length', 'toolUse', 'error', 'aborted'].includes(response.stopReason)) throw invalidReplay('unknown stopReason')
|
|
93
|
+
if (response.responseModel !== undefined && typeof response.responseModel !== 'string') throw invalidReplay('responseModel must be a string')
|
|
94
|
+
if (response.responseId !== undefined && typeof response.responseId !== 'string') throw invalidReplay('responseId must be a string')
|
|
95
|
+
if (!Array.isArray(value.blocks)) throw invalidReplay('blocks must be an array')
|
|
96
|
+
for (const [index, block] of value.blocks.entries()) {
|
|
97
|
+
if (!isObject(block)) throw invalidReplay(`block ${index} must be an object`)
|
|
98
|
+
if (!['text', 'reasoning', 'tool-call'].includes(block.type)) throw invalidReplay(`block ${index} has an unknown type`)
|
|
99
|
+
for (const signature of ['textSignature', 'thinkingSignature', 'thoughtSignature']) if (block[signature] !== undefined && typeof block[signature] !== 'string') throw invalidReplay(`block ${index} ${signature} must be a string`)
|
|
100
|
+
if (block.redacted !== undefined && typeof block.redacted !== 'boolean') throw invalidReplay(`block ${index} redacted must be boolean`)
|
|
77
101
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
type: 'function_call',
|
|
91
|
-
call_id: String(call.id),
|
|
92
|
-
name: String(call.name),
|
|
93
|
-
arguments: typeof call.arguments === 'string' ? call.arguments : JSON.stringify(call.arguments ?? {}),
|
|
94
|
-
})
|
|
95
|
-
}
|
|
96
|
-
return items
|
|
102
|
+
return { response, blocks: value.blocks }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function foreignAssistant(message) {
|
|
106
|
+
const source = message?.source?.kind === 'model' ? message.source : undefined
|
|
107
|
+
const content = []
|
|
108
|
+
for (const block of message?.content ?? []) {
|
|
109
|
+
if (block?.type === 'text') content.push({ type: 'text', text: String(block.text ?? '') })
|
|
110
|
+
else if (block?.type === 'reasoning') content.push({ type: 'thinking', thinking: String(block.text ?? '') })
|
|
111
|
+
else if (block?.type === 'tool-call') content.push({ type: 'toolCall', id: String(block.id), name: String(block.name), arguments: parseArguments(block.arguments) })
|
|
112
|
+
else if (block?.type === 'image') throw unsupportedContent('assistant image')
|
|
113
|
+
else throw unsupportedContent(block?.type)
|
|
97
114
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
115
|
+
return { role: 'assistant', content, api: 'dsh-foreign', provider: source?.provider ?? 'dsh-foreign', model: source?.model ?? 'dsh-foreign', usage: emptyUsage(), stopReason: content.some((block) => block.type === 'toolCall') ? 'toolUse' : 'stop', timestamp: 0 }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function replayedAssistant(message, source) {
|
|
119
|
+
const state = readDshPiReplayState(source.replayState)
|
|
120
|
+
if (state.response.provider !== source.provider) throw invalidReplay('provider does not match assistant source')
|
|
121
|
+
if (state.response.model !== source.model) throw invalidReplay('model does not match assistant source')
|
|
122
|
+
if (state.blocks.length !== (message?.content ?? []).length) throw invalidReplay('block count does not match assistant content')
|
|
123
|
+
const content = (message.content ?? []).map((block, index) => {
|
|
124
|
+
const replay = state.blocks[index]
|
|
125
|
+
if (replayBlockType(block?.type) !== replay?.type) throw invalidReplay(`block ${index} does not match assistant content`)
|
|
126
|
+
if (block.type === 'text') return { type: 'text', text: String(block.text ?? ''), ...(replay.textSignature === undefined ? {} : { textSignature: replay.textSignature }) }
|
|
127
|
+
if (block.type === 'reasoning') return { type: 'thinking', thinking: String(block.text ?? ''), ...(replay.thinkingSignature === undefined ? {} : { thinkingSignature: replay.thinkingSignature }), ...(replay.redacted === undefined ? {} : { redacted: replay.redacted }) }
|
|
128
|
+
return { type: 'toolCall', id: String(block.id), name: String(block.name), arguments: parseArguments(block.arguments), ...(replay.thoughtSignature === undefined ? {} : { thoughtSignature: replay.thoughtSignature }) }
|
|
129
|
+
})
|
|
130
|
+
return { role: 'assistant', content, api: state.response.api, provider: state.response.provider, model: state.response.model, ...(state.response.responseModel === undefined ? {} : { responseModel: state.response.responseModel }), ...(state.response.responseId === undefined ? {} : { responseId: state.response.responseId }), usage: emptyUsage(), stopReason: state.response.stopReason, timestamp: 0 }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function toPiAssistant(message, onReplayDegrade) {
|
|
134
|
+
const source = message?.source
|
|
135
|
+
if (source?.kind !== 'model' || source.replayState === undefined) return foreignAssistant(message)
|
|
136
|
+
try { return replayedAssistant(message, source) }
|
|
137
|
+
catch (cause) {
|
|
138
|
+
if (cause?.code !== 'LCX_COMPACT_INVALID_REPLAY_STATE') throw cause
|
|
139
|
+
onReplayDegrade?.(cause.message)
|
|
140
|
+
return foreignAssistant(message)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function piToolContent(blocks, ctx, options, imageMap) {
|
|
145
|
+
const content = []
|
|
146
|
+
for (const block of blocks ?? []) {
|
|
147
|
+
if (block?.type === 'text') content.push({ type: 'text', text: String(block.text ?? '') })
|
|
148
|
+
else if (block?.type === 'image') content.push(await piImagePart(block, ctx, options, imageMap))
|
|
149
|
+
else if (block?.type === 'tool-result') content.push(...await piToolContent(block.content, ctx, options, imageMap))
|
|
150
|
+
}
|
|
151
|
+
return content.length > 0 ? content : [{ type: 'text', text: '(no output)' }]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function dshToPiMessages(messages, ctx, options, imageMap) {
|
|
155
|
+
const result = []
|
|
156
|
+
for (const message of messages ?? []) {
|
|
157
|
+
if (message?.role === 'system') continue
|
|
158
|
+
if (message?.role === 'assistant') { result.push(toPiAssistant(message, options.onReplayDegrade)); continue }
|
|
159
|
+
if (message?.role !== 'user') continue
|
|
160
|
+
const ordinary = (message.content ?? []).filter((block) => block?.type === 'text' || block?.type === 'image')
|
|
161
|
+
if (ordinary.length > 0) {
|
|
162
|
+
const content = []
|
|
163
|
+
for (const block of ordinary) content.push(block.type === 'text' ? { type: 'text', text: String(block.text ?? '') } : await piImagePart(block, ctx, options, imageMap))
|
|
164
|
+
if (content.length > 0) result.push({ role: 'user', content, timestamp: 0 })
|
|
108
165
|
}
|
|
109
|
-
|
|
110
|
-
if (content.length > 0) items.push({ type: 'message', role: 'user', content })
|
|
166
|
+
for (const block of (message.content ?? []).filter((value) => value?.type === 'tool-result')) result.push({ role: 'toolResult', toolCallId: String(block.toolCallId), toolName: String(block.toolName ?? block.name ?? 'unknown'), content: await piToolContent(block.content, ctx, options, imageMap), addedToolNames: block.addedToolNames ?? message.addedToolNames ?? [], isError: block.isError === true, timestamp: 0 })
|
|
111
167
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
168
|
+
return result
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function builtinResponsesModel(provider, modelId) {
|
|
172
|
+
try { return getBuiltinModels(String(provider ?? '')).find((model) => model?.id === modelId && model?.api === 'openai-responses') }
|
|
173
|
+
catch { return undefined }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function piModel(options) {
|
|
177
|
+
const route = options.route ?? {}
|
|
178
|
+
const explicit = options.model && typeof options.model === 'object' ? options.model : undefined
|
|
179
|
+
const provider = String(explicit?.provider ?? route.provider ?? 'dsh-lcx-codex')
|
|
180
|
+
const id = String(explicit?.id ?? route.model ?? 'unknown')
|
|
181
|
+
const builtin = builtinResponsesModel(provider, id)
|
|
182
|
+
const compat = { ...(builtin?.compat ?? {}), ...(options.responsesCompat ?? {}), ...(explicit?.compat ?? {}) }
|
|
183
|
+
const input = options.imageSupport === 'supported' ? ['text', 'image'] : options.imageSupport === 'unsupported' ? ['text'] : (explicit?.input ?? builtin?.input ?? ['text'])
|
|
184
|
+
return {
|
|
185
|
+
...(builtin ?? {}), ...(explicit ?? {}), id, name: String(explicit?.name ?? builtin?.name ?? id), api: 'openai-responses', provider,
|
|
186
|
+
baseUrl: String(explicit?.baseUrl ?? route.baseURL ?? builtin?.baseUrl ?? ''), reasoning: typeof explicit?.reasoning === 'boolean' ? explicit.reasoning : (builtin?.reasoning ?? true), input,
|
|
187
|
+
cost: explicit?.cost ?? builtin?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: Number(explicit?.contextWindow ?? builtin?.contextWindow) > 0 ? Number(explicit?.contextWindow ?? builtin?.contextWindow) : 262144,
|
|
188
|
+
maxTokens: Number(explicit?.maxTokens ?? builtin?.maxTokens) > 0 ? Number(explicit?.maxTokens ?? builtin?.maxTokens) : 32768,
|
|
189
|
+
...(Object.keys(compat).length > 0 ? { compat } : {}),
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function splitDeferredTools(context, enabled) {
|
|
194
|
+
const unique = new Map()
|
|
195
|
+
for (const tool of context.tools ?? []) if (tool?.name) unique.set(tool.name, tool)
|
|
196
|
+
if (!enabled) return { immediate: [...unique.values()], deferred: new Map() }
|
|
197
|
+
const deferredNames = new Set(); const usedNames = new Set()
|
|
198
|
+
for (const message of context.messages ?? []) {
|
|
199
|
+
if (message.role === 'assistant') {
|
|
200
|
+
for (const block of message.content ?? []) if (block.type === 'toolCall') usedNames.add(block.name)
|
|
201
|
+
} else if (message.role === 'toolResult') {
|
|
202
|
+
for (const name of message.addedToolNames ?? []) if (!usedNames.has(name)) deferredNames.add(name)
|
|
203
|
+
}
|
|
118
204
|
}
|
|
119
|
-
|
|
205
|
+
const immediate = []; const deferred = new Map()
|
|
206
|
+
for (const [name, tool] of unique) { if (deferredNames.has(name)) deferred.set(name, tool); else immediate.push(tool) }
|
|
207
|
+
return { immediate, deferred }
|
|
120
208
|
}
|
|
121
209
|
|
|
122
210
|
export async function serializeDshMessages(messages, ctx, options = {}) {
|
|
123
211
|
const imageMap = new Map()
|
|
124
|
-
const input = []
|
|
125
212
|
const normalized = {
|
|
126
|
-
imageSupport: options.imageSupport ?? 'unknown',
|
|
127
|
-
|
|
128
|
-
maxRequestImageBytes: Number.isSafeInteger(options.maxRequestImageBytes) && options.maxRequestImageBytes > 0
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
requestImagePixelBudget: Number.isSafeInteger(options.requestImagePixelBudget) && options.requestImagePixelBudget > 0
|
|
132
|
-
? options.requestImagePixelBudget
|
|
133
|
-
: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
|
|
134
|
-
requestImageMaxBytes: Number.isSafeInteger(options.requestImageMaxBytes) && options.requestImageMaxBytes > 0
|
|
135
|
-
? options.requestImageMaxBytes
|
|
136
|
-
: DEFAULT_REQUEST_IMAGE_MAX_BYTES,
|
|
213
|
+
imageSupport: options.imageSupport ?? 'unknown', signal: options.signal, route: options.route ?? {}, model: options.model, responsesCompat: options.responsesCompat,
|
|
214
|
+
systemPrompt: typeof options.systemPrompt === 'string' ? options.systemPrompt : undefined, includeSystemPrompt: options.includeSystemPrompt === true, onReplayDegrade: options.onReplayDegrade,
|
|
215
|
+
maxRequestImageBytes: Number.isSafeInteger(options.maxRequestImageBytes) && options.maxRequestImageBytes > 0 ? options.maxRequestImageBytes : DEFAULT_MAX_REQUEST_IMAGE_BYTES,
|
|
216
|
+
requestImagePixelBudget: Number.isSafeInteger(options.requestImagePixelBudget) && options.requestImagePixelBudget > 0 ? options.requestImagePixelBudget : DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
|
|
217
|
+
requestImageMaxBytes: Number.isSafeInteger(options.requestImageMaxBytes) && options.requestImageMaxBytes > 0 ? options.requestImageMaxBytes : DEFAULT_REQUEST_IMAGE_MAX_BYTES,
|
|
137
218
|
}
|
|
138
|
-
const projected = offloadRequestImagesWithPolicy(messages ?? [], {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
219
|
+
const projected = offloadRequestImagesWithPolicy(messages ?? [], { representation: 'base64', maxBytes: normalized.maxRequestImageBytes, byteQuantum: 1, byteLength: ref => Math.min(ref.bytes, normalized.requestImageMaxBytes) })
|
|
220
|
+
const model = piModel(normalized)
|
|
221
|
+
const context = { systemPrompt: normalized.systemPrompt, messages: await dshToPiMessages(projected, ctx, normalized, imageMap), tools: options.tools ?? [] }
|
|
222
|
+
const supportsStrictMode = model.compat?.supportsStrictMode ?? false
|
|
223
|
+
const supportsOpenAIGrammarTools = model.compat?.supportsOpenAIGrammarTools ?? false
|
|
224
|
+
const supportsToolSearch = model.compat?.supportsToolSearch ?? false
|
|
225
|
+
const grammarToolInputProperties = createGrammarToolInputProperties(context.tools, supportsOpenAIGrammarTools)
|
|
226
|
+
const placement = splitDeferredTools(context, supportsToolSearch)
|
|
227
|
+
const toolOptions = { supportsStrictMode, supportsOpenAIGrammarTools }
|
|
228
|
+
const input = convertResponsesMessages(model, context, new Set(['openai', 'openai-codex', 'opencode']), {
|
|
229
|
+
includeSystemPrompt: normalized.includeSystemPrompt, grammarToolInputProperties, deferredTools: placement.deferred, toolOptions,
|
|
143
230
|
})
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
input.push(...await messageItems(message, ctx, normalized, imageMap))
|
|
147
|
-
}
|
|
148
|
-
return { input, imageMap }
|
|
231
|
+
const tools = options.tools === undefined ? undefined : convertResponsesTools(placement.immediate, toolOptions)
|
|
232
|
+
return { input, imageMap, tools }
|
|
149
233
|
}
|
|
150
234
|
|
|
151
235
|
export function responsesTools(tools) {
|
|
152
236
|
if (tools === undefined) return undefined
|
|
153
237
|
if (!Array.isArray(tools)) throw error('LCX Responses tools must be an array', 'LCX_COMPACT_INVALID_TOOLS')
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
type: 'function',
|
|
158
|
-
name: tool.name,
|
|
159
|
-
description: typeof tool.description === 'string' ? tool.description : '',
|
|
160
|
-
parameters: isObject(tool.parameters) ? structuredClone(tool.parameters) : { type: 'object', properties: {} },
|
|
161
|
-
strict: false,
|
|
162
|
-
}
|
|
163
|
-
})
|
|
238
|
+
if (tools.every((tool) => isObject(tool) && typeof tool.type === 'string')) return structuredClone(tools)
|
|
239
|
+
for (const tool of tools) if (!isObject(tool) || typeof tool.name !== 'string' || !tool.name) throw error('LCX Responses tool has no name', 'LCX_COMPACT_INVALID_TOOLS')
|
|
240
|
+
return convertResponsesTools(tools, { supportsStrictMode: false, supportsOpenAIGrammarTools: false })
|
|
164
241
|
}
|
|
165
242
|
|
|
243
|
+
function isResponsesMessageItem(item) { return isObject(item) && typeof item.role === 'string' && (item.type === undefined || item.type === 'message') }
|
|
244
|
+
|
|
166
245
|
function mapImageParts(value, mapper) {
|
|
167
246
|
if (!Array.isArray(value)) return value
|
|
168
247
|
return value.map((part) => {
|
|
@@ -181,7 +260,7 @@ export function persistNativeImageReferences(output, imageMap) {
|
|
|
181
260
|
return { type: 'dsh_image_attachment', attachment: structuredClone(ref) }
|
|
182
261
|
}
|
|
183
262
|
return (output ?? []).map((item) => {
|
|
184
|
-
if (item
|
|
263
|
+
if (isResponsesMessageItem(item) && Array.isArray(item.content)) return { ...structuredClone(item), content: mapImageParts(item.content, persist) }
|
|
185
264
|
if (item?.type === 'function_call_output' && Array.isArray(item.output)) return { ...structuredClone(item), output: mapImageParts(item.output, persist) }
|
|
186
265
|
return structuredClone(item)
|
|
187
266
|
})
|
|
@@ -206,7 +285,7 @@ export async function hydrateNativeImageReferences(output, ctx, options = {}) {
|
|
|
206
285
|
}
|
|
207
286
|
const result = []
|
|
208
287
|
for (const item of output ?? []) {
|
|
209
|
-
if (item
|
|
288
|
+
if (isResponsesMessageItem(item) && Array.isArray(item.content)) {
|
|
210
289
|
const content = []
|
|
211
290
|
for (const part of item.content) content.push(await hydratePart(part, ctx, normalized))
|
|
212
291
|
result.push({ ...structuredClone(item), content })
|