@goodandready/dsh-clinebot 0.5.33 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ All notable changes to `@goodandready/dsh-clinebot` will be documented in this f
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.1] - 2026-10-21
9
+
10
+ ### Fixed
11
+ - **ClineBot-only session cost**: the composer now reads the plugin's per-session `bySession` bucket instead of the host's session-wide `tokenUsage`. A conversation that also used OpenCode can no longer inflate the ClineBot estimate.
12
+ - **Last request in the tooltip**: each session bucket retains the latest ClineBot request; the native token pill title now shows the ClineBot session cost and the last request cost.
13
+ - **Free estimates**: priced requests that cost zero render as `Free` instead of `$0.0000`.
14
+
15
+ ### Added
16
+ - `GET /dsh-clinebot/stats/session?sessionId=...` returns the ClineBot-only aggregate for one DSH session, including the last request.
17
+
18
+ ## [0.6.0] - 2026-10-20
19
+
20
+ ### Added
21
+ - **Native `ctx.llm` adapter**: `ClineBotAdapter` now owns streaming, model metadata, reasoning effort, pre-first-token account failover, retry policy and provider status. The legacy `llm-pi-ai` provider + loopback proxy is no longer required for chat.
22
+ - **Quota panel**: the sidebar opens a center-column `Quota / Analytics` panel through `layout.selectPanel`, with header refresh/close, account tabs, 120s auto-refresh and explicit `no key` / `endpoint unreachable` / `not registered` states. A modal fallback is used when the host panel API is unavailable.
23
+ - **Three fixed quota rows**: the sidebar always shows `5-Hour`, `Weekly` and `Monthly` limits with reset countdowns, `—` for unavailable windows and the active account in the tooltip.
24
+ - **Analytics v2**: persistent stats now track cache read/write tokens, estimated cost, TTFT, latency and bounded per-session buckets. The panel adds 7/30/90-day ranges, a weekday×hour request heatmap, peak-hour summary, by-hour strip and model filter; `GET /dsh-clinebot/analytics` exposes the aggregates and the store keeps 95 days with best-effort backfill.
25
+ - **Native composer cost**: the session estimate is appended to the host token-usage pill (`N tok · Cache hit X% · ≈ $...` / `Free`) instead of a second dock pill; its tooltip breaks down input, output, cache and reasoning tokens. The cost read-out is scoped to ClineBot model selections so OpenCode sessions keep their own provider estimate.
26
+ - **Local price table**: `lib/model-prices.js` resolves per-model estimates with user overrides and `estimate` / `override` provenance; `GET /dsh-clinebot/prices` feeds the composer. Unknown models display `unpriced` instead of `$0`.
27
+ - **dsh-TUI settings**: `ClineBot` section for API key, base URL, plan filtering, web search, sidebar quota, active account, context window and output cap.
28
+ - **Account surface**: live per-account usage, inline rename, clear key, pin/unpin and delete with inline confirmation; `GET /dsh-clinebot/accounts/usage`, `POST /accounts/rename`, `POST /accounts/key/clear`.
29
+ - **Calm settings UI**: staged single-save configuration, collapsible groups, SVG stroke icons and native primitives; no emojis in the client bundle. Neutral surfaces, compact tables and 8px progress bars follow the OpenCode provider visual language.
30
+ - **Coverage**: 240 tests total (238 pass, 2 platform skips, 0 failures); client bundle 251.8 KB, below the 256 KiB DSH Store limit.
31
+
32
+ ### Changed
33
+ - Retargeted to **DSH `0.2.1-rc.2`** (`engines.dsh`) with peer dependencies on `dsh-llm`, `dsh-attachment`, `dsh-credentials`, `dsh-settings`, `dsh-commands` and `dsh-web`.
34
+ - New installs default to `transport: "adapter"`. Settings, models and account actions are grouped; model and account ops stay immediate.
35
+ - Composer cost now reads the live price endpoint and respects `showComposerCost`; `/stats/export?detailed=1` adds cache, cost, TTFT and latency columns.
36
+
37
+ ### Deprecated
38
+ - `transport: "pi-ai"`, `proxyMode`, `lib/proxy-server.js`, `lib/proxy-token.js`, `lib/routes/proxy.js` and the `llm/stream` hook remain for one release as a compatibility fallback. They will be removed in 0.7.0.
39
+
8
40
  ## [0.5.33] - 2026-10-19
9
41
 
10
42
  ### Security
package/README.md CHANGED
@@ -4,11 +4,13 @@
4
4
 
5
5
  <h3>Native ClineBot / ClinePass Provider Companion for DeepSeek Harness</h3>
6
6
 
7
+ <p><b>0.6.0</b> moves chat onto a native <code>ctx.llm</code> adapter. Requires DSH <code>0.2.1-rc.2</code>. The legacy <code>llm-pi-ai</code> + loopback-proxy path remains available behind <code>transport: "pi-ai"</code> for one release.</p>
8
+
7
9
  <p align="center">
8
10
  <a href="https://www.npmjs.com/package/@goodandready/dsh-clinebot"><img src="https://img.shields.io/npm/v/@goodandready/dsh-clinebot.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
9
11
  <a href="LICENSE"><img src="https://img.shields.io/github/license/GooDAnDReaDY/dsh-clinebot.svg?style=for-the-badge&color=10b981&labelColor=064e3b" alt="license"></a>
10
12
  <a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/DSH-Plugin-8b5cf6.svg?style=for-the-badge&labelColor=2e1065" alt="DSH Plugin"></a>
11
- <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-20%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
13
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-22%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
12
14
  </p>
13
15
 
14
16
  <!-- Author Showcase Link -->
@@ -66,59 +68,73 @@ Integrating ClinePass into DeepSeek Harness (DSH) natively poses key challenges:
66
68
  ```mermaid
67
69
  graph LR
68
70
  subgraph UI [DSH Web Interface]
69
- Page["Dedicated Page (Settings -> ClineBot)"]
70
- QuotaBar["5-Hour & Weekly Progress Bars & Warning Banner"]
71
- KeyInput["Direct Key Paste & Save"]
72
- ModelPick["Dynamic Model Sync & Picker Controls"]
73
- StatsCard["Session Metrics Telemetry"]
71
+ Settings["Settings -> ClineBot"]
72
+ Dashboard["Quota / Analytics panel"]
73
+ Composer["Composer cost read-out"]
74
+ Picker["Model picker"]
74
75
  end
75
76
 
76
- subgraph PluginHost [dsh-clinebot Host Runtime]
77
- HttpEndpoints["API: /dsh-clinebot/*"]
78
- ClientCore["lib/cline-client.js"]
79
- ModelCatalog["lib/models.js (Curated + Dynamic Plan)"]
80
- SlashCmd["Command: /cline"]
77
+ subgraph PluginHost [dsh-clinebot Runtime]
78
+ Routes["HTTP: /dsh-clinebot/*"]
79
+ Adapter["lib/adapter.js (ctx.llm LlmAdapter)"]
80
+ Transport["lib/transport/cline-transport.js"]
81
+ Pool["account-pool + session-router"]
82
+ Stats["stats-storage v2 + model-prices"]
83
+ TUI["dsh-TUI settings section"]
81
84
  end
82
85
 
83
- subgraph DSHCore [DeepSeek Harness Services]
84
- Credentials["Credentials Service (~/.dsh/.credentials.yaml)"]
85
- PiAi["Settings: llm-pi-ai.providers.clinebot"]
86
+ subgraph DSHCore [DeepSeek Harness 0.2.1-rc.2]
87
+ Llm["ctx.llm"]
88
+ Credentials["ctx.credentials"]
89
+ Web["ctx.web / settings / commands"]
86
90
  end
87
91
 
88
- subgraph Upstream [Cline Cloud]
89
- ClinePass["api.cline.bot/api/v1/chat/completions"]
90
- ClineQuota["api.cline.bot/api/v1/users/me/plan/usage-limits"]
91
- ClinePlan["api.cline.bot/api/v1/users/me/plan"]
92
+ subgraph Upstream [ClinePass]
93
+ Chat["api.cline.bot/api/v1/chat/completions"]
94
+ Quota["users/me/plan/usage-limits"]
95
+ Plan["users/me/plan"]
92
96
  end
93
97
 
94
- Page -->|GET /status & /usage| HttpEndpoints
95
- KeyInput -->|POST /save-key| HttpEndpoints
96
- ModelPick -->|POST /models/sync| HttpEndpoints
97
- HttpEndpoints --> Credentials
98
- HttpEndpoints --> ClientCore
99
- ClientCore --> ModelCatalog
100
- HttpEndpoints -->|Atomic Mutate| PiAi
101
- ClientCore -->|Chat| ClinePass
102
- ClientCore -->|Usage Limits| ClineQuota
103
- ClientCore -->|Plan Features| ClinePlan
98
+ Settings --> Routes
99
+ Dashboard --> Routes
100
+ Composer --> Routes
101
+ Picker --> Llm
102
+ Llm --> Adapter
103
+ Adapter --> Pool
104
+ Adapter --> Transport
105
+ Transport --> Chat
106
+ Adapter --> Stats
107
+ Routes --> Stats
108
+ Routes --> Credentials
109
+ TUI --> Routes
110
+ Adapter --> Quota
111
+ Adapter --> Plan
104
112
  ```
105
113
 
106
114
  ---
107
115
 
108
116
  ## ✨ Features & Module Breakdown
109
117
 
118
+ * **`lib/adapter.js`**:
119
+ Registers the `ClineBotAdapter` directly on `ctx.llm`: streaming, model catalogue, reasoning effort, pre-first-token account failover, retry policy, usage accounting and provider metadata.
120
+ * **`lib/transport/cline-transport.js`**:
121
+ OpenAI-compatible SSE transport with connect/idle timeouts, abort handling and upstream error classification. The legacy proxy imports the same parser.
110
122
  * **`lib/models.js`**:
111
- Manages the dynamic subscription catalog and fallback models with full reasoning effort mappings (`off: null`, `low`, `medium`, `high`, `max`), upstream 200k context limit declarations, user-defined custom models, and dynamic subscription plan model parsing (`parsePlanIncludedModels`, `getAllModels`, `getDynamicModels`).
123
+ Dynamic subscription catalogue, curated fallback properties, reasoning efforts, custom models and plan parsing (`parsePlanIncludedModels`, `getAllModels`, `getDynamicModels`).
124
+ * **`lib/model-prices.js`**:
125
+ Explicit local cost estimates with per-model overrides. Unknown models stay `unpriced` instead of being reported as $0.
126
+ * **`lib/stats-storage.js`**:
127
+ Persistent JSON telemetry v2: cache buckets, estimated cost, TTFT/latency, sessions and day/month/model/account aggregates. v1 files upgrade on read.
128
+ * **`lib/account-pool.js` / `lib/session-router.js`**:
129
+ Credential discovery, usage-aware least-used and round-robin rotation, sticky sessions and cooldown recovery.
112
130
  * **`lib/cline-client.js`**:
113
- * `fetchUsageLimits`: queries `GET /users/me/plan/usage-limits`, `GET /users/me/plan`, and `GET /users/me` with in-memory caching.
114
- * `sessionStats` / `recordSessionRequest`: in-memory telemetry recording requests count, tokens, latency, and timestamps.
115
- * `saveCredentialKey`: writes credentials directly into `~/.dsh/.credentials.yaml`.
116
- * `smokeChat`: tests latency via non-streaming ping and updates session metrics.
117
- * `buildPiAiProvider`: builds the DSH `llm-pi-ai` structure (`api: 'openai-completions'`) with full reasoning effort protocol compliance (`compat.supportsReasoningEffort: true`).
131
+ `fetchUsageLimits`, credential save, smoke test, model cache and the legacy `llm-pi-ai` provider builder used only by `transport: "pi-ai"`.
118
132
  * **`lib/index.js`**:
119
- Cordis service module managing routes (including `POST /dsh-clinebot/models/sync`), quota warnings threshold evaluation, credentials, and registering the `/cline` slash command.
133
+ Cordis lifecycle: adapter registration, optional HTTP routes, quota warnings, `/cline` command, migration of the legacy provider and TUI section registration.
134
+ * **`lib/tui-settings.js`**:
135
+ `dsh-TUI` section for key, base URL, model filters, active account, context window and output cap.
120
136
  * **`lib/client.js`**:
121
- Configuration page on the plugin row (`plugins.row.config`) with live quota bars, an exhaustion warning, session metrics, one-click plan sync, and a fallback plugins card (`settings.plugin.item`). There is no `settings.section` sidebar entry.
137
+ Native `settings.section` plus provider card, sidebar meter, center-column quota/analytics panel (modal fallback) and composer cost read-out. No emojis, no full-page panel swap.
122
138
 
123
139
  ---
124
140
 
@@ -128,8 +144,19 @@ graph LR
128
144
  dsh plugin --profile web add @goodandready/dsh-clinebot
129
145
  ```
130
146
 
147
+ Requires **DSH `0.2.1-rc.2`** and Node 22+. New installs use `transport: "adapter"` and register directly with `ctx.llm`. Set `transport: "pi-ai"` only if you still need the legacy `llm-pi-ai` provider and loopback proxy for one release.
148
+
131
149
  Restart your DeepSeek Harness instance and refresh the browser.
132
150
 
151
+ ### 0.6.0 endpoints
152
+
153
+ | Endpoint | Purpose |
154
+ | --- | --- |
155
+ | `GET /dsh-clinebot/analytics?groupBy=model\|day\|session&from=&to=` | Aggregated requests, tokens, estimated cost, success rate and TTFT |
156
+ | `GET /dsh-clinebot/prices` | Local price table used by the composer read-out |
157
+ | `GET /dsh-clinebot/accounts/usage` | Per-account quota snapshot |
158
+ | `GET /dsh-clinebot/stats/export?detailed=1` | CSV with cache, cost, TTFT and latency columns |
159
+
133
160
  ---
134
161
 
135
162
  ## 💬 Slash-Command `/cline`
package/README.ru.md CHANGED
@@ -4,11 +4,13 @@
4
4
 
5
5
  <h3>Нативное подключение провайдера ClineBot / ClinePass для DeepSeek Harness</h3>
6
6
 
7
+ <p><b>0.6.0</b> переводит чат на нативный адаптер <code>ctx.llm</code>. Требуется DSH <code>0.2.1-rc.2</code>. Старый путь <code>llm-pi-ai</code> + loopback-прокси остаётся за флагом <code>transport: "pi-ai"</code> на один релиз.</p>
8
+
7
9
  <p align="center">
8
10
  <a href="https://www.npmjs.com/package/@goodandready/dsh-clinebot"><img src="https://img.shields.io/npm/v/@goodandready/dsh-clinebot.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
9
11
  <a href="../LICENSE"><img src="https://img.shields.io/github/license/GooDAnDReaDY/dsh-clinebot.svg?style=for-the-badge&color=10b981&labelColor=064e3b" alt="license"></a>
10
12
  <a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/DSH-Plugin-8b5cf6.svg?style=for-the-badge&labelColor=2e1065" alt="DSH Plugin"></a>
11
- <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-20%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
13
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-22%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
12
14
  </p>
13
15
 
14
16
  <!-- Обязательная кнопка перехода на витрину всех проектов -->
package/README.zh.md CHANGED
@@ -4,11 +4,13 @@
4
4
 
5
5
  <h3>适用于 DeepSeek Harness 的 ClineBot / ClinePass 原生模型提供商伴侣插件</h3>
6
6
 
7
+ <p><b>0.6.0</b> 将聊天迁移到原生 <code>ctx.llm</code> 适配器。需要 DSH <code>0.2.1-rc.2</code>。旧的 <code>llm-pi-ai</code> + 回环代理路径保留一个版本,通过 <code>transport: "pi-ai"</code> 启用。</p>
8
+
7
9
  <p align="center">
8
10
  <a href="https://www.npmjs.com/package/@goodandready/dsh-clinebot"><img src="https://img.shields.io/npm/v/@goodandready/dsh-clinebot.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
9
11
  <a href="../LICENSE"><img src="https://img.shields.io/github/license/GooDAnDReaDY/dsh-clinebot.svg?style=for-the-badge&color=10b981&labelColor=064e3b" alt="license"></a>
10
12
  <a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/DSH-Plugin-8b5cf6.svg?style=for-the-badge&labelColor=2e1065" alt="DSH Plugin"></a>
11
- <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-20%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
13
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-22%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
12
14
  </p>
13
15
 
14
16
  <!-- 作者所有项目展示页面链接 -->
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Translate DSH request messages and tools into ClinePass's OpenAI-compatible
3
+ * wire shape. Files are projected to text handles; images are resolved through
4
+ * the attachment seam into request-sized bytes.
5
+ */
6
+
7
+ const IMAGE_MAX_EDGE = 1568
8
+ const IMAGE_MAX_BYTES = 1024 * 1024
9
+
10
+ /**
11
+ * Concatenate text blocks.
12
+ * @param {readonly Record<string, unknown>[]} blocks
13
+ * @returns {string}
14
+ */
15
+ function textOf(blocks) {
16
+ if (!Array.isArray(blocks)) return ''
17
+ return blocks
18
+ .filter((block) => block && block.type === 'text' && typeof block.text === 'string')
19
+ .map((block) => block.text)
20
+ .join('')
21
+ }
22
+
23
+ /**
24
+ * Stable placeholder for one file block.
25
+ * @param {Record<string, unknown>} block
26
+ * @returns {string}
27
+ */
28
+ function fileText(block) {
29
+ const ref = block?.attachment && typeof block.attachment === 'object' ? block.attachment : null
30
+ if (!ref) return '[File omitted]'
31
+ const name = typeof ref.name === 'string' && ref.name ? ref.name : 'attachment'
32
+ const bytes = Number.isFinite(Number(ref.bytes)) ? Number(ref.bytes) : null
33
+ return `[File: ${name}${bytes === null ? '' : `, ${bytes} bytes`}]`
34
+ }
35
+
36
+ /**
37
+ * Placeholder for an image that cannot be resolved or was deliberately offloaded.
38
+ * @param {Record<string, unknown>} block
39
+ * @returns {string}
40
+ */
41
+ function imagePlaceholder(block) {
42
+ const ref = block?.attachment && typeof block.attachment === 'object' ? block.attachment : null
43
+ const name = typeof ref?.name === 'string' && ref.name ? ref.name : 'image'
44
+ const mediaType = typeof ref?.mediaType === 'string' ? ref.mediaType : 'image'
45
+ if (block?.offloaded === true) {
46
+ return `[Image: ${name} (${mediaType}) — previously analyzed and omitted from this request]`
47
+ }
48
+ return `[Image: ${name} (${mediaType})]`
49
+ }
50
+
51
+ /**
52
+ * Request-image target for ClinePass's gateway.
53
+ * @param {Record<string, unknown>} ref
54
+ * @returns {{ width: number, height: number, maxBytes: number }}
55
+ */
56
+ function imageTarget(ref) {
57
+ const width = Math.max(1, Number(ref?.width) || IMAGE_MAX_EDGE)
58
+ const height = Math.max(1, Number(ref?.height) || IMAGE_MAX_EDGE)
59
+ const scale = Math.min(1, IMAGE_MAX_EDGE / Math.max(width, height))
60
+ return {
61
+ width: Math.max(1, Math.round(width * scale)),
62
+ height: Math.max(1, Math.round(height * scale)),
63
+ maxBytes: IMAGE_MAX_BYTES,
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Resolve one image block to a data URL.
69
+ * @param {Record<string, unknown>} block
70
+ * @param {unknown} attachments
71
+ * @param {AbortSignal | undefined} signal
72
+ * @returns {Promise<string>}
73
+ */
74
+ async function imageToUrl(block, attachments, signal) {
75
+ if (block?.offloaded === true) return imagePlaceholder(block)
76
+ const ref = block?.attachment && typeof block.attachment === 'object' ? block.attachment : null
77
+ if (!ref) return imagePlaceholder(block)
78
+ if (!attachments || typeof attachments.readImageRequest !== 'function') {
79
+ return imagePlaceholder(block)
80
+ }
81
+ try {
82
+ const requestImage = await attachments.readImageRequest(ref, imageTarget(ref), signal)
83
+ if (!requestImage?.data) return imagePlaceholder(block)
84
+ const base64 = Buffer.from(requestImage.data).toString('base64')
85
+ return `data:${requestImage.mediaType || ref.mediaType || 'image/png'};base64,${base64}`
86
+ } catch {
87
+ return imagePlaceholder(block)
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Build OpenAI-compatible content for one user/developer message.
93
+ * @param {readonly Record<string, unknown>[]} blocks
94
+ * @param {unknown} attachments
95
+ * @param {AbortSignal | undefined} signal
96
+ * @returns {Promise<string | Record<string, unknown>[]>}
97
+ */
98
+ async function userContent(blocks, attachments, signal) {
99
+ const content = []
100
+ let hasImage = false
101
+ for (const block of Array.isArray(blocks) ? blocks : []) {
102
+ if (!block || typeof block !== 'object') continue
103
+ if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') {
104
+ content.push({ type: 'text', text: block.text })
105
+ } else if (block.type === 'image') {
106
+ hasImage = true
107
+ content.push({ type: 'image_url', image_url: { url: await imageToUrl(block, attachments, signal) } })
108
+ } else if (block.type === 'file') {
109
+ content.push({ type: 'text', text: fileText(block) })
110
+ } else if (block.type === 'tool-result') {
111
+ const resultText = textOf(block.content)
112
+ if (resultText) content.push({ type: 'text', text: resultText })
113
+ }
114
+ }
115
+ if (!hasImage) {
116
+ return content.filter((part) => part.type === 'text').map((part) => part.text).join('\n')
117
+ }
118
+ return content
119
+ }
120
+
121
+ /**
122
+ * Build an assistant message with text, reasoning replay, and tool calls.
123
+ * @param {Record<string, unknown>} message
124
+ * @param {unknown} attachments
125
+ * @param {AbortSignal | undefined} signal
126
+ * @returns {Promise<Record<string, unknown>>}
127
+ */
128
+ async function assistantMessage(message, attachments, signal) {
129
+ const blocks = Array.isArray(message.content) ? message.content : []
130
+ const text = textOf(blocks)
131
+ const reasoning = blocks
132
+ .filter((block) => block && block.type === 'reasoning' && typeof block.text === 'string')
133
+ .map((block) => block.text)
134
+ .join('')
135
+ const toolCalls = blocks
136
+ .filter((block) => block && block.type === 'tool-call')
137
+ .map((block) => ({
138
+ id: String(block.id || ''),
139
+ type: 'function',
140
+ function: {
141
+ name: String(block.name || ''),
142
+ arguments: typeof block.arguments === 'string' ? block.arguments : JSON.stringify(block.arguments ?? {}),
143
+ },
144
+ }))
145
+
146
+ const out = {
147
+ role: 'assistant',
148
+ content: text === '' ? null : text,
149
+ ...(reasoning ? { reasoning_content: reasoning } : {}),
150
+ ...(toolCalls.length ? { tool_calls: toolCalls } : {}),
151
+ }
152
+
153
+ // Some assistant content may include images. ClinePass's chat shape cannot
154
+ // represent assistant-side images, so they become deterministic text.
155
+ for (const block of blocks) {
156
+ if (block?.type === 'image') {
157
+ out.content = `${out.content || ''}${out.content ? '\n' : ''}${imagePlaceholder(block)}`
158
+ }
159
+ }
160
+ return out
161
+ }
162
+
163
+ /**
164
+ * Build the complete OpenAI-compatible payload.
165
+ *
166
+ * @param {{
167
+ * provider: string,
168
+ * model: string,
169
+ * messages: readonly Record<string, unknown>[],
170
+ * system?: string,
171
+ * tools?: readonly Record<string, unknown>[],
172
+ * temperature?: number,
173
+ * maxTokens?: number,
174
+ * stop?: readonly string[],
175
+ * signal?: AbortSignal,
176
+ * }} options
177
+ * @param {{ attachments?: unknown }} [deps]
178
+ * @returns {Promise<{ messages: Record<string, unknown>[], tools: Record<string, unknown>[] }>}
179
+ */
180
+ export async function buildOpenAiPayload(options, { attachments } = {}) {
181
+ const messages = []
182
+
183
+ if (typeof options.system === 'string' && options.system !== '') {
184
+ messages.push({ role: 'system', content: options.system })
185
+ }
186
+
187
+ for (const message of Array.isArray(options.messages) ? options.messages : []) {
188
+ if (!message || typeof message !== 'object') continue
189
+ const role = String(message.role || '')
190
+ const blocks = Array.isArray(message.content) ? message.content : []
191
+
192
+ if (role === 'system') {
193
+ const text = textOf(blocks)
194
+ if (text !== '') messages.push({ role: 'system', content: text })
195
+ continue
196
+ }
197
+ if (role === 'developer') {
198
+ const text = textOf(blocks)
199
+ if (text !== '') messages.push({ role: 'system', content: text })
200
+ continue
201
+ }
202
+ if (role === 'assistant') {
203
+ messages.push(await assistantMessage(message, attachments, options.signal))
204
+ continue
205
+ }
206
+ if (role === 'tool') {
207
+ const callId = message.toolCallId
208
+ || message.source?.callId
209
+ || blocks.find((block) => block?.type === 'tool-result')?.toolCallId
210
+ || ''
211
+ messages.push({
212
+ role: 'tool',
213
+ tool_call_id: String(callId),
214
+ content: textOf(blocks) || (message.isError ? '[tool error]' : ''),
215
+ })
216
+ continue
217
+ }
218
+ // `user` and identity-free request inputs.
219
+ const content = await userContent(blocks, attachments, options.signal)
220
+ messages.push({ role: 'user', content })
221
+ }
222
+
223
+ const tools = (Array.isArray(options.tools) ? options.tools : [])
224
+ .filter((tool) => tool && typeof tool === 'object')
225
+ .map((tool) => ({
226
+ type: 'function',
227
+ function: {
228
+ name: String(tool.name || ''),
229
+ description: String(tool.description || ''),
230
+ parameters: tool.parameters && typeof tool.parameters === 'object'
231
+ ? tool.parameters
232
+ : { type: 'object', properties: {} },
233
+ },
234
+ }))
235
+
236
+ return { messages, tools }
237
+ }