opencode-agent-flow 0.0.0-stage → 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/CONTRIBUTING.md +3 -0
- package/LICENSE +21 -0
- package/README.md +100 -2
- package/SECURITY.md +3 -0
- package/bin/cli.mjs +52 -0
- package/docs/architecture.md +58 -0
- package/docs/opencode-v2-api-notes.md +89 -0
- package/docs/screenshot.png +0 -0
- package/lib/demo.mjs +10 -0
- package/lib/privacy.mjs +9 -0
- package/lib/upstream.mjs +190 -0
- package/package.json +15 -4
- package/prices.json +10 -0
- package/public/app.js +223 -0
- package/public/flow-webgl.js +155 -0
- package/public/index.html +10 -0
- package/public/style.css +6 -0
- package/public/vendor/three/LICENSE +21 -0
- package/public/vendor/three/postprocessing/EffectComposer.js +363 -0
- package/public/vendor/three/postprocessing/MaskPass.js +195 -0
- package/public/vendor/three/postprocessing/OutputPass.js +139 -0
- package/public/vendor/three/postprocessing/Pass.js +191 -0
- package/public/vendor/three/postprocessing/RenderPass.js +183 -0
- package/public/vendor/three/postprocessing/ShaderPass.js +135 -0
- package/public/vendor/three/postprocessing/UnrealBloomPass.js +492 -0
- package/public/vendor/three/shaders/CopyShader.js +52 -0
- package/public/vendor/three/shaders/LuminosityHighPassShader.js +68 -0
- package/public/vendor/three/shaders/OutputShader.js +103 -0
- package/public/vendor/three/three.core.min.js +6 -0
- package/public/vendor/three/three.module.min.js +6 -0
- package/server.mjs +217 -0
package/CONTRIBUTING.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 orybakov
|
|
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
CHANGED
|
@@ -1,3 +1,101 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-agent-flow
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A live, read-only visualization of OpenCode V2 orchestration. See agent trees,
|
|
4
|
+
grouped parallel calls, current tools, approval waits, token usage and estimated
|
|
5
|
+
costs in one WebGL dashboard. Connect to a local or remote OpenCode server using
|
|
6
|
+
only its public HTTP API and SSE—no database access required.
|
|
7
|
+
|
|
8
|
+
Zero runtime npm dependencies. Node.js 20+; no frontend build step.
|
|
9
|
+
|
|
10
|
+

|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npx opencode-agent-flow
|
|
16
|
+
# Protected server: supply the password through your environment, not CLI flags.
|
|
17
|
+
OPENCODE_SERVER_PASSWORD="$YOUR_OPENCODE_PASSWORD" npx opencode-agent-flow
|
|
18
|
+
# Remote server
|
|
19
|
+
OPENCODE_SERVER_PASSWORD="$YOUR_OPENCODE_PASSWORD" npx opencode-agent-flow --server https://opencode.example.org
|
|
20
|
+
# Fictional data, no server required
|
|
21
|
+
npx opencode-agent-flow --demo --open
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Open <http://127.0.0.1:3340>. For an unpublished local checkout:
|
|
25
|
+
`node bin/cli.mjs --demo`.
|
|
26
|
+
|
|
27
|
+
## Options
|
|
28
|
+
|
|
29
|
+
| Flag | Environment fallback | Default |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| `--server URL` | `OPENCODE_URL` | `http://127.0.0.1:4096` |
|
|
32
|
+
| `--username NAME` | `OPENCODE_SERVER_USERNAME` | `opencode` |
|
|
33
|
+
| `--port NUMBER` | `PORT` | `3340` |
|
|
34
|
+
| `--host ADDRESS` | — | `127.0.0.1` |
|
|
35
|
+
| `--allowed-host HOST` (repeatable) | — | Additional allowed hostname |
|
|
36
|
+
| `--window HOURS` | `WINDOW_HOURS` | `4` |
|
|
37
|
+
| `--open` | — | Open the browser |
|
|
38
|
+
| `--demo` | — | Built-in fictional data |
|
|
39
|
+
| `--help` | — | Show usage |
|
|
40
|
+
|
|
41
|
+
`OPENCODE_SERVER_PASSWORD` is accepted **only through the environment**. Without
|
|
42
|
+
it the BFF tries unauthenticated access; rejected access leaves the UI connecting
|
|
43
|
+
while the backend retries.
|
|
44
|
+
|
|
45
|
+
## How it works
|
|
46
|
+
|
|
47
|
+
A thin Node backend-for-frontend (BFF) loads paginated session/message history,
|
|
48
|
+
keeps one global upstream SSE subscription, and sends aggregated snapshots to
|
|
49
|
+
the browser. Reconnects use exponential backoff (1–30 seconds), reset only after
|
|
50
|
+
a connection lasts at least 30 seconds. Full resynchronization is limited to
|
|
51
|
+
once per 10 seconds; initial connection failures retry with the same backoff.
|
|
52
|
+
A periodic reconciliation (every 60 seconds) catches missed events. Permissions and forms are
|
|
53
|
+
polled for each active location. All/session modes, grouped `×N` agents and
|
|
54
|
+
individual-call popovers preserve the orchestration hierarchy.
|
|
55
|
+
|
|
56
|
+
`N agents · M calls` counts visible groups and all sessions respectively, each
|
|
57
|
+
including the root. The recent activity window applies to root selection; explicit
|
|
58
|
+
session mode loads the complete selected tree, including dormant children.
|
|
59
|
+
|
|
60
|
+
## Security and compatibility
|
|
61
|
+
|
|
62
|
+
- Loopback binding by default. **The UI has no authentication**; exposing it on
|
|
63
|
+
another interface reveals session titles, paths and activity. Use an authenticated
|
|
64
|
+
reverse proxy for shared deployments.
|
|
65
|
+
- The upstream password stays in Node, never in browser responses or URLs.
|
|
66
|
+
Use HTTPS to protect Basic authentication to remote servers.
|
|
67
|
+
- Host/Origin checks reject unapproved hostnames (DNS-rebinding protection).
|
|
68
|
+
Extra hostnames require `--allowed-host`; the configured port is enforced.
|
|
69
|
+
- The UI displays session titles, commands and paths. Secret redaction is
|
|
70
|
+
**best-effort**, not a guarantee. Do not expose it to a network without a
|
|
71
|
+
reverse proxy providing authentication. Report vulnerabilities via
|
|
72
|
+
[GitHub Security Advisories](https://github.com/orybakov/opencode-agent-flow/security/advisories/new).
|
|
73
|
+
- Tested against **OpenCode 2.0.30**. The V2 HTTP API is **experimental**; future
|
|
74
|
+
changes may require adapter updates. Non-V2 servers remain in connecting state.
|
|
75
|
+
- SSE is live-only, without replay. Reconciliation recovers durable snapshots,
|
|
76
|
+
not every transient event. No historical permission-decision journal is available.
|
|
77
|
+
- Token/cost panels use available history (at least 12 hours/current day for
|
|
78
|
+
default aggregates). Context size is approximate, not an exact provider meter.
|
|
79
|
+
|
|
80
|
+
## Cost estimates
|
|
81
|
+
|
|
82
|
+
`prices.json` supplies editable USD-per-million input/output/cache-read rates and
|
|
83
|
+
context limits. The UI labels costs **`≈ est.`**: pricing may be outdated, provider
|
|
84
|
+
subscriptions may differ, and these figures are not billing statements.
|
|
85
|
+
|
|
86
|
+
## Development
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
node bin/cli.mjs --demo
|
|
90
|
+
node --test
|
|
91
|
+
npm pack --dry-run
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
See [architecture](docs/architecture.md) and
|
|
95
|
+
[public API notes](docs/opencode-v2-api-notes.md). Vendored browser modules avoid
|
|
96
|
+
CDN/network dependencies; there is no install or bundling step.
|
|
97
|
+
|
|
98
|
+
## License and acknowledgements
|
|
99
|
+
|
|
100
|
+
MIT © 2026 orybakov. WebGL rendering uses [three.js](https://threejs.org/),
|
|
101
|
+
vendored under its MIT license in `public/vendor/three/LICENSE`.
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
Please report vulnerabilities privately using [GitHub Security Advisories](https://github.com/orybakov/opencode-agent-flow/security/advisories/new). Include reproduction steps and the affected version. Do not include credentials or private session data in public issues.
|
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { start } from '../server.mjs';
|
|
4
|
+
process.on('unhandledRejection', error => console.error('Unhandled rejection:', error?.name || 'Error'));
|
|
5
|
+
process.on('uncaughtException', error => console.error('Uncaught exception:', error?.name || 'Error'));
|
|
6
|
+
|
|
7
|
+
const help = `opencode-agent-flow 0.1.0 — live OpenCode V2 agent visualization
|
|
8
|
+
|
|
9
|
+
Usage: npx opencode-agent-flow [options]
|
|
10
|
+
--server URL OpenCode V2 URL (OPENCODE_URL; http://127.0.0.1:4096)
|
|
11
|
+
--username NAME Basic auth username (OPENCODE_SERVER_USERNAME; opencode)
|
|
12
|
+
--port NUMBER UI port (PORT; 3340)
|
|
13
|
+
--host ADDRESS UI bind address (127.0.0.1)
|
|
14
|
+
--allowed-host HOST Allow an additional Host hostname (repeatable)
|
|
15
|
+
--window HOURS Recent activity window (WINDOW_HOURS; 4)
|
|
16
|
+
--open Open the UI in your default browser
|
|
17
|
+
--demo Use built-in fictional data; no upstream connection
|
|
18
|
+
--help, -h Show this help
|
|
19
|
+
|
|
20
|
+
Password: OPENCODE_SERVER_PASSWORD environment variable only.
|
|
21
|
+
The UI has no authentication. Keep the default loopback bind address.`;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const config = { server: process.env.OPENCODE_URL || 'http://127.0.0.1:4096', username: process.env.OPENCODE_SERVER_USERNAME || 'opencode', password: process.env.OPENCODE_SERVER_PASSWORD, port: process.env.PORT || 3340, host: '127.0.0.1', window: process.env.WINDOW_HOURS || 4, open: false, demo: false };
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
for (let i = 0; i < args.length; i++) {
|
|
27
|
+
const [flag, inline] = args[i].split(/=(.*)/s);
|
|
28
|
+
if (['--help', '-h'].includes(flag)) { console.log(help); process.exit(0); }
|
|
29
|
+
if (['--open', '--demo'].includes(flag)) { if (inline !== undefined) throw new Error(`${flag} does not take a value.`); config[flag.slice(2)] = true; continue; }
|
|
30
|
+
if (!['--server', '--username', '--port', '--host', '--window', '--allowed-host'].includes(flag)) throw new Error('Unknown option. Use --help. Passwords are accepted only via OPENCODE_SERVER_PASSWORD.');
|
|
31
|
+
const value = inline ?? args[++i];
|
|
32
|
+
if (!value || value.startsWith('--')) throw new Error(`Missing value for ${flag}.`);
|
|
33
|
+
if (flag === '--allowed-host') (config.allowedHosts ||= []).push(value); else config[flag.slice(2)] = value;
|
|
34
|
+
}
|
|
35
|
+
config.port = Number(config.port); config.window = Number(config.window);
|
|
36
|
+
if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) throw new Error('--port must be an integer from 1 to 65535.');
|
|
37
|
+
if (!Number.isFinite(config.window) || config.window <= 0) throw new Error('--window must be a positive number of hours.');
|
|
38
|
+
let url;
|
|
39
|
+
try { url = new URL(config.server); } catch { throw new Error('--server must be a valid HTTP(S) URL.'); }
|
|
40
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('--server must be HTTP(S), without credentials, query or fragment.');
|
|
41
|
+
config.server = url.href.replace(/\/$/, '');
|
|
42
|
+
if (config.host === '[::1]') config.host = '::1';
|
|
43
|
+
if (!['127.0.0.1', 'localhost', '::1'].includes(config.host)) console.error('WARNING: UI has no authentication and exposes session data. Non-loopback bind requested.');
|
|
44
|
+
if (config.password && url.protocol === 'http:' && !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) console.error('WARNING: use HTTPS for a remote OpenCode server; Basic authentication is not encrypted over HTTP.');
|
|
45
|
+
await start(config);
|
|
46
|
+
if (config.open) {
|
|
47
|
+
const target = `http://${config.host.includes(':') ? `[${config.host}]` : config.host}:${config.port}`;
|
|
48
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open';
|
|
49
|
+
const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', target] : [target];
|
|
50
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' }); child.on('error', () => console.error('Unable to open browser; open the UI URL manually.')); child.unref();
|
|
51
|
+
}
|
|
52
|
+
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
Agent Flow is a read-only backend-for-frontend (BFF) for OpenCode V2. Node.js
|
|
4
|
+
20+ serves static browser assets and aggregates the public HTTP API in memory.
|
|
5
|
+
There is no database access, build step, or runtime npm dependency.
|
|
6
|
+
|
|
7
|
+
## Data lifecycle
|
|
8
|
+
|
|
9
|
+
1. Check `/api/info` for a V2 server version. Authenticate with Basic only when
|
|
10
|
+
an environment password is configured. Credentials never reach the browser.
|
|
11
|
+
2. Open one global `/api/event` SSE subscription before loading history.
|
|
12
|
+
3. Page `/api/session` descending by `time.updated`, stopping locally at the
|
|
13
|
+
aggregation cutoff: the earliest of the activity window, 12 hours, and midnight.
|
|
14
|
+
4. Recover missing ancestors and recursively enumerate children of active roots,
|
|
15
|
+
including dormant children. Fetch message pages with concurrency capped at four.
|
|
16
|
+
Messages have no time filter; pagination stops locally after an old page.
|
|
17
|
+
5. Semantic SSE events mark affected sessions dirty. Coalesced GET requests replace
|
|
18
|
+
authoritative session/message snapshots, avoiding double-counted usage and
|
|
19
|
+
duplicated tool events. Reconnect uses exponential backoff (1–30 seconds), then
|
|
20
|
+
resynchronizes snapshots. A 60-second reconciliation covers missed events.
|
|
21
|
+
6. Poll pending permissions and forms every five seconds for every known active
|
|
22
|
+
session directory. Locations refer to the upstream server, not the BFF machine.
|
|
23
|
+
|
|
24
|
+
The selected-session endpoint lazily loads the complete descendant history,
|
|
25
|
+
including sessions outside the activity window. Default aggregates include only
|
|
26
|
+
the aggregation horizon; session-scoped aggregates include the selected tree.
|
|
27
|
+
The in-memory cache lasts for the process lifetime; no prompts or tool results
|
|
28
|
+
are written to disk. Unknown events are ignored or reconciled by snapshots.
|
|
29
|
+
|
|
30
|
+
## Browser contract
|
|
31
|
+
|
|
32
|
+
- `GET /api/state[?session=ID]`: roots, trees, grouped agents, last 200 events,
|
|
33
|
+
token aggregates, pending approvals, permission health, upstream version/status.
|
|
34
|
+
- `GET /api/sessions`: recent root session picker summaries.
|
|
35
|
+
- `GET /api/stream[?session=ID]`: `event: state` snapshots and 15-second heartbeats.
|
|
36
|
+
- `GET /api/permission/request`: normalized pending permissions/forms.
|
|
37
|
+
|
|
38
|
+
Children with the same nonempty agent name under one parent form a group (`×N`).
|
|
39
|
+
Unnamed sessions retain distinct identities; empty unnamed children are hidden.
|
|
40
|
+
Group status precedence is waiting, working, idle, done. `agentCount` counts visible
|
|
41
|
+
groups including the root; `callCount` counts all descendant sessions including
|
|
42
|
+
the root. Group popovers preserve individual sessions and token totals.
|
|
43
|
+
|
|
44
|
+
Statuses use active-session snapshots, running tools, pending permissions/forms,
|
|
45
|
+
and final idle messages. Context is the latest assistant input plus cache read and
|
|
46
|
+
write; missing current usage is marked estimated. Costs use `prices.json` and are
|
|
47
|
+
always approximate (`≈ est.`), not provider billing.
|
|
48
|
+
|
|
49
|
+
## Rendering and security
|
|
50
|
+
|
|
51
|
+
Vanilla browser modules render all/session modes, event log, hourly token chart,
|
|
52
|
+
cache ratio, activity heatmap, estimated cost, ledger and accessible group popovers.
|
|
53
|
+
Vendored three.js powers WebGL flow connections with a Canvas fallback.
|
|
54
|
+
|
|
55
|
+
Static assets use an explicit allowlist. The BFF exposes only GET routes, not a
|
|
56
|
+
general upstream proxy. Loopback is the default; non-loopback binding warns that
|
|
57
|
+
the UI has no authentication. Use HTTPS for remote upstream Basic authentication.
|
|
58
|
+
See [API contract notes](opencode-v2-api-notes.md) for compatibility limitations.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# OpenCode V2 public API: contributor notes
|
|
2
|
+
|
|
3
|
+
Contract verified against OpenCode **2.0.30**. The server describes this HTTP API
|
|
4
|
+
as experimental. Version checks reject non-V2 servers but do not guarantee that
|
|
5
|
+
every future V2 revision preserves this contract.
|
|
6
|
+
|
|
7
|
+
## HTTP snapshots
|
|
8
|
+
|
|
9
|
+
| Endpoint | Response and important details |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| `/api/info` | Server `version`; OpenAPI info.version is not the server version |
|
|
12
|
+
| `/api/session?order=desc&limit=100` | `{data: Session[], cursor:{previous,next}}`, ordered by time.updated then ID |
|
|
13
|
+
| `/api/session?parentID=ID` | Children, also paginated; parentID=null lists roots |
|
|
14
|
+
| `/api/session/ID` | `{data: Session}` |
|
|
15
|
+
| `/api/session/ID/message?order=desc&limit=100` | `{data: Message[],cursor}`, sequence order within a session |
|
|
16
|
+
| `/api/session/active` | `{data: {sessionID: {type:"running"}}}` |
|
|
17
|
+
| `/api/permission/request?location[directory]=PATH` | `{location,data: Permission[]}` |
|
|
18
|
+
| `/api/form?location[directory]=PATH` | `{location,data: Form[]}` |
|
|
19
|
+
|
|
20
|
+
Subsequent pages use opaque `cursor` **without order**. Stop on empty pages as
|
|
21
|
+
well as null cursors: the final nonempty page may still contain a next cursor.
|
|
22
|
+
There are no session/message time filters. Apply cutoffs locally. Archived
|
|
23
|
+
sessions are not automatically excluded. A session directory is
|
|
24
|
+
`session.location.directory`; parentID is a tree edge, while fork is not.
|
|
25
|
+
Old descendants must be loaded independently of an updated-time cutoff.
|
|
26
|
+
|
|
27
|
+
Messages contain `id`, `type`, `time.created`, and assistant `content`, `model`,
|
|
28
|
+
`tokens`, optional `finish`, `time.streamed/completed`. Tool content contains
|
|
29
|
+
`id`, `name`, `state.input/status`, `time.created/ran/completed`. Statuses are
|
|
30
|
+
streaming, running, completed, error; streaming input can be a JSON string.
|
|
31
|
+
Unfiltered history includes idle messages, although `type=idle` is rejected.
|
|
32
|
+
The public API does not expose message row update timestamps or sequence fields.
|
|
33
|
+
|
|
34
|
+
Tokens are `{input,output,reasoning,cache:{read,write}}`. Session token totals are
|
|
35
|
+
cumulative: replace them, never add successive usage snapshots. A latest
|
|
36
|
+
assistant token snapshot approximates context size. Model cost can be zero;
|
|
37
|
+
local pricing is an estimate, not billing.
|
|
38
|
+
|
|
39
|
+
Permissions and forms are **location-scoped**. Omitting location only accesses
|
|
40
|
+
the server's default directory, not every session. Permission resources use
|
|
41
|
+
`resources: string[]`; forms contain id, sessionID, title and fields. Pending
|
|
42
|
+
forms count as waiting too. Session-scoped alternatives exist at
|
|
43
|
+
`/api/session/ID/permission` and `/api/session/ID/form`.
|
|
44
|
+
|
|
45
|
+
## Global event stream
|
|
46
|
+
|
|
47
|
+
`GET /api/event` covers all locations with one subscription. Frames use `data:`
|
|
48
|
+
containing JSON `{id,created,type,location?,data,durable?}`. The first frame is
|
|
49
|
+
`server.connected`. Heartbeats are comments. There is no documented replay,
|
|
50
|
+
time filter, cursor, or Last-Event-ID recovery; JSON event IDs do not imply replay.
|
|
51
|
+
Slow readers may be disconnected. The BFF must reconnect and reload snapshots.
|
|
52
|
+
|
|
53
|
+
Relevant event families include:
|
|
54
|
+
|
|
55
|
+
- session.created/deleted/renamed/moved, agent.selected/model.selected
|
|
56
|
+
- session.step.started/streamed/ended/failed and session.usage.updated
|
|
57
|
+
- session.tool.input.started/delta/ended, tool.called/progress/success/failed
|
|
58
|
+
- session.text.started/delta/ended, reasoning.started/delta/ended
|
|
59
|
+
- session.execution.started/succeeded/failed/interrupted, session.status/idle
|
|
60
|
+
- session.inbox.*, permission.asked/replied, form.created/replied/cancelled
|
|
61
|
+
|
|
62
|
+
Most data carries `sessionID`; assistant events carry `assistantMessageID` and
|
|
63
|
+
tool events carry tool `id`. form.created nests its session ID in `data.form`.
|
|
64
|
+
V1-style message.updated and message.part.updated events are not available.
|
|
65
|
+
Invalidating per-session snapshots avoids implementing a fragile full event store.
|
|
66
|
+
Do not log reasoning, prompts, tool outputs, form answers or authorization headers.
|
|
67
|
+
|
|
68
|
+
The experimental session log endpoint did not yield historical events in the
|
|
69
|
+
verified deployment (only log.synced); it is not used as a recovery guarantee.
|
|
70
|
+
Historical permission decisions and transient streaming deltas are not recoverable
|
|
71
|
+
from standard snapshots. Exact message row update timestamps cannot be reproduced.
|
|
72
|
+
|
|
73
|
+
## Authentication and portability
|
|
74
|
+
|
|
75
|
+
Basic authentication works with the configured server username/password. A
|
|
76
|
+
protected server returns 401 without credentials. Bearer authentication is not
|
|
77
|
+
equivalent. Keep credentials in environment variables and use HTTPS remotely.
|
|
78
|
+
Native browser EventSource cannot set Authorization, and arbitrary origins may
|
|
79
|
+
not be allowed by upstream CORS. The same-origin Node BFF avoids both issues.
|
|
80
|
+
It never forwards credentials to browser clients and performs only GET requests.
|
|
81
|
+
|
|
82
|
+
Verified against 2.0.30: version, session/active snapshots, pagination, idle
|
|
83
|
+
messages, location-scoped pending requests, Basic authentication and a live global
|
|
84
|
+
stream containing multiple locations. Remote deployments and future versions
|
|
85
|
+
require additional verification; capability changes should fail clearly.
|
|
86
|
+
|
|
87
|
+
Sources: [API](https://opencode.ai/v2/docs/api),
|
|
88
|
+
[client](https://opencode.ai/v2/docs/build/client), published
|
|
89
|
+
`@opencode/client@2.0.30` event types, and live read-only HTTP/SSE observations.
|
|
Binary file
|
package/lib/demo.mjs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function demo() {
|
|
2
|
+
const now = Date.now(), sessions = new Map(), messages = new Map();
|
|
3
|
+
const agents = ['lead', 'builder', 'researcher', 'reviewer', 'builder', 'tester', 'designer'];
|
|
4
|
+
agents.forEach((agent, i) => {
|
|
5
|
+
const id = `ses_demo${i}`, model = { id: ['gpt-6.1-sol', 'claude-sonnet-4-5', 'gemini-2.5-pro'][i % 3] };
|
|
6
|
+
sessions.set(id, { id, parentID: i ? 'ses_demo0' : undefined, title: i ? `${agent} · public API integration` : 'Build a better developer experience', agent, model, time: { created: now - 3600000, updated: now - i * 1000 }, tokens: { input: 24000 + i * 7000, output: 3200, cache: { read: 180000 + i * 19000, write: 0 } } });
|
|
7
|
+
messages.set(id, [{ id: `msg_demo${i}`, type: 'assistant', model, time: { created: now - 90000 + i * 5000, completed: now - 20000 }, tokens: { input: 12000, output: 1200, cache: { read: 48000, write: 0 } }, content: [{ id: `call_demo${i}`, type: 'tool', name: ['read', 'patch', 'webfetch', 'grep', 'shell', 'shell', 'read'][i], state: { status: i === 1 || i === 2 ? 'running' : 'completed', input: { path: ['src/index.ts', 'src/api/client.ts', 'https://example.org/docs', 'src/**/*.ts', 'npm test', 'npm run check', 'docs/design.md'][i] } }, time: { created: now - 30000 + i * 2000, completed: now - 10000 } }], finish: 'tool-calls' }]);
|
|
8
|
+
});
|
|
9
|
+
return { sessions, messages, active: { ses_demo1: { type: 'running' }, ses_demo2: { type: 'running' } }, approvals: [], health: { ok: true, status: 200 }, version: '2.0.30', connection: 'connected', cutoff: () => now - 86400000, start: async () => {}, stop() {}, ensureTree: async () => {} };
|
|
10
|
+
}
|
package/lib/privacy.mjs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Best-effort display redaction, not a security boundary.
|
|
2
|
+
export function clip(value, limit = 180) {
|
|
3
|
+
return String(value ?? '')
|
|
4
|
+
.replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]')
|
|
5
|
+
.replace(/(-u\s+)\S+:\S+/g, '$1[redacted]')
|
|
6
|
+
.replace(/((?:--)?(?:password|token|secret|authorization|api[_-]?key)["']?\s*(?:[:=]\s*|\s+))["']?[^\s,;"'}]+["']?/gi, '$1[redacted]')
|
|
7
|
+
.replace(/\b(?:sk-[\w-]+|ghp_[\w]+|github_pat_[\w_]+|xox[bp]-[\w-]+|AKIA[A-Z0-9]+)\b/g, '[redacted]')
|
|
8
|
+
.slice(0, limit);
|
|
9
|
+
}
|
package/lib/upstream.mjs
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
export function reconnectDelay(delay, connectedAt, now = Date.now()) { return connectedAt !== undefined && now - connectedAt >= 30000 ? 1000 : delay; }
|
|
2
|
+
export function trimMessage(m) {
|
|
3
|
+
return { id: m.id, type: m.type, time: m.time, tokens: m.tokens, agent: m.agent, model: m.model, finish: m.finish, outcome: String(m.outcome || '').slice(0, 300), content: (m.content || []).map(c => ({ type: c.type, name: c.name || c.tool, id: c.id, time: c.time, text: String(c.text || '').slice(0, 300), state: { status: c.state?.status, time: c.state?.time, input: Object.fromEntries(Object.entries(c.state?.input || c.input || {}).filter(([key]) => ['code', 'filePath', 'path', 'pattern', 'command', 'query', 'url', 'agent', 'description'].includes(key)).map(([key, value]) => [key, String(value).slice(0, 300)])) } })) };
|
|
4
|
+
}
|
|
5
|
+
export class Upstream {
|
|
6
|
+
constructor(config, changed) {
|
|
7
|
+
Object.assign(this, { config, changed, sessions: new Map(), messages: new Map(), messageVersions: new Map(), fullHistory: new Set(), active: {}, approvals: [], health: { ok: false }, connection: 'connecting', dirty: new Set(), queue: [], jobs: 0, abort: new AbortController() });
|
|
8
|
+
}
|
|
9
|
+
async request(path, signal) {
|
|
10
|
+
const headers = this.config.password ? { Authorization: `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}` } : {};
|
|
11
|
+
const response = await fetch(`${this.config.server}${path}`, { headers, redirect: 'error', signal: AbortSignal.any([this.abort.signal, signal || AbortSignal.timeout(15000)]) });
|
|
12
|
+
if (!response.ok) { await response.body?.cancel(); throw new Error(response.status === 401 ? 'OpenCode returned 401: set OPENCODE_SERVER_PASSWORD (and optionally OPENCODE_SERVER_USERNAME).' : `OpenCode API returned HTTP ${response.status}.`); }
|
|
13
|
+
return response;
|
|
14
|
+
}
|
|
15
|
+
async get(path) { return (await this.request(path)).json(); }
|
|
16
|
+
async limited(fn) {
|
|
17
|
+
if (this.jobs >= 4) await new Promise(resolve => this.queue.push(resolve));
|
|
18
|
+
else this.jobs++;
|
|
19
|
+
try { return await fn(); } finally { const next = this.queue.shift(); if (next) next(); else this.jobs--; }
|
|
20
|
+
}
|
|
21
|
+
cutoff() { return Math.min(Date.now() - Math.max(12, this.config.window) * 3600000, new Date().setHours(0, 0, 0, 0)); }
|
|
22
|
+
async pages(path, stop = () => false, compact = value => value) {
|
|
23
|
+
const all = []; let cursor; const seen = new Set();
|
|
24
|
+
do {
|
|
25
|
+
const body = await this.get(`${path}${path.includes('?') ? '&' : '?'}limit=100&${cursor ? `cursor=${encodeURIComponent(cursor)}` : 'order=desc'}`);
|
|
26
|
+
if (!Array.isArray(body.data)) throw new Error('Unsupported OpenCode V2 list response.');
|
|
27
|
+
all.push(...body.data.map(compact));
|
|
28
|
+
if (!body.data.length || stop(body.data)) break;
|
|
29
|
+
cursor = body.cursor?.next;
|
|
30
|
+
if (seen.has(cursor)) break; seen.add(cursor);
|
|
31
|
+
} while (cursor);
|
|
32
|
+
return all;
|
|
33
|
+
}
|
|
34
|
+
async loadMessages(id, full = false, recent = false) {
|
|
35
|
+
full ||= this.fullHistory.has(id);
|
|
36
|
+
return this.limited(async () => {
|
|
37
|
+
const version = this.sessions.get(id)?.time?.updated;
|
|
38
|
+
const previous = this.messages.get(id), known = new Set(previous?.map(m => m.id));
|
|
39
|
+
const list = await this.pages(`/api/session/${encodeURIComponent(id)}/message`, page => recent && page.some(m => known.has(m.id)) || !full && page.every(m => m.time?.created < this.cutoff()), trimMessage);
|
|
40
|
+
if (!this.sessions.has(id)) return;
|
|
41
|
+
const compact = list.map(trimMessage);
|
|
42
|
+
if (recent && previous) {
|
|
43
|
+
const merged = new Map(previous.map(m => [m.id, m]));
|
|
44
|
+
for (const message of compact.reverse()) merged.set(message.id, message);
|
|
45
|
+
this.messages.set(id, [...merged.values()].filter(m => full || Math.max(m.time?.created || 0, m.time?.completed || 0) >= this.cutoff()));
|
|
46
|
+
} else this.messages.set(id, compact.reverse().filter(m => full || Math.max(m.time?.created || 0, m.time?.completed || 0) >= this.cutoff()));
|
|
47
|
+
if (!recent) this.messageVersions.set(id, version);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
async loadSession(id) {
|
|
51
|
+
const body = await this.get(`/api/session/${encodeURIComponent(id)}`);
|
|
52
|
+
if (body.data) this.sessions.set(id, body.data);
|
|
53
|
+
}
|
|
54
|
+
removeSession(id, seen = new Set()) {
|
|
55
|
+
if (seen.has(id)) return; seen.add(id);
|
|
56
|
+
for (const child of this.sessions.values()) if (child.parentID === id) this.removeSession(child.id, seen);
|
|
57
|
+
this.sessions.delete(id); this.messages.delete(id); this.fullHistory.delete(id); this.messageVersions.delete(id);
|
|
58
|
+
}
|
|
59
|
+
async descendants(ids) {
|
|
60
|
+
const seen = new Set(); let pending = [...ids];
|
|
61
|
+
while (pending.length) {
|
|
62
|
+
const batch = pending.filter(id => !seen.has(id)); pending = [];
|
|
63
|
+
if (!batch.length) break;
|
|
64
|
+
await Promise.all(batch.map(id => this.limited(async () => {
|
|
65
|
+
seen.add(id);
|
|
66
|
+
const children = await this.pages(`/api/session?parentID=${encodeURIComponent(id)}`), ids = new Set(children.map(child => child.id));
|
|
67
|
+
for (const child of this.sessions.values()) if (child.parentID === id && !ids.has(child.id)) this.removeSession(child.id);
|
|
68
|
+
for (const child of children) { this.sessions.set(child.id, child); pending.push(child.id); }
|
|
69
|
+
})));
|
|
70
|
+
}
|
|
71
|
+
return seen;
|
|
72
|
+
}
|
|
73
|
+
async sync(force = false) {
|
|
74
|
+
if (this.syncing) return this.syncing;
|
|
75
|
+
this.syncing = (async () => {
|
|
76
|
+
const list = await this.pages('/api/session', page => page.at(-1)?.time?.updated < this.cutoff());
|
|
77
|
+
const listed = new Set(list.map(s => s.id));
|
|
78
|
+
for (const s of this.sessions.values()) if (!this.fullHistory.has(s.id) && !s.parentID && (s.time?.updated < this.cutoff() || !listed.has(s.id))) this.removeSession(s.id);
|
|
79
|
+
for (const s of list) if (s.time?.updated >= this.cutoff() || this.fullHistory.has(s.id)) this.sessions.set(s.id, s);
|
|
80
|
+
for (const s of [...this.sessions.values()]) { let parent = s.parentID; const seen = new Set(); while (parent && !this.sessions.has(parent) && !seen.has(parent)) { seen.add(parent); await this.loadSession(parent); parent = this.sessions.get(parent)?.parentID; } }
|
|
81
|
+
const roots = [...this.sessions.values()].filter(s => !s.parentID && !s.time?.archived && s.time?.updated >= Date.now() - this.config.window * 3600000);
|
|
82
|
+
const tree = await this.descendants([...roots.map(s => s.id), ...this.fullHistory]);
|
|
83
|
+
this.evict(tree);
|
|
84
|
+
await Promise.all([...this.sessions.values()].filter(s => (tree.has(s.id) || s.time?.updated >= this.cutoff() || !this.messages.has(s.id)) && (force || !this.messages.has(s.id) || this.active[s.id] || this.messageVersions.get(s.id) !== s.time?.updated)).map(s => this.loadMessages(s.id)));
|
|
85
|
+
this.active = (await this.get('/api/session/active')).data || {};
|
|
86
|
+
await this.pollPending(); this.changed();
|
|
87
|
+
})().finally(() => { this.syncing = null; });
|
|
88
|
+
return this.syncing;
|
|
89
|
+
}
|
|
90
|
+
async ensureTree(id) {
|
|
91
|
+
await this.loadSession(id); const ids = await this.descendants([id]);
|
|
92
|
+
this.historyScopes ||= new Map(); this.historyScopes.delete(id); this.historyScopes.set(id, ids);
|
|
93
|
+
while (this.historyScopes.size > 20) this.historyScopes.delete(this.historyScopes.keys().next().value);
|
|
94
|
+
this.fullHistory = new Set([...this.historyScopes.values()].flatMap(scope => [...scope]));
|
|
95
|
+
await Promise.all([...ids].map(sid => this.loadMessages(sid, true))); this.changed();
|
|
96
|
+
}
|
|
97
|
+
evict(tree = new Set()) {
|
|
98
|
+
for (const s of [...this.sessions.values()]) if (!tree.has(s.id) && !this.fullHistory.has(s.id) && s.time?.updated < this.cutoff()) this.removeSession(s.id);
|
|
99
|
+
for (const [id, messages] of this.messages) if (!this.fullHistory.has(id)) this.messages.set(id, messages.filter(m => Math.max(m.time?.created || 0, m.time?.completed || 0) >= this.cutoff()));
|
|
100
|
+
}
|
|
101
|
+
touchScope(id) { const scope = this.historyScopes?.get(id); if (scope) { this.historyScopes.delete(id); this.historyScopes.set(id, scope); } }
|
|
102
|
+
async pollPending() {
|
|
103
|
+
if (this.pendingPoll) return;
|
|
104
|
+
this.pendingPoll = true;
|
|
105
|
+
const previous = JSON.stringify([this.approvals, this.health]);
|
|
106
|
+
try {
|
|
107
|
+
const locations = new Set([...this.sessions.values()].filter(s => this.active[s.id] || s.time?.updated >= Date.now() - this.config.window * 3600000).map(s => s.location?.directory).filter(Boolean));
|
|
108
|
+
const approvals = [];
|
|
109
|
+
await Promise.all([...locations].map(directory => this.limited(async () => {
|
|
110
|
+
const query = `?location%5Bdirectory%5D=${encodeURIComponent(directory)}`;
|
|
111
|
+
const permissions = await this.get(`/api/permission/request${query}`), forms = await this.get(`/api/form${query}`);
|
|
112
|
+
for (const p of permissions.data || []) approvals.push({ id: p.id, sessionID: p.sessionID, action: p.action || 'permission', resource: (p.resources || []).join(', '), time: this.approvals.find(a => a.id === p.id)?.time || Date.now() });
|
|
113
|
+
for (const f of forms.data || []) approvals.push({ id: f.id, sessionID: f.sessionID, action: 'form', resource: f.title || 'Input requested', time: this.approvals.find(a => a.id === f.id)?.time || Date.now() });
|
|
114
|
+
})));
|
|
115
|
+
this.approvals = approvals; this.health = { ok: true, status: 200 };
|
|
116
|
+
} catch { this.health = { ok: false, status: null }; }
|
|
117
|
+
finally { this.pendingPoll = false; if (previous !== JSON.stringify([this.approvals, this.health])) this.changed(); }
|
|
118
|
+
}
|
|
119
|
+
event(event) {
|
|
120
|
+
const data = event.data || {}, id = data.sessionID || data.form?.sessionID;
|
|
121
|
+
if (!id) return;
|
|
122
|
+
if (event.type === 'session.deleted') { this.removeSession(id); this.changed(); return; }
|
|
123
|
+
this.dirty.add(id);
|
|
124
|
+
if (/permission\.|form\./.test(event.type)) void this.pollPending();
|
|
125
|
+
if (!this.dirtyTimer) this.dirtyTimer = setTimeout(() => {
|
|
126
|
+
this.dirtyTimer = null; const ids = [...this.dirty]; this.dirty.clear();
|
|
127
|
+
void Promise.allSettled(ids.map(sid => this.limited(() => this.loadSession(sid)).then(() => this.loadMessages(sid, false, true)))).then(async () => { this.active = (await this.get('/api/session/active')).data || {}; this.changed(); }).catch(() => console.error('OpenCode live refresh failed; reconciliation will retry.'));
|
|
128
|
+
}, 250);
|
|
129
|
+
}
|
|
130
|
+
async stream() {
|
|
131
|
+
let delay = 1000;
|
|
132
|
+
while (!this.abort.signal.aborted) {
|
|
133
|
+
const controller = new AbortController(), abort = () => controller.abort();
|
|
134
|
+
this.abort.signal.addEventListener('abort', abort, { once: true });
|
|
135
|
+
let watchdog = setTimeout(abort, 45000), connectedAt;
|
|
136
|
+
try {
|
|
137
|
+
const response = await this.request('/api/event', controller.signal);
|
|
138
|
+
this.connection = 'connected'; this.changed();
|
|
139
|
+
connectedAt = Date.now();
|
|
140
|
+
if (!this.lastResync || Date.now() - this.lastResync >= 10000) { this.lastResync = Date.now(); void this.sync(true).catch(() => console.error('OpenCode resynchronization failed; retrying periodically.')); }
|
|
141
|
+
let buffer = ''; const decoder = new TextDecoder();
|
|
142
|
+
for await (const chunk of response.body) {
|
|
143
|
+
clearTimeout(watchdog); watchdog = setTimeout(abort, 45000);
|
|
144
|
+
buffer += decoder.decode(chunk, { stream: true }); buffer = buffer.replace(/\r\n/g, '\n');
|
|
145
|
+
let end;
|
|
146
|
+
while ((end = buffer.indexOf('\n\n')) >= 0) {
|
|
147
|
+
const frame = buffer.slice(0, end); buffer = buffer.slice(end + 2);
|
|
148
|
+
const text = frame.split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n');
|
|
149
|
+
if (text) { try { this.event(JSON.parse(text)); } catch { /* Ignore unknown envelopes. */ } }
|
|
150
|
+
}
|
|
151
|
+
if (buffer.length > 8 * 1024 * 1024) throw new Error('Oversized SSE frame');
|
|
152
|
+
}
|
|
153
|
+
if (!this.abort.signal.aborted) throw new Error('SSE closed');
|
|
154
|
+
} catch {
|
|
155
|
+
if (this.abort.signal.aborted) break;
|
|
156
|
+
delay = reconnectDelay(delay, connectedAt);
|
|
157
|
+
this.connection = 'reconnecting'; this.changed();
|
|
158
|
+
console.error(`OpenCode event stream disconnected; reconnecting in ${delay / 1000}s.`);
|
|
159
|
+
await new Promise(resolve => { const finish = () => { clearTimeout(t); this.abort.signal.removeEventListener('abort', finish); resolve(); }; const t = setTimeout(finish, delay); this.abort.signal.addEventListener('abort', finish, { once: true }); });
|
|
160
|
+
delay = Math.min(delay * 2, 30000);
|
|
161
|
+
} finally {
|
|
162
|
+
clearTimeout(watchdog); this.abort.signal.removeEventListener('abort', abort); controller.abort();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async start() {
|
|
167
|
+
void this.initialize();
|
|
168
|
+
}
|
|
169
|
+
async initialize() {
|
|
170
|
+
let delay = 1000;
|
|
171
|
+
while (!this.abort.signal.aborted) {
|
|
172
|
+
try {
|
|
173
|
+
let info;
|
|
174
|
+
try { info = await this.get('/api/info'); } catch (error) { throw new Error(`Cannot connect to OpenCode V2. ${error.message}`); }
|
|
175
|
+
this.version = info.version || info.data?.version;
|
|
176
|
+
if (!/^2\./.test(this.version || '')) throw new Error('Unsupported server: OpenCode V2 /api/info version 2.x is required.');
|
|
177
|
+
await this.sync(); if (this.abort.signal.aborted) return; void this.stream();
|
|
178
|
+
this.syncTimer = setInterval(() => { void this.sync().catch(() => console.error('OpenCode reconciliation failed; retrying in 60s.')); }, 60000);
|
|
179
|
+
this.pendingTimer = setInterval(() => { void this.pollPending(); }, 5000);
|
|
180
|
+
return;
|
|
181
|
+
} catch {
|
|
182
|
+
if (this.abort.signal.aborted) return;
|
|
183
|
+
this.connection = 'connecting'; this.changed();
|
|
184
|
+
await new Promise(resolve => { const finish = () => { clearTimeout(timer); this.abort.signal.removeEventListener('abort', finish); resolve(); }; const timer = setTimeout(finish, delay); this.abort.signal.addEventListener('abort', finish, { once: true }); });
|
|
185
|
+
delay = Math.min(delay * 2, 30000);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
stop() { this.abort.abort(); clearInterval(this.syncTimer); clearInterval(this.pendingTimer); clearTimeout(this.dirtyTimer); }
|
|
190
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-agent-flow",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Live agent flow visualization for OpenCode V2 over its public HTTP API and SSE",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "opencode-agent-flow": "bin/cli.mjs" },
|
|
7
|
+
"engines": { "node": ">=20" },
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"author": "orybakov (https://github.com/orybakov)",
|
|
10
|
+
"repository": { "type": "git", "url": "https://github.com/orybakov/opencode-agent-flow.git" },
|
|
11
|
+
"bugs": { "url": "https://github.com/orybakov/opencode-agent-flow/issues" },
|
|
12
|
+
"homepage": "https://github.com/orybakov/opencode-agent-flow#readme",
|
|
13
|
+
"keywords": ["opencode", "ai-agents", "visualizer", "observability", "webgl"],
|
|
14
|
+
"exports": {},
|
|
15
|
+
"files": ["bin", "server.mjs", "lib", "public", "prices.json", "README.md", "LICENSE", "SECURITY.md", "CONTRIBUTING.md", "docs/*.md", "docs/screenshot.png"],
|
|
16
|
+
"scripts": { "start": "node bin/cli.mjs", "test": "node --test" }
|
|
17
|
+
}
|
package/prices.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"note": "Illustrative placeholders, USD per 1M tokens, not subscription billing. Context limits are estimates; edit to match your provider.",
|
|
3
|
+
"default": { "input": 3, "output": 15, "cacheRead": 0.3, "contextLimit": 200000 },
|
|
4
|
+
"models": {
|
|
5
|
+
"claude-opus-5-5": { "input": 5, "output": 25, "cacheRead": 0.5, "contextLimit": 200000 },
|
|
6
|
+
"claude-sonnet-5-5": { "input": 3, "output": 15, "cacheRead": 0.3, "contextLimit": 200000 },
|
|
7
|
+
"gpt-6.1-sol": { "input": 2.5, "output": 15, "cacheRead": 0.25, "contextLimit": 200000 },
|
|
8
|
+
"gemini-3.8-flash-high": { "input": 0.5, "output": 3, "cacheRead": 0.05, "contextLimit": 1000000 }
|
|
9
|
+
}
|
|
10
|
+
}
|