@ljw1004/opencode-trace 0.1.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/LICENSE +21 -0
- package/README.md +17 -0
- package/index.ts +578 -0
- package/package.json +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lucian Wischik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# opencode-trace
|
|
2
|
+
|
|
3
|
+
This opencode plugin lets you see the raw json requests made to the LLM, and the responses.
|
|
4
|
+
* Example: https://ljw1004.github.io/opencode-trace/example.html
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
Add the package to your OpenCode config:
|
|
9
|
+
|
|
10
|
+
```json
|
|
11
|
+
{
|
|
12
|
+
"$schema": "https://opencode.ai/config.json",
|
|
13
|
+
"plugin": ["@ljw1004/opencode-trace"]
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Restart OpenCode and you'll see each transcript stored in `~/opencode-trace`.
|
package/index.ts
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode server plugin which captures raw LLM HTTP payloads to `~/opencode-trace`.
|
|
3
|
+
*
|
|
4
|
+
* Each trace is an interactive html file whose trailing unterminated html-comment contains
|
|
5
|
+
* one json object per line. To read the logs programmatically, strip everything up to that
|
|
6
|
+
* final comment. To browse them interactively, open the html file directly in a browser.
|
|
7
|
+
*
|
|
8
|
+
* The plugin loads `./viewer.js` at runtime and embeds it into each new html trace, while also
|
|
9
|
+
* preferring a sibling `viewer.js` if one exists next to the saved html file.
|
|
10
|
+
*/
|
|
11
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"
|
|
12
|
+
import { createHash } from "node:crypto"
|
|
13
|
+
import os from "node:os"
|
|
14
|
+
import path from "node:path"
|
|
15
|
+
|
|
16
|
+
const root = path.join(os.homedir(), "opencode-trace")
|
|
17
|
+
const PREAMBLE = `<!DOCTYPE html>
|
|
18
|
+
<html>
|
|
19
|
+
<head>
|
|
20
|
+
<style>
|
|
21
|
+
body {font-family: system-ui, -apple-system, sans-serif; margin: 0;}
|
|
22
|
+
body>details {margin-top: 1ex; padding-top: 1ex; border-top: 1px solid lightgray;}
|
|
23
|
+
details {position: relative; padding-left: 1.25em;}
|
|
24
|
+
summary {list-style: none; cursor: pointer;}
|
|
25
|
+
summary::-webkit-details-marker {display: none;}
|
|
26
|
+
summary::before {content: '▷';position: absolute;left: 0;color: #666;}
|
|
27
|
+
details[open]>summary::before {content: '▽';}
|
|
28
|
+
details>div {margin-left: 1.25em;}
|
|
29
|
+
details[open]>summary output {display: none;}
|
|
30
|
+
</style>
|
|
31
|
+
<script src="viewer.js"></script>
|
|
32
|
+
<script>
|
|
33
|
+
if (window.buildNode === undefined) {
|
|
34
|
+
// {viewer.js}
|
|
35
|
+
}
|
|
36
|
+
</script>
|
|
37
|
+
</head>
|
|
38
|
+
<body>
|
|
39
|
+
</body>
|
|
40
|
+
</html>
|
|
41
|
+
${"<!" + "--"}
|
|
42
|
+
`
|
|
43
|
+
|
|
44
|
+
/** Mutable global state: maps OpenCode session ids to the html logfile path for that session. */
|
|
45
|
+
const files = new Map<string, string>()
|
|
46
|
+
|
|
47
|
+
/** Mutable global state: maps `session\nmethod\nurl\n_kind\nmeta|real` to the previous raw body used as the delta base. */
|
|
48
|
+
const prevs = new Map<string, object>()
|
|
49
|
+
|
|
50
|
+
/** Mutable global state: maps OpenCode session ids to the next per-session fetch sequence number. */
|
|
51
|
+
const ids = new Map<string, number>()
|
|
52
|
+
|
|
53
|
+
/** Mutable module state: the unpatched global fetch for this module instance, assigned inside `server()`. */
|
|
54
|
+
let orig: typeof globalThis.fetch | undefined
|
|
55
|
+
|
|
56
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
57
|
+
return !!v && typeof v === "object" && !Array.isArray(v)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Given a parsed LLM request body, returns the first user prompt text if one is present, e.g.
|
|
62
|
+
* {input:[{role:"user",content:[{type:"input_text",text:"why is the sky blue?"}]}]}
|
|
63
|
+
* ==> "why is the sky blue?"
|
|
64
|
+
*/
|
|
65
|
+
function extractPromptFromRequestBody(v: Record<string, unknown>): string | undefined {
|
|
66
|
+
const usable = (text: string | undefined): string | undefined => {
|
|
67
|
+
const trimmed = text?.trim()
|
|
68
|
+
return trimmed && trimmed !== "Generate a title for this conversation:" ? trimmed : undefined
|
|
69
|
+
}
|
|
70
|
+
const text = (part: unknown): string | undefined => {
|
|
71
|
+
if (typeof part === "string") return usable(part)
|
|
72
|
+
if (!isRecord(part)) return
|
|
73
|
+
if (typeof part.text === "string") return usable(part.text)
|
|
74
|
+
if (typeof part.input_text === "string") return usable(part.input_text)
|
|
75
|
+
return undefined
|
|
76
|
+
}
|
|
77
|
+
const content = (v: unknown): string | undefined => {
|
|
78
|
+
if (typeof v === "string") return v
|
|
79
|
+
if (!Array.isArray(v)) return undefined
|
|
80
|
+
for (const part of v) {
|
|
81
|
+
const found = text(part)
|
|
82
|
+
if (found) return found
|
|
83
|
+
}
|
|
84
|
+
return undefined
|
|
85
|
+
}
|
|
86
|
+
const first = (list: unknown, key: "input" | "messages"): string | undefined => {
|
|
87
|
+
if (!Array.isArray(list)) return undefined
|
|
88
|
+
for (const item of list) {
|
|
89
|
+
if (!isRecord(item)) continue
|
|
90
|
+
if (item.role !== "user") continue
|
|
91
|
+
const found = content(item.content) ?? (key === "input" ? text(item) : undefined)
|
|
92
|
+
if (found) return found
|
|
93
|
+
}
|
|
94
|
+
return undefined
|
|
95
|
+
}
|
|
96
|
+
return first(v.input, "input") ?? first(v.messages, "messages") ?? (typeof v.prompt === "string" ? v.prompt : undefined)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Appends one row to the session logfile.
|
|
101
|
+
* If this is the first row for the session, also chooses the filename and writes the html preamble.
|
|
102
|
+
* Side effects: may create directories, create a logfile, mutate `files`, and append to disk.
|
|
103
|
+
*/
|
|
104
|
+
function write(id: string, name: string, row: Record<string, unknown>): void {
|
|
105
|
+
const prev = files.get(id)
|
|
106
|
+
const d = new Date()
|
|
107
|
+
const file = prev ?? path.join(
|
|
108
|
+
root,
|
|
109
|
+
`${d.getFullYear()}.${d.getMonth() + 1}.${d.getDate()} ${d.getHours()}.${d.getMinutes()}.${d.getSeconds()} ${name}.html`,
|
|
110
|
+
)
|
|
111
|
+
mkdirSync(root, { recursive: true })
|
|
112
|
+
if (!existsSync(file)) {
|
|
113
|
+
const html = PREAMBLE.replace(
|
|
114
|
+
"// {viewer.js}",
|
|
115
|
+
readFileSync(new URL("./viewer.js", import.meta.url), "utf8"),
|
|
116
|
+
)
|
|
117
|
+
appendFileSync(
|
|
118
|
+
file,
|
|
119
|
+
html,
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
files.set(id, file)
|
|
123
|
+
appendFileSync(file, `${JSON.stringify(row).replace(/-->/g, "--\\u003e")}\n`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Given two json values, returns a bool for whether they are identical, plus a representation
|
|
128
|
+
* of the difference intended for humans to read.
|
|
129
|
+
*
|
|
130
|
+
* The representation always has the same type as `next`.
|
|
131
|
+
*
|
|
132
|
+
* For changed lists, the representation is either the full new list, or, when shorter,
|
|
133
|
+
* `['...', additions, '---', removals]`.
|
|
134
|
+
*
|
|
135
|
+
* For dicts, removed keys appear as `-k: null`, added keys as `+k: v`, and changed keys as
|
|
136
|
+
* `*k: v`. If a changed dict field is itself a compact list diff, that is rendered as
|
|
137
|
+
* `k+: [...]` and `k-: [...]` for readability.
|
|
138
|
+
*/
|
|
139
|
+
function delta(prev: unknown, next: unknown): [unknown, boolean] {
|
|
140
|
+
const hash = (v: unknown): string => {
|
|
141
|
+
const sort = (v: unknown): unknown => {
|
|
142
|
+
if (Array.isArray(v)) return v.map(sort)
|
|
143
|
+
if (!v || typeof v !== "object") return v
|
|
144
|
+
return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sort((v as Record<string, unknown>)[k])]))
|
|
145
|
+
}
|
|
146
|
+
return createHash("blake2b512").update(JSON.stringify(sort(v))).digest("hex")
|
|
147
|
+
}
|
|
148
|
+
if (isRecord(prev) && isRecord(next)) {
|
|
149
|
+
const out: Record<string, unknown> = {}
|
|
150
|
+
const pk = new Set(Object.keys(prev))
|
|
151
|
+
const nk = new Set(Object.keys(next))
|
|
152
|
+
for (const k of [...pk].filter((k) => !nk.has(k)).sort()) out[`-${k}`] = null
|
|
153
|
+
for (const k of [...nk].filter((k) => !pk.has(k)).sort()) out[`+${k}`] = next[k]
|
|
154
|
+
for (const k of [...pk].filter((k) => nk.has(k)).sort()) {
|
|
155
|
+
const [sub, same] = delta(prev[k], next[k])
|
|
156
|
+
if (same) {
|
|
157
|
+
const raw = JSON.stringify(next[k]) ?? ""
|
|
158
|
+
out[k] =
|
|
159
|
+
raw.length < 128
|
|
160
|
+
? next[k]
|
|
161
|
+
: Array.isArray(next[k])
|
|
162
|
+
? ["..."]
|
|
163
|
+
: isRecord(next[k])
|
|
164
|
+
? { "[unchanged]": "[unchanged]" }
|
|
165
|
+
: typeof next[k] === "string"
|
|
166
|
+
? "[unchanged]"
|
|
167
|
+
: next[k]
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
if (!Array.isArray(sub) || (sub[0] !== "..." && sub[0] !== "---")) {
|
|
171
|
+
out[`*${k}`] = sub
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
const cut = sub.findIndex((item) => item === "---")
|
|
175
|
+
const cut2 = cut === -1 ? undefined : cut
|
|
176
|
+
const add = cut2 === 0 ? [] : cut2 == null ? sub.slice(1) : sub.slice(1, cut2)
|
|
177
|
+
const del = cut2 == null ? [] : sub.slice(cut2 + 1)
|
|
178
|
+
if (del.length > 0) out[`${k}-`] = del
|
|
179
|
+
if (add.length > 0) out[`${k}+`] = add
|
|
180
|
+
}
|
|
181
|
+
return Object.keys(out).length === 0 ? [{ "[repeat]": "[repeat]" }, true] : [out, false]
|
|
182
|
+
}
|
|
183
|
+
if (Array.isArray(prev) && Array.isArray(next)) {
|
|
184
|
+
const left: Array<readonly [unknown, string]> = prev.map((v) => [v, hash(v)] as const)
|
|
185
|
+
let right: Array<readonly [unknown, string]> = next.map((v) => [v, hash(v)] as const)
|
|
186
|
+
const add: unknown[] = []
|
|
187
|
+
const del: unknown[] = []
|
|
188
|
+
for (const [value, sig] of left) {
|
|
189
|
+
const ix = right.findIndex((item) => item[1] === sig)
|
|
190
|
+
if (ix === -1) {
|
|
191
|
+
del.push(value)
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
add.push(...right.slice(0, ix).map((item) => item[0]))
|
|
195
|
+
right = right.slice(ix + 1)
|
|
196
|
+
}
|
|
197
|
+
add.push(...right.map((item) => item[0]))
|
|
198
|
+
if (add.length === 0 && del.length === 0) return [next, true]
|
|
199
|
+
if (add.length + del.length < next.length) {
|
|
200
|
+
return del.length === 0
|
|
201
|
+
? [["...", ...add], false]
|
|
202
|
+
: add.length === 0
|
|
203
|
+
? [["---", ...del], false]
|
|
204
|
+
: [["...", ...add, "---", ...del], false]
|
|
205
|
+
}
|
|
206
|
+
return [next, false]
|
|
207
|
+
}
|
|
208
|
+
return [next, prev === next]
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Given two objects, returns their shallow merge, else just returns the right-hand side. */
|
|
212
|
+
function merge(a: unknown, b: unknown): unknown {
|
|
213
|
+
if (!isRecord(a) || !isRecord(b)) return b
|
|
214
|
+
return { ...a, ...b }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Given an SSE response body, returns parsed `{event?, data}` blocks.
|
|
219
|
+
* Returns undefined if the body is not parseable SSE json.
|
|
220
|
+
*/
|
|
221
|
+
function events(text: string): Array<{ event: string | undefined; data: unknown }> | undefined {
|
|
222
|
+
const out: Array<{ event: string | undefined; data: unknown }> = []
|
|
223
|
+
for (const block of text.split(/\r?\n\r?\n/)) {
|
|
224
|
+
if (!block.trim()) continue
|
|
225
|
+
let name: string | undefined
|
|
226
|
+
const data = block
|
|
227
|
+
.split(/\r?\n/)
|
|
228
|
+
.flatMap((line) => {
|
|
229
|
+
if (line.startsWith("event:")) {
|
|
230
|
+
name = line.slice(6).trim()
|
|
231
|
+
return []
|
|
232
|
+
}
|
|
233
|
+
if (line.startsWith("data:")) return [line.slice(5).trimStart()]
|
|
234
|
+
return []
|
|
235
|
+
})
|
|
236
|
+
.join("\n")
|
|
237
|
+
if (!data || data === "[DONE]") continue
|
|
238
|
+
try {
|
|
239
|
+
out.push({ event: name, data: JSON.parse(data) as unknown })
|
|
240
|
+
} catch {
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return out.length > 0 ? out : undefined
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Given parsed OpenAI `/responses` SSE blocks, reconstructs the final response object. */
|
|
248
|
+
function openaiResponses(list: Array<{ data: unknown }>): Record<string, unknown> {
|
|
249
|
+
let base: Record<string, unknown> = { object: "response" }
|
|
250
|
+
const ids: string[] = []
|
|
251
|
+
const items = new Map<string, Record<string, unknown>>()
|
|
252
|
+
const sums = new Map<string, string>()
|
|
253
|
+
for (const row of list) {
|
|
254
|
+
if (!isRecord(row.data) || typeof row.data.type !== "string") continue
|
|
255
|
+
if (isRecord(row.data.response)) base = merge(base, row.data.response) as Record<string, unknown>
|
|
256
|
+
if (row.data.type === "response.output_item.added" && isRecord(row.data.item) && typeof row.data.item.id === "string") {
|
|
257
|
+
const id = row.data.item.id
|
|
258
|
+
const item = { ...row.data.item }
|
|
259
|
+
if (item.type === "message") item.content = (items.get(id)?.content as unknown[]) ?? []
|
|
260
|
+
if (!ids.includes(id)) ids.push(id)
|
|
261
|
+
items.set(id, item)
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
264
|
+
if (row.data.type === "response.output_text.delta" && typeof row.data.item_id === "string") {
|
|
265
|
+
const prev = items.get(row.data.item_id) ?? { id: row.data.item_id, type: "message", role: "assistant", content: [] }
|
|
266
|
+
const content: unknown[] = Array.isArray(prev.content) ? [...(prev.content as unknown[])] : []
|
|
267
|
+
const last = content[content.length - 1]
|
|
268
|
+
if (isRecord(last) && last.type === "output_text" && typeof last.text === "string") {
|
|
269
|
+
last.text += typeof row.data.delta === "string" ? row.data.delta : ""
|
|
270
|
+
} else {
|
|
271
|
+
content.push({ type: "output_text", text: typeof row.data.delta === "string" ? row.data.delta : "" })
|
|
272
|
+
}
|
|
273
|
+
items.set(row.data.item_id, { ...prev, content })
|
|
274
|
+
if (!ids.includes(row.data.item_id)) ids.push(row.data.item_id)
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
if (row.data.type === "response.function_call_arguments.delta" && typeof row.data.item_id === "string") {
|
|
278
|
+
const prev = items.get(row.data.item_id) ?? { id: row.data.item_id, type: "function_call", arguments: "" }
|
|
279
|
+
items.set(row.data.item_id, {
|
|
280
|
+
...prev,
|
|
281
|
+
arguments: `${typeof prev.arguments === "string" ? prev.arguments : ""}${typeof row.data.delta === "string" ? row.data.delta : ""}`,
|
|
282
|
+
})
|
|
283
|
+
if (!ids.includes(row.data.item_id)) ids.push(row.data.item_id)
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
if (row.data.type === "response.function_call_arguments.done" && typeof row.data.item_id === "string") {
|
|
287
|
+
const prev = items.get(row.data.item_id) ?? { id: row.data.item_id, type: "function_call" }
|
|
288
|
+
items.set(row.data.item_id, {
|
|
289
|
+
...prev,
|
|
290
|
+
arguments: typeof row.data.arguments === "string" ? row.data.arguments : prev.arguments,
|
|
291
|
+
})
|
|
292
|
+
if (!ids.includes(row.data.item_id)) ids.push(row.data.item_id)
|
|
293
|
+
continue
|
|
294
|
+
}
|
|
295
|
+
if (row.data.type === "response.reasoning_summary_text.delta" && typeof row.data.item_id === "string") {
|
|
296
|
+
sums.set(row.data.item_id, `${sums.get(row.data.item_id) ?? ""}${typeof row.data.delta === "string" ? row.data.delta : ""}`)
|
|
297
|
+
continue
|
|
298
|
+
}
|
|
299
|
+
if (row.data.type === "response.output_item.done" && isRecord(row.data.item) && typeof row.data.item.id === "string") {
|
|
300
|
+
const prev = items.get(row.data.item.id) ?? {}
|
|
301
|
+
const next = { ...prev, ...row.data.item }
|
|
302
|
+
if (Array.isArray(prev.content) && !Array.isArray(next.content)) next.content = prev.content
|
|
303
|
+
if (typeof prev.arguments === "string" && typeof next.arguments !== "string") next.arguments = prev.arguments
|
|
304
|
+
items.set(row.data.item.id, next)
|
|
305
|
+
if (!ids.includes(row.data.item.id)) ids.push(row.data.item.id)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const output = ids.map((id) => {
|
|
309
|
+
const item = { ...(items.get(id) ?? { id }) }
|
|
310
|
+
if (item.type === "reasoning" && sums.has(id)) item.summary = [{ type: "summary_text", text: sums.get(id) }]
|
|
311
|
+
return item
|
|
312
|
+
})
|
|
313
|
+
return { ...base, output }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Given parsed OpenAI chat-completions SSE blocks, reconstructs the final completion json. */
|
|
317
|
+
function openaiChat(list: Array<{ data: unknown }>): Record<string, unknown> {
|
|
318
|
+
const choices = new Map<number, Record<string, unknown>>()
|
|
319
|
+
let id = ""
|
|
320
|
+
let model = ""
|
|
321
|
+
let created = 0
|
|
322
|
+
let usage: unknown
|
|
323
|
+
for (const row of list) {
|
|
324
|
+
if (!isRecord(row.data)) continue
|
|
325
|
+
if (typeof row.data.id === "string") id = row.data.id
|
|
326
|
+
if (typeof row.data.model === "string") model = row.data.model
|
|
327
|
+
if (typeof row.data.created === "number") created = row.data.created
|
|
328
|
+
if (isRecord(row.data.usage)) usage = row.data.usage
|
|
329
|
+
if (!Array.isArray(row.data.choices)) continue
|
|
330
|
+
for (const part of row.data.choices) {
|
|
331
|
+
if (!isRecord(part)) continue
|
|
332
|
+
const ix = typeof part.index === "number" ? part.index : 0
|
|
333
|
+
const prev = choices.get(ix) ?? { index: ix, message: { role: "assistant" }, finish_reason: null }
|
|
334
|
+
const msg: Record<string, unknown> = isRecord(prev.message) ? { ...prev.message } : { role: "assistant" }
|
|
335
|
+
if (isRecord(part.delta)) {
|
|
336
|
+
if (typeof part.delta.role === "string") msg.role = part.delta.role
|
|
337
|
+
if (typeof part.delta.content === "string") msg.content = `${typeof msg.content === "string" ? msg.content : ""}${part.delta.content}`
|
|
338
|
+
if (typeof part.delta.reasoning_content === "string") {
|
|
339
|
+
msg.reasoning_content = `${typeof msg.reasoning_content === "string" ? msg.reasoning_content : ""}${part.delta.reasoning_content}`
|
|
340
|
+
}
|
|
341
|
+
if (Array.isArray(part.delta.tool_calls)) {
|
|
342
|
+
const calls: unknown[] = Array.isArray(msg.tool_calls) ? [...(msg.tool_calls as unknown[])] : []
|
|
343
|
+
for (const call of part.delta.tool_calls) {
|
|
344
|
+
if (!isRecord(call)) continue
|
|
345
|
+
const jx = typeof call.index === "number" ? call.index : calls.length
|
|
346
|
+
const prevCall = isRecord(calls[jx])
|
|
347
|
+
? { ...calls[jx] }
|
|
348
|
+
: { index: jx, id: call.id, type: call.type ?? "function", function: { name: "", arguments: "" } }
|
|
349
|
+
const fn = isRecord(prevCall.function) ? { ...prevCall.function } : { name: "", arguments: "" }
|
|
350
|
+
if (isRecord(call.function) && typeof call.function.name === "string") fn.name = call.function.name
|
|
351
|
+
if (isRecord(call.function) && typeof call.function.arguments === "string") {
|
|
352
|
+
fn.arguments = `${typeof fn.arguments === "string" ? fn.arguments : ""}${call.function.arguments}`
|
|
353
|
+
}
|
|
354
|
+
calls[jx] = { ...prevCall, ...call, function: fn }
|
|
355
|
+
}
|
|
356
|
+
msg.tool_calls = calls
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
choices.set(ix, {
|
|
360
|
+
...prev,
|
|
361
|
+
message: msg,
|
|
362
|
+
finish_reason: part.finish_reason ?? prev.finish_reason ?? null,
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
id,
|
|
368
|
+
object: "chat.completion",
|
|
369
|
+
created,
|
|
370
|
+
model,
|
|
371
|
+
choices: [...choices.values()].sort((a, b) => Number(a.index) - Number(b.index)),
|
|
372
|
+
...(usage ? { usage } : {}),
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Given parsed Anthropic SSE blocks, reconstructs the final message json. */
|
|
377
|
+
function anthropic(list: Array<{ data: unknown }>): Record<string, unknown> {
|
|
378
|
+
let base: Record<string, unknown> = { type: "message", role: "assistant" }
|
|
379
|
+
const blocks = new Map<number, Record<string, unknown>>()
|
|
380
|
+
const json = new Map<number, string>()
|
|
381
|
+
for (const row of list) {
|
|
382
|
+
if (!isRecord(row.data) || typeof row.data.type !== "string") continue
|
|
383
|
+
if (row.data.type === "message_start" && isRecord(row.data.message)) {
|
|
384
|
+
base = { ...base, ...row.data.message }
|
|
385
|
+
continue
|
|
386
|
+
}
|
|
387
|
+
if (row.data.type === "content_block_start" && typeof row.data.index === "number" && isRecord(row.data.content_block)) {
|
|
388
|
+
blocks.set(row.data.index, { ...row.data.content_block })
|
|
389
|
+
continue
|
|
390
|
+
}
|
|
391
|
+
if (row.data.type === "content_block_delta" && typeof row.data.index === "number" && isRecord(row.data.delta)) {
|
|
392
|
+
const prev = blocks.get(row.data.index) ?? { type: "text", text: "" }
|
|
393
|
+
if (row.data.delta.type === "text_delta") blocks.set(row.data.index, { ...prev, text: `${typeof prev.text === "string" ? prev.text : ""}${typeof row.data.delta.text === "string" ? row.data.delta.text : ""}` })
|
|
394
|
+
if (row.data.delta.type === "input_json_delta") json.set(row.data.index, `${json.get(row.data.index) ?? ""}${typeof row.data.delta.partial_json === "string" ? row.data.delta.partial_json : ""}`)
|
|
395
|
+
continue
|
|
396
|
+
}
|
|
397
|
+
if (row.data.type === "message_delta") {
|
|
398
|
+
if (isRecord(row.data.delta)) base = { ...base, ...row.data.delta }
|
|
399
|
+
if (isRecord(row.data.usage)) base.usage = merge(base.usage, row.data.usage)
|
|
400
|
+
continue
|
|
401
|
+
}
|
|
402
|
+
if (isRecord(row.data.usage)) base.usage = merge(base.usage, row.data.usage)
|
|
403
|
+
}
|
|
404
|
+
const content = [...blocks.entries()]
|
|
405
|
+
.sort((a, b) => a[0] - b[0])
|
|
406
|
+
.map(([ix, block]) => {
|
|
407
|
+
if (block.type !== "tool_use" || !json.has(ix)) return block
|
|
408
|
+
const raw = json.get(ix) ?? ""
|
|
409
|
+
try {
|
|
410
|
+
return { ...block, input: JSON.parse(raw) as unknown }
|
|
411
|
+
} catch {
|
|
412
|
+
return { ...block, input: raw }
|
|
413
|
+
}
|
|
414
|
+
})
|
|
415
|
+
return { ...base, content }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Given a raw response body and url, returns the final json to log.
|
|
420
|
+
* Plain json is returned directly; known SSE formats are consolidated; failures become `{_body}`.
|
|
421
|
+
*/
|
|
422
|
+
function responseAsJson(text: string, url: string): Record<string, unknown> {
|
|
423
|
+
try {
|
|
424
|
+
const body = JSON.parse(text) as unknown
|
|
425
|
+
return isRecord(body) ? body : { _body: text }
|
|
426
|
+
} catch {
|
|
427
|
+
const list = events(text)
|
|
428
|
+
if (!list) return { _body: text }
|
|
429
|
+
const path = ((): string => {
|
|
430
|
+
try {
|
|
431
|
+
return new URL(url).pathname
|
|
432
|
+
} catch {
|
|
433
|
+
return url
|
|
434
|
+
}
|
|
435
|
+
})()
|
|
436
|
+
const first = list[0]?.data
|
|
437
|
+
if (path.endsWith("/responses") || (isRecord(first) && typeof first.type === "string" && first.type.startsWith("response."))) {
|
|
438
|
+
return openaiResponses(list)
|
|
439
|
+
}
|
|
440
|
+
if (path.endsWith("/chat/completions") || (isRecord(first) && first.object === "chat.completion.chunk")) {
|
|
441
|
+
return openaiChat(list)
|
|
442
|
+
}
|
|
443
|
+
if (path.endsWith("/messages") || (isRecord(first) && typeof first.type === "string" && (first.type === "message_start" || first.type === "content_block_start"))) {
|
|
444
|
+
return anthropic(list)
|
|
445
|
+
}
|
|
446
|
+
return { _body: text }
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Intercepts matching OpenCode LLM fetches and logs request/response rows.
|
|
452
|
+
* Side effects: mutates `prevs`, mutates `ids`, and writes logs to disk.
|
|
453
|
+
*/
|
|
454
|
+
async function tracedFetch(
|
|
455
|
+
input: Parameters<typeof globalThis.fetch>[0],
|
|
456
|
+
init?: Parameters<typeof globalThis.fetch>[1],
|
|
457
|
+
): Promise<Response> {
|
|
458
|
+
const now = (): string => new Date().toISOString()
|
|
459
|
+
const error = (err: unknown): { _error: string; _stack?: string } =>
|
|
460
|
+
err instanceof Error
|
|
461
|
+
? err.stack === undefined
|
|
462
|
+
? { _error: err.message }
|
|
463
|
+
: { _error: err.message, _stack: err.stack }
|
|
464
|
+
: { _error: String(err) }
|
|
465
|
+
|
|
466
|
+
const req = new Request(input, init)
|
|
467
|
+
const session = req.headers.get("x-opencode-session") ?? req.headers.get("x-session-affinity") ?? req.headers.get("session_id") ?? undefined;
|
|
468
|
+
if (session === undefined) return orig!(input,init);
|
|
469
|
+
|
|
470
|
+
const text = await req.clone().text().catch(() => "")
|
|
471
|
+
const raw = ((): Record<string, unknown> => {
|
|
472
|
+
try {
|
|
473
|
+
const body = JSON.parse(text) as unknown
|
|
474
|
+
return isRecord(body) ? body : { _body: text }
|
|
475
|
+
} catch {
|
|
476
|
+
return { _body: text }
|
|
477
|
+
}
|
|
478
|
+
})()
|
|
479
|
+
const title = isRecord(raw) && typeof raw._body !== "string" ? extractPromptFromRequestBody(raw) : undefined
|
|
480
|
+
const purpose = isRecord(raw) && Array.isArray(raw.tools) && raw.tools.length > 0 ? '' : '[meta]';
|
|
481
|
+
const seq = (ids.get(session) ?? 0) + 1
|
|
482
|
+
ids.set(session, seq)
|
|
483
|
+
const common = { _id: seq, _purpose: purpose, _url: req.url }
|
|
484
|
+
const name = (title ?? session ?? '')
|
|
485
|
+
.replace(/[^A-Za-z0-9 _-]+/g, " ")
|
|
486
|
+
.trim()
|
|
487
|
+
.split(/\s+/)
|
|
488
|
+
.slice(0, 10)
|
|
489
|
+
.join(" ")
|
|
490
|
+
.slice(0, 50)
|
|
491
|
+
.trim() || "session";
|
|
492
|
+
const requestKey = `${session}\n${req.method}\n${req.url}\nrequest\n${purpose}`
|
|
493
|
+
const requestNext = raw as object
|
|
494
|
+
const [requestRow] = delta(prevs.get(requestKey), requestNext)
|
|
495
|
+
prevs.set(requestKey, requestNext)
|
|
496
|
+
write(session, name, {
|
|
497
|
+
...(requestRow as Record<string, unknown>),
|
|
498
|
+
...common,
|
|
499
|
+
_kind: "request",
|
|
500
|
+
_ts: now(),
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
const res = await orig!(req).catch((err) => {
|
|
504
|
+
write(session, name, {
|
|
505
|
+
...common,
|
|
506
|
+
_kind: "error",
|
|
507
|
+
_ts: now(),
|
|
508
|
+
...error(err),
|
|
509
|
+
})
|
|
510
|
+
throw err
|
|
511
|
+
});
|
|
512
|
+
// We'll register background processing of the response, once it comes. But return 'res' immediately.
|
|
513
|
+
void res
|
|
514
|
+
.clone()
|
|
515
|
+
.text()
|
|
516
|
+
.then((body) => {
|
|
517
|
+
const json = responseAsJson(body, req.url)
|
|
518
|
+
const detail = isRecord(json) && isRecord(json.error) && typeof json.error.message === "string"
|
|
519
|
+
? json.error.message
|
|
520
|
+
: isRecord(json) && typeof json.error === "string"
|
|
521
|
+
? json.error
|
|
522
|
+
: `${res.status} ${res.statusText}`
|
|
523
|
+
const responseNext = (!res.ok
|
|
524
|
+
? { ...json, _status: res.status, _status_text: res.statusText, _error: detail }
|
|
525
|
+
: json) as object
|
|
526
|
+
const responseKey = `${session}\n${req.method}\n${req.url}\nresponse\n${purpose}`
|
|
527
|
+
const [responseRow] = delta(prevs.get(responseKey), responseNext)
|
|
528
|
+
prevs.set(responseKey, responseNext)
|
|
529
|
+
write(session, name, {
|
|
530
|
+
...(responseRow as Record<string, unknown>),
|
|
531
|
+
...common,
|
|
532
|
+
_kind: "response",
|
|
533
|
+
_ts: now(),
|
|
534
|
+
})
|
|
535
|
+
})
|
|
536
|
+
.catch((err) => {
|
|
537
|
+
write(session, name, {
|
|
538
|
+
...common,
|
|
539
|
+
_kind: "error",
|
|
540
|
+
_ts: now(),
|
|
541
|
+
...error(err),
|
|
542
|
+
})
|
|
543
|
+
})
|
|
544
|
+
return res
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export default {
|
|
548
|
+
id: "ljw.opencode-trace",
|
|
549
|
+
async server(): Promise<object> {
|
|
550
|
+
// OpenCode loads this module with dynamic import() when plugin state is initialized for
|
|
551
|
+
// an instance/directory. In current OpenCode this module import is normally cached, so
|
|
552
|
+
// top-level state like `orig`, `files`, and `prevs` survives repeated hook initialization.
|
|
553
|
+
//
|
|
554
|
+
// OpenCode then calls `server()` when it initializes this plugin's server hooks for that
|
|
555
|
+
// instance. That can happen more than once per process across instance reload/dispose, so
|
|
556
|
+
// the fetch patch must be guarded even though the module itself is usually only loaded once.
|
|
557
|
+
if (!orig) {
|
|
558
|
+
orig = globalThis.fetch.bind(globalThis)
|
|
559
|
+
globalThis.fetch = tracedFetch
|
|
560
|
+
}
|
|
561
|
+
return {}
|
|
562
|
+
},
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// For opencode versions prior to 1.4, it doesn't get picked up automatically from the plugins
|
|
566
|
+
// directory so you have to add this to your ~/.config/opencode/opencode.json
|
|
567
|
+
// "plugin": [
|
|
568
|
+
// "file:///path/to/.config/opencode/plugins/index.ts"
|
|
569
|
+
// ]
|
|
570
|
+
//
|
|
571
|
+
// And it uses a different default export:
|
|
572
|
+
// export default async function opencodeTracePlugin() {
|
|
573
|
+
// if (!orig) {
|
|
574
|
+
// orig = globalThis.fetch.bind(globalThis);
|
|
575
|
+
// globalThis.fetch = tracedFetch;
|
|
576
|
+
// }
|
|
577
|
+
// return {}
|
|
578
|
+
// }
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ljw1004/opencode-trace",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode plugin that saves raw json LLM request+responses as jsonl in ~/opencode-trace, with built-in HTML interactive viewer",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": "./index.ts",
|
|
8
|
+
"main": "./index.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"index.ts",
|
|
11
|
+
"viewer.js",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/ljw1004/opencode-trace.git"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/ljw1004/opencode-trace#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/ljw1004/opencode-trace/issues"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"opencode",
|
|
25
|
+
"opencode-plugin",
|
|
26
|
+
"trace",
|
|
27
|
+
"llm"
|
|
28
|
+
],
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"lint": "eslint index.ts",
|
|
34
|
+
"typecheck": "tsc"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@eslint/js": "9.24.0",
|
|
38
|
+
"@tsconfig/node22": "22.0.2",
|
|
39
|
+
"@types/node": "22.13.9",
|
|
40
|
+
"eslint": "9.24.0",
|
|
41
|
+
"typescript-eslint": "8.29.0",
|
|
42
|
+
"typescript": "5.8.2"
|
|
43
|
+
}
|
|
44
|
+
}
|