@byokit/openclaw 0.1.4 → 0.2.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 +35 -0
- package/README.md +11 -3
- package/dist/bridge.d.ts +3 -1
- package/dist/bridge.js +24 -1
- package/dist/device.d.ts +23 -9
- package/dist/device.js +3 -1
- package/dist/index.d.ts +1 -1
- package/dist/kit.d.ts +2 -0
- package/dist/kit.js +5 -0
- package/dist/link.js +50 -6
- package/dist/runs.d.ts +7 -1
- package/dist/runs.js +94 -10
- package/dist/testing/fake-gateway.js +46 -9
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/testing/model-stub.d.ts +6 -0
- package/dist/testing/model-stub.js +9 -0
- package/dist/types.d.ts +29 -0
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- Depends on @byokit/relay 0.3.1.
|
|
6
|
+
- Depends on @byokit/reach 0.4.0.
|
|
7
|
+
- Depends on @byokit/link 0.5.0.
|
|
8
|
+
- FIX: (from @byokit/link 0.5.0) `decodeOffer()` reads legacy compact direct pairing codes with checksum, padding, bounds and expiry validation; new encodings keep the complete current format.
|
|
9
|
+
## 0.2.1 (2026-09-30)
|
|
10
|
+
|
|
11
|
+
- Dependency update: pins @byokit/link 0.5.0, @byokit/reach 0.4.0 and @byokit/relay 0.3.1.
|
|
12
|
+
|
|
13
|
+
## 0.2.0 (2026-09-30)
|
|
14
|
+
|
|
15
|
+
- Full run options over the link (v1 A6): `oc.run` and `openclawDevice(link).run(message, o)` forward `system`,
|
|
16
|
+
`images`, `thinking` and `tools`, each type-checked. `RunSpec.tools` is the run's subset of the app's tools
|
|
17
|
+
(`KitOptions.tools` names, listed by the new `kit.toolNames()`): the bridge refuses any other app tool before
|
|
18
|
+
`ToolHost.gate` sees it, engine builtins are unaffected, and a name the kit does not register is refused before the
|
|
19
|
+
engine is called (over the link, `link.notAllowed`). The pinned engine takes no per-run tool list, so the model
|
|
20
|
+
still sees every tool; the gate is the enforcement. Runs sharing a session key share the narrowest subset.
|
|
21
|
+
- Structured tool events (v1 A7): a `tool` `RunEvent` carries the engine's `id` (its toolCallId) on both ends, the
|
|
22
|
+
call's `input` on `start`, and the engine's `output` and `error` on `end`.
|
|
23
|
+
- Usage (v1 A8): `RunEnd.ok` carries `usage` (`input`, `output`, `cacheRead`, `cacheWrite`, `reasoning`, `total`,
|
|
24
|
+
`costUsd`), the engine's own total for the run from the `agent` request's final frame, and `planWindow`
|
|
25
|
+
(`{ provider, plan?, windows: { label, usedPercent, resetAt? }[] }`), the engine's cached read of the
|
|
26
|
+
subscription's quota for the run's provider. Each is present only when the engine reports it; nothing is estimated.
|
|
27
|
+
New types `RunUsage` and `PlanWindow` (`.`, `./device`).
|
|
28
|
+
- Presence and version (v1 A12, device side): `oc.state` (`openclawDevice(link).state()`, typed `DeviceState`) adds
|
|
29
|
+
`version` (this kit), `engine` (`ENGINE_VERSION`) and `signedIn`, the providers the device's member is usably
|
|
30
|
+
signed in to (not an expired or unfinished sign-in), absent while the engine can't say.
|
|
31
|
+
- FIX: a run ending no longer drops another live run's registration on the same session key (its tool calls then
|
|
32
|
+
failed as an unknown run); a key stays registered until its last run ends. `Bridge.register` returns the release.
|
|
33
|
+
- FIX: the engine's `update`, `input_delta` and `review` tool phases no longer end a tool pair early; only `result`
|
|
34
|
+
(or `end`) does.
|
|
35
|
+
- Testing: the fake Gateway's runs are engine-shaped (`toolCallId`, `args`, `result`, `isError`; an `accepted` frame
|
|
36
|
+
and, with `expectFinal`, a final frame with `agentMeta.usage`), and the model stub ends a reply asked
|
|
37
|
+
`stream_options.include_usage` with a `STUB_USAGE` chunk (exported from `./testing`). `GatewayTransport.request`
|
|
38
|
+
options take `expectFinal` and `onAccepted`.
|
|
39
|
+
|
|
5
40
|
## 0.1.4 (2026-09-30)
|
|
6
41
|
|
|
7
42
|
- Depends on @byokit/relay 0.3.0.
|
package/README.md
CHANGED
|
@@ -54,6 +54,14 @@ provider or model. If the member isn't signed in to it, the run ends `{ ok: fals
|
|
|
54
54
|
engine is never called. Leave `model` out to keep the engine's own choice. A specific sign-in (`@profile`) can't be
|
|
55
55
|
picked per run: the pinned engine may still switch to another sign-in for the same provider.
|
|
56
56
|
|
|
57
|
+
A run spec also takes `system`, `images` (`{ data, mimeType }[]`), `thinking` and `tools`, a subset of the app tools
|
|
58
|
+
(`KitOptions.tools` names) this run may call; any other app tool is refused at the gate before `ToolHost.gate` sees
|
|
59
|
+
it. Tool events carry the engine's call `id`, the `input` on `start` and the `output` and `error` on `end`. A run that
|
|
60
|
+
ends ok carries `usage` (the engine's token total for the run, and `costUsd` when it priced the model) and
|
|
61
|
+
`planWindow` (the subscription's quota windows as the engine last read them), each only when the engine reports it.
|
|
62
|
+
`openclawDevice(link).run(message, o)` takes the same options over the link, and `state()` adds the kit and engine
|
|
63
|
+
versions and the providers the device's member is signed in to.
|
|
64
|
+
|
|
57
65
|
The same kit runs offline against the fake Gateway from `@byokit/openclaw/testing` (no engine, no network, no
|
|
58
66
|
account), and lists the sign-in routes it offers:
|
|
59
67
|
|
|
@@ -89,7 +97,7 @@ Its output, run from this repo with Node:
|
|
|
89
97
|
state: ready | protocol: 4
|
|
90
98
|
event: {"type":"text","text":"fake: Say hello."}
|
|
91
99
|
event: {"type":"text","text":"fake: Say hello."}
|
|
92
|
-
end: {"ok":true,"text":"fake: Say hello."}
|
|
100
|
+
end: {"ok":true,"text":"fake: Say hello.","usage":{"input":10,"output":16,"total":26}}
|
|
93
101
|
route: openai subscription browser
|
|
94
102
|
route: openai-device-code subscription code
|
|
95
103
|
route: xai-oauth subscription code
|
|
@@ -113,9 +121,9 @@ Entries:
|
|
|
113
121
|
|
|
114
122
|
| Export | What it does |
|
|
115
123
|
| --- | --- |
|
|
116
|
-
| `OpenClawKit` (`.`) | Prepares, starts, supervises and stops the engine. `call` / `callDynamic` pass through to the Gateway, `onEvent` listens. Helpers: `ensureMember`, `routes`, `providers`, `signedIn`, `signIn`, `signOut`, `migrateRetainedLogin`, `confirmRetainedLogin`, `run`, `steer`, `abort`, `approvals`, `onApproval`, `decide`, `allowOnce`, `disallowOnce`, `patchConfig`, `memoryLimited`, `doctorContext` |
|
|
124
|
+
| `OpenClawKit` (`.`) | Prepares, starts, supervises and stops the engine. `call` / `callDynamic` pass through to the Gateway, `onEvent` listens. Helpers: `ensureMember`, `routes`, `providers`, `signedIn`, `signIn`, `signOut`, `migrateRetainedLogin`, `confirmRetainedLogin`, `toolNames`, `run`, `steer`, `abort`, `approvals`, `onApproval`, `decide`, `allowOnce`, `disallowOnce`, `patchConfig`, `memoryLimited`, `doctorContext` |
|
|
117
125
|
| `ENGINE_VERSION`, `PROTOCOL_VERSION`, `OPERATOR_SCOPES` (`.`) | The pinned engine version, its protocol and the operator scopes the kit connects with |
|
|
118
|
-
| `KitOptions`, `RunSpec`, `RunEvent`, `RunEnd`, `Route`, `Approval`, `Decision`, `ToolSpec`, `ToolHost`, `KitState`, ... (`.`) | Public types (docs/runtime-kits.md §5.2) |
|
|
126
|
+
| `KitOptions`, `RunSpec`, `RunEvent`, `RunEnd`, `RunUsage`, `PlanWindow`, `Route`, `Approval`, `Decision`, `ToolSpec`, `ToolHost`, `KitState`, ... (`.`) | Public types (docs/runtime-kits.md §5.2) |
|
|
119
127
|
| `openclawDevice(link)` (`./device`) | Portable client: state, routes, sign-in, runs, steer, abort, approvals, events, sealed notices, pass-through `call` |
|
|
120
128
|
| `LinkRefused`, `openNotice` (`./device`) | The host's own refusal as an error; opens a sealed approval notice |
|
|
121
129
|
| `words`, `stateWords`, `toAccountView` (`.`, `./device`) | The kit's sentences, so a phone shows the words the computer does; `toAccountView` feeds `@byokit/ui-core`'s `phaseOf` |
|
package/dist/bridge.d.ts
CHANGED
|
@@ -35,6 +35,7 @@ export declare class Bridge {
|
|
|
35
35
|
private readonly onAskGone;
|
|
36
36
|
private server?;
|
|
37
37
|
private readonly runs;
|
|
38
|
+
private readonly live;
|
|
38
39
|
private readonly permits;
|
|
39
40
|
private readonly tickets;
|
|
40
41
|
private armed;
|
|
@@ -51,7 +52,8 @@ export declare class Bridge {
|
|
|
51
52
|
});
|
|
52
53
|
start(): Promise<void>;
|
|
53
54
|
stop(): void;
|
|
54
|
-
|
|
55
|
+
/** Register one run; the returned release ends only this registration (the key stays while another run holds it). */
|
|
56
|
+
register(run: RunRef, tools?: readonly string[]): () => void;
|
|
55
57
|
unregister(sessionKey: string): void;
|
|
56
58
|
allowOnce(rule: {
|
|
57
59
|
keyPrefix: string;
|
package/dist/bridge.js
CHANGED
|
@@ -69,6 +69,9 @@ export class Bridge {
|
|
|
69
69
|
onAskGone;
|
|
70
70
|
server;
|
|
71
71
|
runs = new Map();
|
|
72
|
+
// Every live registration per session key, each with its run's subset of the app's tools (RunSpec.tools; none: all
|
|
73
|
+
// of them). Runs sharing a key share the narrowest: a tool must be in every live subset.
|
|
74
|
+
live = new Map();
|
|
72
75
|
permits = new Map();
|
|
73
76
|
tickets = [];
|
|
74
77
|
armed;
|
|
@@ -111,11 +114,28 @@ export class Bridge {
|
|
|
111
114
|
this.server = undefined;
|
|
112
115
|
void rm(this.path, { force: true }).catch(() => { });
|
|
113
116
|
}
|
|
114
|
-
|
|
117
|
+
/** Register one run; the returned release ends only this registration (the key stays while another run holds it). */
|
|
118
|
+
register(run, tools) {
|
|
115
119
|
this.runs.set(run.sessionKey, run);
|
|
120
|
+
const entry = tools ? { tools: new Set(tools) } : {};
|
|
121
|
+
const list = this.live.get(run.sessionKey);
|
|
122
|
+
if (list)
|
|
123
|
+
list.push(entry);
|
|
124
|
+
else
|
|
125
|
+
this.live.set(run.sessionKey, [entry]);
|
|
126
|
+
return () => {
|
|
127
|
+
const now = this.live.get(run.sessionKey);
|
|
128
|
+
const at = now?.indexOf(entry) ?? -1;
|
|
129
|
+
if (!now || at < 0)
|
|
130
|
+
return;
|
|
131
|
+
now.splice(at, 1);
|
|
132
|
+
if (!now.length)
|
|
133
|
+
this.unregister(run.sessionKey);
|
|
134
|
+
};
|
|
116
135
|
}
|
|
117
136
|
unregister(sessionKey) {
|
|
118
137
|
this.runs.delete(sessionKey);
|
|
138
|
+
this.live.delete(sessionKey);
|
|
119
139
|
for (const [permit, p] of this.permits)
|
|
120
140
|
if (p.key === sessionKey)
|
|
121
141
|
this.permits.delete(permit);
|
|
@@ -235,6 +255,9 @@ export class Bridge {
|
|
|
235
255
|
async gateRun(socket, run, tool, input) {
|
|
236
256
|
if (!this.host)
|
|
237
257
|
return this.deny(socket, "can't check this action right now");
|
|
258
|
+
// An app tool outside the run's own subset never reaches the host; builtins stay with the gate.
|
|
259
|
+
if (this.tools.has(tool) && (this.live.get(run.sessionKey) ?? []).some((e) => e.tools && !e.tools.has(tool)))
|
|
260
|
+
return this.deny(socket, 'this tool is not available in this run');
|
|
238
261
|
let result;
|
|
239
262
|
try {
|
|
240
263
|
result = await this.host.gate(run, tool, input, { builtin: !this.tools.has(tool) });
|
package/dist/device.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { DeviceLink } from '@byokit/link';
|
|
|
2
2
|
import { type AccountView } from './words.ts';
|
|
3
3
|
import type { Approval, Decision, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, KitState, Route, RunEnd, RunEvent, SignInView } from './types.ts';
|
|
4
4
|
export type { GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult } from './types.ts';
|
|
5
|
-
export type { Approval, Decision, KitState, Route, RunEnd, RunEvent, SignInView } from './types.ts';
|
|
5
|
+
export type { Approval, Decision, KitState, PlanWindow, Route, RunEnd, RunEvent, RunUsage, SignInView } from './types.ts';
|
|
6
6
|
/** One `oc.events` frame: a member's Gateway event, typed by name, or an approval add/resolve (7.2). */
|
|
7
7
|
export type OpenClawLinkEvent = {
|
|
8
8
|
[E in GatewayEventName]: {
|
|
@@ -19,6 +19,26 @@ export type SessionRow = {
|
|
|
19
19
|
sessionKey: string;
|
|
20
20
|
[k: string]: unknown;
|
|
21
21
|
};
|
|
22
|
+
/** `oc.run`'s options beside the message: the member's own session, account, and the kit's run options. */
|
|
23
|
+
export type DeviceRunOptions = {
|
|
24
|
+
sessionKey?: string;
|
|
25
|
+
model?: string;
|
|
26
|
+
system?: string;
|
|
27
|
+
images?: {
|
|
28
|
+
data: string;
|
|
29
|
+
mimeType: string;
|
|
30
|
+
}[];
|
|
31
|
+
thinking?: 'off' | 'low' | 'medium' | 'high';
|
|
32
|
+
tools?: string[];
|
|
33
|
+
};
|
|
34
|
+
/** `oc.state`: the engine phase and words, this kit's and the engine's versions, and the device member's sign-ins. */
|
|
35
|
+
export type DeviceState = {
|
|
36
|
+
state: KitState;
|
|
37
|
+
words: string;
|
|
38
|
+
version: string;
|
|
39
|
+
engine: string;
|
|
40
|
+
signedIn?: string[];
|
|
41
|
+
};
|
|
22
42
|
/**
|
|
23
43
|
* The host ended a stream with its own words (e.g. a refusal). Portable twin of link's PublicLinkError:
|
|
24
44
|
* same shape ({ code: 'failed', sealed: true }) so a screen can show .message the same way. A device-side
|
|
@@ -30,10 +50,7 @@ export declare class LinkRefused extends Error {
|
|
|
30
50
|
constructor(message: string);
|
|
31
51
|
}
|
|
32
52
|
export declare function openclawDevice(link: DeviceLink): {
|
|
33
|
-
state(): Promise<
|
|
34
|
-
state: KitState;
|
|
35
|
-
words: string;
|
|
36
|
-
}>;
|
|
53
|
+
state(): Promise<DeviceState>;
|
|
37
54
|
routes(): Promise<Route[]>;
|
|
38
55
|
signIn: {
|
|
39
56
|
start(p: string, via: 'browser' | 'code'): Promise<SignInView>;
|
|
@@ -43,10 +60,7 @@ export declare function openclawDevice(link: DeviceLink): {
|
|
|
43
60
|
};
|
|
44
61
|
signOut(p: string): Promise<void>;
|
|
45
62
|
sessions(): Promise<SessionRow[]>;
|
|
46
|
-
run(message: string, o?: {
|
|
47
|
-
sessionKey?: string;
|
|
48
|
-
model?: string;
|
|
49
|
-
}): AsyncIterable<RunEvent | {
|
|
63
|
+
run(message: string, o?: DeviceRunOptions): AsyncIterable<RunEvent | {
|
|
50
64
|
type: 'end';
|
|
51
65
|
end: RunEnd;
|
|
52
66
|
}>;
|
package/dist/device.js
CHANGED
|
@@ -138,7 +138,9 @@ export function openclawDevice(link) {
|
|
|
138
138
|
sessions: () => link.request('oc.sessions'),
|
|
139
139
|
run: (message, o) => {
|
|
140
140
|
const inner = liveStream(() => link.stream('oc.run', { message, ...(o?.sessionKey ? { sessionKey: o.sessionKey } : {}),
|
|
141
|
-
...(o?.model !== undefined ? { model: o.model } : {}) })
|
|
141
|
+
...(o?.model !== undefined ? { model: o.model } : {}), ...(o?.system !== undefined ? { system: o.system } : {}),
|
|
142
|
+
...(o?.images !== undefined ? { images: o.images } : {}), ...(o?.thinking !== undefined ? { thinking: o.thinking } : {}),
|
|
143
|
+
...(o?.tools !== undefined ? { tools: o.tools } : {}) }));
|
|
142
144
|
let finished = false;
|
|
143
145
|
return {
|
|
144
146
|
[Symbol.asyncIterator]() {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { ENGINE_VERSION, OPERATOR_SCOPES, PROTOCOL_VERSION } from './constants.ts';
|
|
2
|
-
export type { Approval, CallOptions, Decision, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, GateResult, Hello, KitState, Member, Route, RunEnd, RunEvent, RunRef, RunSpec, SignInView, ToolHost, ToolSpec, } from './types.ts';
|
|
2
|
+
export type { Approval, CallOptions, Decision, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, GateResult, Hello, KitState, Member, PlanWindow, Route, RunEnd, RunEvent, RunRef, RunSpec, RunUsage, SignInView, ToolHost, ToolSpec, } from './types.ts';
|
|
3
3
|
export { OpenClawKit, type KitOptions, type RetainedLogin } from './kit.ts';
|
|
4
4
|
export { stateWords, toAccountView, words, type AccountView, type WordKey } from './words.ts';
|
package/dist/kit.d.ts
CHANGED
|
@@ -85,6 +85,8 @@ export declare class OpenClawKit {
|
|
|
85
85
|
migrateRetainedLogin(member: Member, source: RetainedLogin): Promise<'staged' | 'nothing' | 'failed'>;
|
|
86
86
|
confirmRetainedLogin(member: Member, source: RetainedLogin): Promise<boolean>;
|
|
87
87
|
private runs;
|
|
88
|
+
/** The app tool names (`KitOptions.tools`): the only names `RunSpec.tools` may carry. */
|
|
89
|
+
toolNames(): string[];
|
|
88
90
|
run(spec: RunSpec, on?: (e: RunEvent) => void): Promise<RunEnd>;
|
|
89
91
|
steer(sessionKey: string, text: string): Promise<void>;
|
|
90
92
|
abort(sessionKey: string): Promise<void>;
|
package/dist/kit.js
CHANGED
|
@@ -611,8 +611,13 @@ export class OpenClawKit {
|
|
|
611
611
|
},
|
|
612
612
|
ensure: (member) => this.ensureMember(member),
|
|
613
613
|
bridge: this.bridge,
|
|
614
|
+
tools: new Set(this.toolNames()),
|
|
614
615
|
});
|
|
615
616
|
}
|
|
617
|
+
/** The app tool names (`KitOptions.tools`): the only names `RunSpec.tools` may carry. */
|
|
618
|
+
toolNames() {
|
|
619
|
+
return (this.o.tools ?? []).map((t) => t.name);
|
|
620
|
+
}
|
|
616
621
|
run(spec, on) {
|
|
617
622
|
return this.runs().run(spec, on);
|
|
618
623
|
}
|
package/dist/link.js
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
// Host-side link adapter (7.1): typed, member-checked ops over @byokit/link, sealed approval push
|
|
2
2
|
// via the relay (7.3). Node only.
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
4
5
|
import { createServer } from 'node:http';
|
|
5
6
|
import { WebSocketServer } from 'ws';
|
|
6
7
|
import { PublicLinkError, } from '@byokit/link';
|
|
7
8
|
import { reach } from '@byokit/reach';
|
|
9
|
+
import { ENGINE_VERSION } from "./constants.js";
|
|
8
10
|
import { b64urlDecode, sealNotice } from "./notices.js";
|
|
9
11
|
import { routeFor } from "./routes.js";
|
|
12
|
+
import { signedInProviders } from "./runs.js";
|
|
10
13
|
import { stateWords, words } from "./words.js";
|
|
11
14
|
const VIEW_OPS = new Set(['oc.state', 'oc.routes', 'oc.signin.view', 'oc.sessions', 'oc.approvals', 'oc.events']);
|
|
15
|
+
// This kit's own version, read once from its package.json (beside src/ and dist/ alike).
|
|
16
|
+
const KIT_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
17
|
+
const THINKING = new Set(['off', 'low', 'medium', 'high']);
|
|
12
18
|
const refused = () => new PublicLinkError(words('link.notAllowed'));
|
|
13
19
|
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
14
20
|
const memberKey = (member, sessionKey) => sessionKey.startsWith(`agent:${member}:`);
|
|
@@ -16,6 +22,27 @@ function memberSessions(result, member) {
|
|
|
16
22
|
const rows = Array.isArray(result) ? result : isRecord(result) && Array.isArray(result.sessions) ? result.sessions : [];
|
|
17
23
|
return rows.filter((row) => isRecord(row) && typeof row.sessionKey === 'string' && memberKey(member, row.sessionKey));
|
|
18
24
|
}
|
|
25
|
+
/** `oc.run`'s run options, type-checked; the kit's own checks (account, tool names) still apply. */
|
|
26
|
+
function runOptions(args) {
|
|
27
|
+
const { message, model, system, images, thinking, tools } = args;
|
|
28
|
+
const ok = typeof message === 'string'
|
|
29
|
+
&& (model === undefined || typeof model === 'string')
|
|
30
|
+
&& (system === undefined || typeof system === 'string')
|
|
31
|
+
&& (images === undefined || (Array.isArray(images) && images.every((i) => isRecord(i) && typeof i.data === 'string' && typeof i.mimeType === 'string')))
|
|
32
|
+
&& (thinking === undefined || (typeof thinking === 'string' && THINKING.has(thinking)))
|
|
33
|
+
&& (tools === undefined || (Array.isArray(tools) && tools.every((t) => typeof t === 'string')));
|
|
34
|
+
if (!ok)
|
|
35
|
+
throw new Error('oc.run needs { message, sessionKey?, model?, system?, images?, thinking?, tools? }');
|
|
36
|
+
return {
|
|
37
|
+
message: message,
|
|
38
|
+
...(model === undefined ? {} : { model: model }),
|
|
39
|
+
...(system === undefined ? {} : { system: system }),
|
|
40
|
+
...(images === undefined ? {} : { images: images
|
|
41
|
+
.map((i) => ({ data: i.data, mimeType: i.mimeType })) }),
|
|
42
|
+
...(thinking === undefined ? {} : { thinking: thinking }),
|
|
43
|
+
...(tools === undefined ? {} : { tools: [...tools] }),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
19
46
|
export function openclawLink(kit, o) {
|
|
20
47
|
const passThrough = o.passThrough ?? (() => false);
|
|
21
48
|
// Grants that have called, so sealed pushes and push actions can check the member (7.3).
|
|
@@ -69,14 +96,29 @@ export function openclawLink(kit, o) {
|
|
|
69
96
|
return null;
|
|
70
97
|
};
|
|
71
98
|
const endReason = (error) => error instanceof PublicLinkError ? error.message.slice(0, 200) : 'failed';
|
|
99
|
+
const signIns = async (member) => {
|
|
100
|
+
try {
|
|
101
|
+
const { agentId } = await kit.ensureMember(member);
|
|
102
|
+
const status = await kit.call('models.authStatus', { agentId }, { timeoutMs: 5_000 });
|
|
103
|
+
return signedInProviders(status);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
72
109
|
const handle = async (req, grant) => {
|
|
73
110
|
const member = memberOf(grant);
|
|
74
111
|
if (grant.role === 'view' && !VIEW_OPS.has(req.op))
|
|
75
112
|
throw refused();
|
|
76
113
|
const args = isRecord(req.args) ? req.args : {};
|
|
77
114
|
switch (req.op) {
|
|
78
|
-
case 'oc.state':
|
|
79
|
-
|
|
115
|
+
case 'oc.state': {
|
|
116
|
+
// signedIn: the providers the device member is usably signed in to (an expired or unfinished sign-in is not),
|
|
117
|
+
// only while the engine can say: absent is unknown, never "none".
|
|
118
|
+
const signedIn = kit.state.phase === 'ready' ? await signIns(member) : undefined;
|
|
119
|
+
return { state: kit.state, words: stateWords(kit.state), version: KIT_VERSION, engine: ENGINE_VERSION,
|
|
120
|
+
...(signedIn ? { signedIn } : {}) };
|
|
121
|
+
}
|
|
80
122
|
case 'oc.routes':
|
|
81
123
|
return kit.routes().filter((route) => route.offer);
|
|
82
124
|
case 'oc.signin.start': {
|
|
@@ -190,19 +232,21 @@ export function openclawLink(kit, o) {
|
|
|
190
232
|
if (grant.role === 'view')
|
|
191
233
|
throw refused();
|
|
192
234
|
const args = isRecord(req.args) ? req.args : {};
|
|
193
|
-
|
|
194
|
-
throw new Error('oc.run needs { message, sessionKey?, model? }');
|
|
235
|
+
const options = runOptions(args);
|
|
195
236
|
const sessionKey = args.sessionKey === undefined ? `agent:${member}:link:${randomUUID()}` : args.sessionKey;
|
|
196
237
|
if (typeof sessionKey !== 'string' || !memberKey(member, sessionKey))
|
|
197
238
|
throw refused();
|
|
239
|
+
// A tool the kit does not register is not this device's to name.
|
|
240
|
+
const known = new Set(kit.toolNames());
|
|
241
|
+
if (options.tools?.some((tool) => !known.has(tool)))
|
|
242
|
+
throw refused();
|
|
198
243
|
// Ordered frames over the paced stream: each write waits for the last.
|
|
199
244
|
let tail = Promise.resolve();
|
|
200
245
|
const send = (frame) => {
|
|
201
246
|
tail = tail.then(() => s.write(`${JSON.stringify(frame)}\n`)).catch(() => { });
|
|
202
247
|
};
|
|
203
248
|
try {
|
|
204
|
-
const end = await kit.run({ member, sessionKey,
|
|
205
|
-
...(args.model === undefined ? {} : { model: args.model }) }, (e) => send(e));
|
|
249
|
+
const end = await kit.run({ member, sessionKey, ...options }, (e) => send(e));
|
|
206
250
|
send({ type: 'end', end });
|
|
207
251
|
await tail;
|
|
208
252
|
s.end();
|
package/dist/runs.d.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import type { Bridge } from './bridge.ts';
|
|
2
2
|
import type { GatewayTransport, Member, RunEnd, RunEvent, RunSpec } from './types.ts';
|
|
3
|
+
/**
|
|
4
|
+
* The providers a `models.authStatus` answer shows usable (the rule a picked account is checked by), lowercased and
|
|
5
|
+
* unique; undefined while the engine has no prepared status (unknown, not none).
|
|
6
|
+
*/
|
|
7
|
+
export declare function signedInProviders(status: unknown): string[] | undefined;
|
|
3
8
|
export declare function createRuns(ctx: {
|
|
4
9
|
request: GatewayTransport['request'];
|
|
5
10
|
onEvent: GatewayTransport['onEvent'];
|
|
6
11
|
ensure(member: Member): Promise<{
|
|
7
12
|
agentId: string;
|
|
8
13
|
}>;
|
|
9
|
-
bridge: Pick<Bridge, 'register'
|
|
14
|
+
bridge: Pick<Bridge, 'register'>;
|
|
15
|
+
tools: ReadonlySet<string>;
|
|
10
16
|
}): {
|
|
11
17
|
run(spec: RunSpec, on?: (e: RunEvent) => void): Promise<RunEnd>;
|
|
12
18
|
steer(k: string, t: string): Promise<void>;
|
package/dist/runs.js
CHANGED
|
@@ -1,13 +1,58 @@
|
|
|
1
1
|
// Runs: one Gateway run per spec, streamed through typed events, ending in a classified RunEnd (5.8, O8).
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
4
|
import { classify } from "./classify.js";
|
|
4
5
|
// The gateway gives up on a silent run after an hour; we wait ten minutes longer for the reply frame itself.
|
|
5
6
|
const WAIT_MS = 3_600_000;
|
|
6
7
|
const WAIT_CLIENT_MS = 3_610_000;
|
|
8
|
+
// The `agent` request's final frame lands with the run's end; a run already over waits no longer than this for it.
|
|
9
|
+
const FINAL_GRACE_MS = 5_000;
|
|
10
|
+
const STATUS_MS = 5_000;
|
|
7
11
|
const USABLE = new Set(['ok', 'expiring', 'static']);
|
|
8
12
|
const usable = (row) => typeof row === 'string'
|
|
9
13
|
|| (Array.isArray(row.profiles) && row.profiles.length > 0 ? row.profiles.some((p) => USABLE.has(String(p?.status)))
|
|
10
14
|
: row.status === undefined || USABLE.has(String(row.status)));
|
|
15
|
+
/**
|
|
16
|
+
* The providers a `models.authStatus` answer shows usable (the rule a picked account is checked by), lowercased and
|
|
17
|
+
* unique; undefined while the engine has no prepared status (unknown, not none).
|
|
18
|
+
*/
|
|
19
|
+
export function signedInProviders(status) {
|
|
20
|
+
const auth = (status ?? {});
|
|
21
|
+
if (auth.unavailable || !Array.isArray(auth.providers))
|
|
22
|
+
return undefined;
|
|
23
|
+
const names = auth.providers.filter((row) => row && usable(row))
|
|
24
|
+
.map((row) => (typeof row === 'string' ? row : row.provider))
|
|
25
|
+
.filter((name) => typeof name === 'string' && name !== '');
|
|
26
|
+
return [...new Set(names.map((name) => name.toLowerCase()))];
|
|
27
|
+
}
|
|
28
|
+
const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
29
|
+
const count = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
|
|
30
|
+
/**
|
|
31
|
+
* The run's usage off the `agent` final frame's `result.meta.agentMeta` (pin: `usage` summed over the run, zero
|
|
32
|
+
* buckets and all-zero usage omitted; `costUsd` only when the engine has a price). Undefined when it reports none.
|
|
33
|
+
*/
|
|
34
|
+
function usageOf(agentMeta) {
|
|
35
|
+
const u = isRecord(agentMeta.usage) ? agentMeta.usage : {};
|
|
36
|
+
const usage = {};
|
|
37
|
+
for (const [from, to] of [['input', 'input'], ['output', 'output'], ['cacheRead', 'cacheRead'], ['cacheWrite', 'cacheWrite'],
|
|
38
|
+
['reasoningTokens', 'reasoning'], ['total', 'total']])
|
|
39
|
+
if (count(u[from]))
|
|
40
|
+
usage[to] = u[from];
|
|
41
|
+
if (count(agentMeta.costUsd))
|
|
42
|
+
usage.costUsd = agentMeta.costUsd;
|
|
43
|
+
return Object.keys(usage).length ? usage : undefined;
|
|
44
|
+
}
|
|
45
|
+
/** The provider's quota windows from a `models.authStatus` row's `usage`, only when it lists at least one. */
|
|
46
|
+
function planOf(provider, row) {
|
|
47
|
+
const usage = row?.usage;
|
|
48
|
+
if (!isRecord(usage) || !Array.isArray(usage.windows))
|
|
49
|
+
return undefined;
|
|
50
|
+
const windows = usage.windows.filter((w) => isRecord(w) && typeof w.label === 'string' && typeof w.usedPercent === 'number' && Number.isFinite(w.usedPercent))
|
|
51
|
+
.map((w) => ({ label: w.label, usedPercent: w.usedPercent, ...(count(w.resetAt) ? { resetAt: w.resetAt } : {}) }));
|
|
52
|
+
if (!windows.length)
|
|
53
|
+
return undefined;
|
|
54
|
+
return { provider, ...(typeof usage.plan === 'string' ? { plan: usage.plan } : {}), windows };
|
|
55
|
+
}
|
|
11
56
|
/**
|
|
12
57
|
* The account a run names, `provider/model`, split for the `agent` request's per-run `provider`/`model` override.
|
|
13
58
|
* Refused before any request when malformed or when it carries an `@profile` pin: the pin only reaches the engine as
|
|
@@ -20,15 +65,28 @@ function account(model) {
|
|
|
20
65
|
return { provider: model.slice(0, at).toLowerCase(), model: model.slice(at + 1) }; // the engine lowercases ids
|
|
21
66
|
}
|
|
22
67
|
export function createRuns(ctx) {
|
|
68
|
+
// The plan window the engine last read for the run's provider: `models.authStatus` answers from its cache without
|
|
69
|
+
// blocking. Nothing reported, or no answer in time, is no window.
|
|
70
|
+
const planWindowOf = async (agentId, provider) => {
|
|
71
|
+
try {
|
|
72
|
+
const auth = await ctx.request('models.authStatus', { agentId }, { timeoutMs: STATUS_MS });
|
|
73
|
+
const row = (auth.providers ?? []).find((e) => typeof e !== 'string' && e?.provider === provider);
|
|
74
|
+
return planOf(provider, row);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
23
80
|
const run = async (spec, on) => {
|
|
24
81
|
// Member boundary first: a member never speaks in another member's session, refused before any request.
|
|
25
82
|
if (!spec.sessionKey.startsWith(`agent:${spec.member}:`))
|
|
26
83
|
throw new Error(`refused: "${spec.sessionKey}" is not a session of member "${spec.member}"`);
|
|
27
84
|
const picked = spec.model === undefined ? undefined : account(spec.model);
|
|
85
|
+
for (const tool of spec.tools ?? [])
|
|
86
|
+
if (!ctx.tools.has(tool))
|
|
87
|
+
throw new Error(`refused: "${tool}" is not one of this kit's tools`);
|
|
28
88
|
const { agentId } = await ctx.ensure(spec.member);
|
|
29
|
-
const
|
|
30
|
-
if (registered)
|
|
31
|
-
ctx.bridge.register({ sessionKey: spec.sessionKey, member: spec.member });
|
|
89
|
+
const release = spec.register !== false ? ctx.bridge.register({ sessionKey: spec.sessionKey, member: spec.member }, spec.tools) : undefined;
|
|
32
90
|
let last = '';
|
|
33
91
|
let runId = ''; // gateway events for other runs carry a real runId and never match the empty one
|
|
34
92
|
let ended = false;
|
|
@@ -49,8 +107,17 @@ export function createRuns(ctx) {
|
|
|
49
107
|
on?.({ type: 'text', text: p.data.text });
|
|
50
108
|
}
|
|
51
109
|
else if (p.stream === 'tool' && typeof p.data?.name === 'string') {
|
|
52
|
-
// The real engine marks completion `phase: 'result'` (O11)
|
|
53
|
-
|
|
110
|
+
// The real engine marks completion `phase: 'result'` (O11). Its `update`, `input_delta` and `review` phases
|
|
111
|
+
// are progress inside the pair, not its end.
|
|
112
|
+
const d = p.data;
|
|
113
|
+
const end = d.phase === 'result' || d.phase === 'end';
|
|
114
|
+
if (d.phase !== 'start' && !end)
|
|
115
|
+
return;
|
|
116
|
+
on?.({ type: 'tool', name: d.name, phase: end ? 'end' : 'start',
|
|
117
|
+
...(typeof d.toolCallId === 'string' ? { id: d.toolCallId } : {}),
|
|
118
|
+
...(!end && isRecord(d.args) ? { input: d.args } : {}),
|
|
119
|
+
...(end && d.result !== undefined ? { output: d.result } : {}),
|
|
120
|
+
...(end && typeof d.isError === 'boolean' ? { error: d.isError } : {}) });
|
|
54
121
|
}
|
|
55
122
|
});
|
|
56
123
|
try {
|
|
@@ -68,7 +135,17 @@ export function createRuns(ctx) {
|
|
|
68
135
|
if (!row || !usable(row))
|
|
69
136
|
return { ok: false, kind: 'signed-out', message: `${picked.provider} is not signed in for ${spec.member}` };
|
|
70
137
|
}
|
|
71
|
-
|
|
138
|
+
// One `agent` request: its interim `accepted` frame names the run, its final frame (after the run) carries the
|
|
139
|
+
// run's usage. The wait below still decides how the run ended.
|
|
140
|
+
let accepted;
|
|
141
|
+
const ack = new Promise((resolve) => { accepted = resolve; });
|
|
142
|
+
// The run id is taken as the accepted frame lands, so no event read after it is missed.
|
|
143
|
+
const onAccepted = (payload) => {
|
|
144
|
+
if (isRecord(payload) && typeof payload.runId === 'string')
|
|
145
|
+
runId = payload.runId;
|
|
146
|
+
accepted(payload);
|
|
147
|
+
};
|
|
148
|
+
const final = ctx.request('agent', {
|
|
72
149
|
agentId,
|
|
73
150
|
sessionKey: spec.sessionKey,
|
|
74
151
|
message: spec.message,
|
|
@@ -77,13 +154,21 @@ export function createRuns(ctx) {
|
|
|
77
154
|
...(spec.system ? { extraSystemPrompt: spec.system } : {}),
|
|
78
155
|
...(spec.images ? { attachments: spec.images.map((image) => ({ mimeType: image.mimeType, content: image.data })) } : {}),
|
|
79
156
|
...(spec.thinking ? { thinking: spec.thinking } : {}),
|
|
80
|
-
});
|
|
157
|
+
}, { expectFinal: true, timeoutMs: WAIT_CLIENT_MS, onAccepted });
|
|
158
|
+
final.catch(() => { }); // a late failure is the wait's to report
|
|
159
|
+
const started = await Promise.race([ack, final]);
|
|
81
160
|
runId = started.runId;
|
|
82
161
|
const result = await ctx.request('agent.wait', { runId, timeoutMs: WAIT_MS }, { timeoutMs: WAIT_CLIENT_MS });
|
|
83
162
|
if (result.status === 'ok') {
|
|
84
163
|
const text = typeof result.terminalReply?.text === 'string' ? result.terminalReply.text : last;
|
|
85
164
|
on?.({ type: 'text', text }); // the final cumulative text
|
|
86
|
-
|
|
165
|
+
const done = await Promise.race([final.catch(() => undefined), delay(FINAL_GRACE_MS, undefined, { ref: false })]);
|
|
166
|
+
const meta = isRecord(done) && isRecord(done.result) && isRecord(done.result.meta) ? done.result.meta : {};
|
|
167
|
+
const agentMeta = isRecord(meta.agentMeta) ? meta.agentMeta : {};
|
|
168
|
+
const usage = usageOf(agentMeta);
|
|
169
|
+
const planWindow = typeof agentMeta.provider === 'string'
|
|
170
|
+
? await planWindowOf(agentId, agentMeta.provider.toLowerCase()) : undefined;
|
|
171
|
+
return { ok: true, text, ...(usage ? { usage } : {}), ...(planWindow ? { planWindow } : {}) };
|
|
87
172
|
}
|
|
88
173
|
if (result.stopReason === 'aborted' || (abortedByEngine && result.status !== 'ok'))
|
|
89
174
|
return { ok: false, aborted: true };
|
|
@@ -98,8 +183,7 @@ export function createRuns(ctx) {
|
|
|
98
183
|
finally {
|
|
99
184
|
ended = true;
|
|
100
185
|
unsubscribe();
|
|
101
|
-
|
|
102
|
-
ctx.bridge.unregister(spec.sessionKey);
|
|
186
|
+
release?.();
|
|
103
187
|
}
|
|
104
188
|
};
|
|
105
189
|
return {
|
|
@@ -144,34 +144,65 @@ export function fakeGateway(script) {
|
|
|
144
144
|
const startRun = (params, bridgeSock) => {
|
|
145
145
|
if (params.agentId != null && !agents.has(String(params.agentId)))
|
|
146
146
|
throw new Error(`unknown agent: ${params.agentId}`);
|
|
147
|
-
|
|
147
|
+
// Like the engine: the provider/model the run is called on, its own override or the member's (here openai).
|
|
148
|
+
const run = { runId: randomUUID(), sessionKey: String(params.sessionKey ?? ''), seq: 0,
|
|
149
|
+
provider: String(params.provider ?? 'openai'), model: String(params.model ?? 'gpt-5.1'), waiters: [] };
|
|
148
150
|
runs.set(run.runId, run);
|
|
149
151
|
const message = String(params.message ?? '');
|
|
150
152
|
const text = `fake: ${message}`;
|
|
151
153
|
setTimeout(() => {
|
|
152
154
|
void (async () => {
|
|
153
155
|
const table = bridgeSock ? pluginTable(bridgeSock) : undefined;
|
|
156
|
+
let n = 0;
|
|
154
157
|
for (const call of toolCalls(message)) {
|
|
155
|
-
|
|
158
|
+
const toolCallId = `call-${++n}`;
|
|
159
|
+
emitRun(run, 'tool', { phase: 'start', name: call.name, toolCallId, args: call.input });
|
|
156
160
|
// As the plugin: an engine builtin is gated unless the app opted out, and an allowed one never calls back.
|
|
157
161
|
const builtin = !!table && !table.tools.some((t) => t.name === call.name);
|
|
162
|
+
let output = `${call.name} ran`;
|
|
163
|
+
let isError = false;
|
|
158
164
|
if (bridgeSock && !(builtin && table?.gateBuiltins === false)) {
|
|
159
165
|
try {
|
|
160
166
|
const gate = await bridgeRequest(bridgeSock, { kind: 'gate', key: run.sessionKey, tool: call.name, input: call.input });
|
|
161
|
-
if (gate.allow
|
|
162
|
-
|
|
167
|
+
if (!gate.allow) {
|
|
168
|
+
isError = true;
|
|
169
|
+
output = String(gate.reason ?? 'blocked');
|
|
170
|
+
}
|
|
171
|
+
else if (!builtin) {
|
|
172
|
+
const done = await bridgeRequest(bridgeSock, { kind: 'call', key: run.sessionKey, permit: gate.permit, tool: call.name, input: call.input });
|
|
173
|
+
isError = done.ok !== true;
|
|
174
|
+
output = String(done.ok === true ? done.text : done.reason);
|
|
175
|
+
}
|
|
163
176
|
}
|
|
164
177
|
catch { /* no bridge or refused: the tool pair still plays, nothing is called */ }
|
|
165
178
|
}
|
|
166
|
-
// Completion rides `phase: 'result'` like the real engine (O11), not `'end'
|
|
167
|
-
emitRun(run, 'tool', { name: call.name,
|
|
179
|
+
// Completion rides `phase: 'result'` like the real engine (O11), not `'end'`, with its result.
|
|
180
|
+
emitRun(run, 'tool', { phase: 'result', name: call.name, toolCallId, isError,
|
|
181
|
+
result: { content: [{ type: 'text', text: output }] } });
|
|
168
182
|
}
|
|
169
183
|
emitRun(run, 'assistant', { text });
|
|
184
|
+
// The engine sums the run's usage (zero buckets omitted); the fake counts characters.
|
|
185
|
+
run.usage = { input: message.length, output: text.length, total: message.length + text.length };
|
|
170
186
|
setTimeout(() => finish(run, { status: 'ok', terminalReply: { text } }), ABORT_WINDOW_MS);
|
|
171
187
|
})();
|
|
172
188
|
}, 0);
|
|
173
|
-
return { runId: run.runId };
|
|
189
|
+
return { runId: run.runId, status: 'accepted' };
|
|
174
190
|
};
|
|
191
|
+
/** The `agent` request's final frame (pin): the run's end with `result.meta.agentMeta`, once the run is over. */
|
|
192
|
+
const finalOf = (runId) => new Promise((resolve) => {
|
|
193
|
+
const run = runs.get(runId);
|
|
194
|
+
const settle = (result) => {
|
|
195
|
+
const ok = result.status === 'ok';
|
|
196
|
+
resolve({ runId, status: ok ? 'ok' : 'error', summary: ok ? 'completed' : 'failed',
|
|
197
|
+
...(ok ? {} : { stopReason: result.stopReason }),
|
|
198
|
+
result: { payloads: ok ? [{ text: result.terminalReply?.text }] : [],
|
|
199
|
+
meta: { agentMeta: { provider: run.provider, model: run.model, ...(run.usage ? { usage: run.usage } : {}) } } } });
|
|
200
|
+
};
|
|
201
|
+
if (run.result)
|
|
202
|
+
settle(run.result);
|
|
203
|
+
else
|
|
204
|
+
run.waiters.push(settle);
|
|
205
|
+
});
|
|
175
206
|
const defaults = {
|
|
176
207
|
health: () => ({ ok: true, plugins: { loaded: [] } }),
|
|
177
208
|
'agents.list': () => ({
|
|
@@ -339,10 +370,16 @@ export function fakeGateway(script) {
|
|
|
339
370
|
throw new Error(closedWhy());
|
|
340
371
|
return hello();
|
|
341
372
|
},
|
|
342
|
-
request: async (method, params) => {
|
|
373
|
+
request: async (method, params, o) => {
|
|
343
374
|
if (!entry.open)
|
|
344
375
|
throw new Error(closedWhy());
|
|
345
|
-
|
|
376
|
+
const reply = await dispatch(method, params, sock);
|
|
377
|
+
// expectFinal: the interim accepted frame goes to onAccepted, the request settles with the final one.
|
|
378
|
+
if (!o?.expectFinal || !isPlainObject(reply) || reply.status !== 'accepted' || typeof reply.runId !== 'string'
|
|
379
|
+
|| !runs.has(reply.runId))
|
|
380
|
+
return reply;
|
|
381
|
+
o.onAccepted?.(reply);
|
|
382
|
+
return finalOf(reply.runId);
|
|
346
383
|
},
|
|
347
384
|
onEvent: (fn) => (entry.events.add(fn), () => entry.events.delete(fn)),
|
|
348
385
|
onClose: (fn) => (entry.closes.add(fn), () => entry.closes.delete(fn)),
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { fakeGateway, type FakeScript } from './fake-gateway.ts';
|
|
2
2
|
export { openclawContract, type ContractFixture } from './contract.ts';
|
|
3
|
-
export { startModelStub, useModelStub, releaseStub, stubHolding, toolCalls, type ModelStub, type ModelStubOptions, type StubCall } from './model-stub.ts';
|
|
3
|
+
export { STUB_USAGE, startModelStub, useModelStub, releaseStub, stubHolding, toolCalls, type ModelStub, type ModelStubOptions, type StubCall } from './model-stub.ts';
|
package/dist/testing/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// The kit's fakes, contract suite and scripted model (5.11). Built in O7.
|
|
2
2
|
export { fakeGateway } from "./fake-gateway.js";
|
|
3
3
|
export { openclawContract } from "./contract.js";
|
|
4
|
-
export { startModelStub, useModelStub, releaseStub, stubHolding, toolCalls } from "./model-stub.js";
|
|
4
|
+
export { STUB_USAGE, startModelStub, useModelStub, releaseStub, stubHolding, toolCalls } from "./model-stub.js";
|
|
@@ -4,6 +4,12 @@ export type StubCall = {
|
|
|
4
4
|
path: string;
|
|
5
5
|
body: any;
|
|
6
6
|
};
|
|
7
|
+
/** The token usage every stub reply reports when asked (`stream_options.include_usage`), per model call. */
|
|
8
|
+
export declare const STUB_USAGE: {
|
|
9
|
+
readonly prompt_tokens: 11;
|
|
10
|
+
readonly completion_tokens: 7;
|
|
11
|
+
readonly total_tokens: 18;
|
|
12
|
+
};
|
|
7
13
|
export type ModelStub = {
|
|
8
14
|
port: number;
|
|
9
15
|
url: string;
|
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
// ask permission hold the turn (after its tool calls) until `releaseStub` lets it finish
|
|
11
11
|
// [route ID] asked who should take a request: ID, fairly sure; [route ?]: torn evenly; neither: Chief, sure
|
|
12
12
|
// anything else reply `stub <bot>: done with "<the last line of the message>"`
|
|
13
|
+
// Every scripted reply that asks `stream_options.include_usage` ends with a usage chunk of STUB_USAGE, as OpenAI does.
|
|
13
14
|
// The bot id (`idPattern`, default /Your id is ([a-z0-9-]+)\./) is read from the system prompt, and routing
|
|
14
15
|
// requests start with `routingMarker` (default '[routing]'); pass both explicitly to mirror another app's grammar.
|
|
15
16
|
import { createServer } from 'node:http';
|
|
16
17
|
import { randomUUID } from 'node:crypto';
|
|
18
|
+
/** The token usage every stub reply reports when asked (`stream_options.include_usage`), per model call. */
|
|
19
|
+
export const STUB_USAGE = { prompt_tokens: 11, completion_tokens: 7, total_tokens: 18 };
|
|
17
20
|
/** The tool calls in the script, each with its arguments. Braces are balanced, so a script may nest. */
|
|
18
21
|
export function toolCalls(text) {
|
|
19
22
|
const out = [];
|
|
@@ -126,9 +129,14 @@ export function startModelStub(script = [], o = {}) {
|
|
|
126
129
|
const id = randomUUID();
|
|
127
130
|
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
|
|
128
131
|
const send = (delta, finish = null) => res.write(`data: ${JSON.stringify({ id, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model: body.model, choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`);
|
|
132
|
+
const usage = () => {
|
|
133
|
+
if (body.stream_options?.include_usage === true)
|
|
134
|
+
res.write(`data: ${JSON.stringify({ id, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model: body.model, choices: [], usage: STUB_USAGE })}\n\n`);
|
|
135
|
+
};
|
|
129
136
|
if (next) {
|
|
130
137
|
send({ role: 'assistant', tool_calls: [{ index: 0, id: `call_${id}`, type: 'function', function: { name: next.name, arguments: JSON.stringify(next.input) } }] });
|
|
131
138
|
send({}, 'tool_calls');
|
|
139
|
+
usage();
|
|
132
140
|
return void res.end('data: [DONE]\n\n');
|
|
133
141
|
}
|
|
134
142
|
let text = queue.length ? queue.shift() : done();
|
|
@@ -141,6 +149,7 @@ export function startModelStub(script = [], o = {}) {
|
|
|
141
149
|
}
|
|
142
150
|
send({ role: 'assistant', content: text });
|
|
143
151
|
send({}, 'stop');
|
|
152
|
+
usage();
|
|
144
153
|
res.end('data: [DONE]\n\n');
|
|
145
154
|
});
|
|
146
155
|
return new Promise((resolve) => {
|
package/dist/types.d.ts
CHANGED
|
@@ -34,6 +34,7 @@ export interface RunSpec extends RunRef {
|
|
|
34
34
|
}[];
|
|
35
35
|
thinking?: 'off' | 'low' | 'medium' | 'high';
|
|
36
36
|
model?: string;
|
|
37
|
+
tools?: string[];
|
|
37
38
|
register?: boolean;
|
|
38
39
|
}
|
|
39
40
|
export type RunEvent = {
|
|
@@ -43,10 +44,36 @@ export type RunEvent = {
|
|
|
43
44
|
type: 'tool';
|
|
44
45
|
name: string;
|
|
45
46
|
phase: 'start' | 'end';
|
|
47
|
+
id?: string;
|
|
48
|
+
input?: Record<string, unknown>;
|
|
49
|
+
output?: unknown;
|
|
50
|
+
error?: boolean;
|
|
51
|
+
};
|
|
52
|
+
/** Token usage of one run as the engine totals it (every model call of the run, compaction included). */
|
|
53
|
+
export type RunUsage = {
|
|
54
|
+
input?: number;
|
|
55
|
+
output?: number;
|
|
56
|
+
cacheRead?: number;
|
|
57
|
+
cacheWrite?: number;
|
|
58
|
+
reasoning?: number;
|
|
59
|
+
total?: number;
|
|
60
|
+
costUsd?: number;
|
|
61
|
+
};
|
|
62
|
+
/** A subscription's quota windows, as the engine last read them from the provider (never estimated by the kit). */
|
|
63
|
+
export type PlanWindow = {
|
|
64
|
+
provider: string;
|
|
65
|
+
plan?: string;
|
|
66
|
+
windows: {
|
|
67
|
+
label: string;
|
|
68
|
+
usedPercent: number;
|
|
69
|
+
resetAt?: number;
|
|
70
|
+
}[];
|
|
46
71
|
};
|
|
47
72
|
export type RunEnd = {
|
|
48
73
|
ok: true;
|
|
49
74
|
text: string;
|
|
75
|
+
usage?: RunUsage;
|
|
76
|
+
planWindow?: PlanWindow;
|
|
50
77
|
} | {
|
|
51
78
|
ok: false;
|
|
52
79
|
aborted: true;
|
|
@@ -109,6 +136,8 @@ export interface GatewayTransport {
|
|
|
109
136
|
request(method: string, params?: unknown, o?: {
|
|
110
137
|
timeoutMs?: number;
|
|
111
138
|
signal?: AbortSignal;
|
|
139
|
+
expectFinal?: boolean;
|
|
140
|
+
onAccepted?: (payload: unknown) => void;
|
|
112
141
|
}): Promise<unknown>;
|
|
113
142
|
onEvent(fn: (e: {
|
|
114
143
|
event: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@byokit/openclaw",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "The OpenClaw runtime kit: the pinned engine's full operator surface with plain-words helpers for members, sign-in, runs and approvals.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -51,9 +51,9 @@
|
|
|
51
51
|
"prepack": "tsc -b && node ../../scripts/fix-words-dts.cjs"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@byokit/link": "0.
|
|
55
|
-
"@byokit/relay": "0.3.
|
|
56
|
-
"@byokit/reach": "0.
|
|
54
|
+
"@byokit/link": "0.5.0",
|
|
55
|
+
"@byokit/relay": "0.3.1",
|
|
56
|
+
"@byokit/reach": "0.4.0",
|
|
57
57
|
"@byokit/seal": "0.2.0",
|
|
58
58
|
"@openclaw/gateway-client": "2026.8.1",
|
|
59
59
|
"@openclaw/gateway-protocol": "2026.8.1",
|