@vibedev-si/dsh-vibedev 0.3.1 → 0.3.2

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/README.md CHANGED
@@ -97,6 +97,7 @@
97
97
  - 图片按张计费,视频按秒计费(参考视频的时长也计入),音乐和播客按次计费,语音按原始秒计费(按服务商返回的原始时长,可能和播放时长不同),转写按分钟计费。`media_models` 会列出当前价格;目录没有给出价格的模型显示「未定价」,不代表免费。
98
98
  - 视频和音频只在成功时扣费,失败和取消的任务不扣。视频提交后无法取消。
99
99
  - 同一次操作只会生成一次:提交没有得到确认时,会用同一个请求键向网关确认结果;查询或下载失败只会重新查询、刷新链接,不会重新提交。结果已经生成、但费用还在结算(`settlement_pending`)时,结果照常保存,不会重新生成。
100
+ - 余额在每次付费请求结束后(对话回复、生成、转写)马上重新读取,侧栏和设置页几秒内就能看到扣费后的余额。
100
101
  - 插件设置里的「每次付费生成前先询问」默认关闭。打开后,每次付费的媒体请求之前都会先弹出确认,并显示预估金额。
101
102
 
102
103
  ## 设置
@@ -162,6 +163,8 @@ Everything is paid from your VibeDev balance; the DeepSeek account stays DeepSee
162
163
 
163
164
  **Gateway audio (0.3.1)**: audio models are recognized from the catalog's structured `audio` block (music, podcast, speech, transcription), never from their names; speech generation (Seed) joins music and podcasts; prices show their unit (per generation, per second of original audio, per minute) and "not priced" when the catalog states none; the `vibedevMedia` service gains `startAudio` with an operation key and query-only result reading.
164
165
 
166
+ **Balance without the wait (0.3.2)**: the balance is read again right after a paid request ends (a chat reply, a generation, a transcription) and the account pages hear it through a long poll, so a charge shows within seconds instead of after up to two minutes. A page that already has a balance is answered at once while a newer one is read behind it; reads after charges are at least five seconds apart.
167
+
165
168
  **Models**: the list comes from the VibeDev gateway for your account, so nothing is listed before you sign in; models appear right after, without a restart. Using a VibeDev model while signed out, or after the sign-in has ended, asks you to sign in; a used-up balance says so with the top-up link. These messages are separate from the DeepSeek account's. An expired access token is renewed and the request sent again, without repeating any output.
166
169
 
167
170
  **Web search**: the plugin registers the web search provider `vibedev-gateway`, signed in with your VibeDev account. Registering does not select it: DeepSeek Harness keeps its own search unless the profile sets `searchProvider: vibedev-gateway` on the `web` row (the VibeDev app does). Searching is free, but the gateway refuses every request, searches included, while the balance is zero.
@@ -1,4 +1,4 @@
1
- THIRD-PARTY NOTICES · @vibedev-si/dsh-vibedev 0.3.1
1
+ THIRD-PARTY NOTICES · @vibedev-si/dsh-vibedev 0.3.2
2
2
 
3
3
  This package bundles code from the projects below. Each is used under the licence that
4
4
  follows its name; the licence texts are reproduced as the projects ship them.
package/client/client.js CHANGED
@@ -9,6 +9,8 @@ window.__ModuleLoader__.load({ id: "@vibedev-si/dsh-vibedev", factory: (require)
9
9
  const ACCOUNT_ROUTE = "/api/dsh-vibedev/account";
10
10
  const PENDING_POLL_MS = 2e3;
11
11
  const IDLE_POLL_MS = 6e4;
12
+ /** After a failed long poll, the next one waits this long (the Host may be restarting). */
13
+ const WATCH_RETRY_MS = 5e3;
12
14
  /** Who to show: the nickname, else the email. */
13
15
  function displayName(view) {
14
16
  return view?.user?.nickname ?? view?.user?.email;
@@ -33,6 +35,8 @@ window.__ModuleLoader__.load({ id: "@vibedev-si/dsh-vibedev", factory: (require)
33
35
  reading;
34
36
  stopped = true;
35
37
  unsubscribeFocus;
38
+ /** The open long poll, when one is. */
39
+ watching;
36
40
  /**
37
41
  * @param options - fetch, timers, window opening and focus (test seams).
38
42
  */
@@ -82,17 +86,61 @@ window.__ModuleLoader__.load({ id: "@vibedev-si/dsh-vibedev", factory: (require)
82
86
  globalThis.clearTimeout(handle);
83
87
  }))(this.timer);
84
88
  this.timer = void 0;
89
+ this.watching?.abort();
90
+ this.watching = void 0;
85
91
  }
86
- schedule() {
87
- if (this.stopped) return;
92
+ later(callback, ms) {
88
93
  if (this.timer !== void 0) (this.options.clearTimeout ?? ((handle) => {
89
94
  globalThis.clearTimeout(handle);
90
95
  }))(this.timer);
91
- const delay = this.state.view?.pending !== void 0 ? PENDING_POLL_MS : IDLE_POLL_MS;
92
- this.timer = (this.options.setTimeout ?? ((callback, ms) => globalThis.setTimeout(callback, ms)))(() => {
96
+ this.timer = (this.options.setTimeout ?? ((run, delay) => globalThis.setTimeout(run, delay)))(() => {
97
+ this.timer = void 0;
98
+ callback();
99
+ }, ms);
100
+ }
101
+ schedule() {
102
+ if (this.stopped) return;
103
+ const view = this.state.view;
104
+ if (view?.pending === void 0 && typeof view?.revision === "number") {
105
+ if (this.timer !== void 0) (this.options.clearTimeout ?? ((handle) => {
106
+ globalThis.clearTimeout(handle);
107
+ }))(this.timer);
93
108
  this.timer = void 0;
109
+ this.watch(view.revision);
110
+ return;
111
+ }
112
+ this.watching?.abort();
113
+ this.watching = void 0;
114
+ this.later(() => {
94
115
  this.refresh();
95
- }, delay);
116
+ }, view?.pending !== void 0 ? PENDING_POLL_MS : IDLE_POLL_MS);
117
+ }
118
+ /** Keep one long poll open for changes past `revision`. */
119
+ watch(revision) {
120
+ if (this.watching !== void 0) return;
121
+ const controller = new AbortController();
122
+ this.watching = controller;
123
+ (async () => {
124
+ let failed = false;
125
+ try {
126
+ const response = await this.fetch(`${ACCOUNT_ROUTE}?since=${revision}`, { signal: controller.signal });
127
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
128
+ const view = await response.json();
129
+ if (!controller.signal.aborted && (view.revision ?? 0) >= (this.state.view?.revision ?? 0)) this.set({
130
+ view,
131
+ loadFailed: false
132
+ });
133
+ } catch {
134
+ failed = !controller.signal.aborted;
135
+ } finally {
136
+ if (this.watching === controller) this.watching = void 0;
137
+ }
138
+ if (this.stopped || controller.signal.aborted) return;
139
+ if (failed) this.later(() => {
140
+ this.refresh();
141
+ }, WATCH_RETRY_MS);
142
+ else this.schedule();
143
+ })();
96
144
  }
97
145
  /**
98
146
  * Read the account now.
@@ -7,6 +7,9 @@
7
7
  * app's account, the plugin's own sign-in, a development key, or none), who
8
8
  * is signed in, the balance, a sign-in waiting for the browser, and how many
9
9
  * VibeDev models the pickers list; `?fresh=1` reads the balance again;
10
+ * `?since=<revision>` holds the request until the person or the balance
11
+ * changes (a charge is read again right after it ends), so the pages show a
12
+ * new balance within seconds instead of on their next minute poll;
10
13
  * - `POST /api/dsh-vibedev/account/sign-in` — `{ open?: boolean }`: start the
11
14
  * plugin's own sign-in; answers the authorization link (`open: false` leaves
12
15
  * opening it to the page);
@@ -58,6 +61,11 @@ export interface AccountView {
58
61
  };
59
62
  /** The VibeDev account is the app's main account (the VibeDev app): its row leads the sidebar foot and Settings. */
60
63
  readonly primary: boolean;
64
+ /**
65
+ * Grows whenever the person or the balance changes; `GET …/account?since=<revision>`
66
+ * answers as soon as it grows (or after a while with the same view).
67
+ */
68
+ readonly revision: number;
61
69
  }
62
70
  /** What the account routes need from the plugin. */
63
71
  export interface AccountServiceOptions {
@@ -74,16 +82,51 @@ export interface AccountServiceOptions {
74
82
  readonly primary?: boolean;
75
83
  readonly fetch?: typeof globalThis.fetch;
76
84
  readonly now?: () => number;
85
+ /** Timer seams for the reads after a charge and the long poll. */
86
+ readonly setTimeout?: (callback: () => void, ms: number) => unknown;
87
+ readonly clearTimeout?: (handle: unknown) => void;
77
88
  }
78
89
  /** The account state behind the pages. */
79
90
  export declare class AccountService {
80
91
  private readonly options;
81
92
  private balanceCache;
93
+ /** A background read of a stale balance, so only one runs at a time. */
94
+ private revalidating;
82
95
  private readonly now;
96
+ private revision;
97
+ /** What the last read showed, to tell whether the next one changed anything. */
98
+ private lastSeen;
99
+ private readonly waiters;
100
+ private settleTimers;
101
+ /** Grows with every credential change; a read started before one is dropped. */
102
+ private epoch;
103
+ /** When the last read after a charge started, to space them out. */
104
+ private lastSpendReadAt;
83
105
  /**
84
106
  * @param options - origin, credential chain, the plugin's sign-in and the route's model state.
85
107
  */
86
108
  constructor(options: AccountServiceOptions);
109
+ private timeout;
110
+ private cancelTimeout;
111
+ /** Something the pages show changed: answer every waiting `?since=` request. */
112
+ changed(): void;
113
+ /** The account changed hands (sign-in, sign-out, switch): forget the balance and tell the pages. */
114
+ credentialChanged(): void;
115
+ /**
116
+ * A request that may have cost money ended (a chat reply, a generation, a
117
+ * transcription): read the balance again shortly, and once more later if the
118
+ * charge had not settled yet. Repeated calls in a burst share the reads.
119
+ */
120
+ spent(): void;
121
+ /** Read the balance of the current credential now (the pages hear about a change through the long poll). */
122
+ private readNow;
123
+ /**
124
+ * Wait until the view changes past `since`, or a while.
125
+ * @param since - the revision the page shows.
126
+ * @param signal - the page went away.
127
+ * @returns once there is something new, or the wait ran out.
128
+ */
129
+ wait(since: number, signal?: AbortSignal): Promise<void>;
87
130
  /** Links to the gateway's pages. */
88
131
  links(): AccountView['links'];
89
132
  /**
@@ -92,8 +135,12 @@ export declare class AccountService {
92
135
  * @returns the view.
93
136
  */
94
137
  view(fresh?: boolean): Promise<AccountView>;
95
- /** `GET /v1/account/auth/me` for one token, reused for a minute. */
138
+ /**
139
+ * `GET /v1/account/auth/me` for one token. A read younger than the TTL is
140
+ * reused; an older one is answered at once while a new read runs behind it.
141
+ */
96
142
  private me;
143
+ private read;
97
144
  /**
98
145
  * Start the plugin's own sign-in.
99
146
  * @param open - open the system browser (false: the page opens the link itself).
@@ -111,6 +158,8 @@ export declare class AccountService {
111
158
  * @returns false when the credential is the app's account, which is signed out in the app.
112
159
  */
113
160
  signOut(): Promise<boolean>;
161
+ /** Stop the pending reads after a charge (the plugin is going away). */
162
+ dispose(): void;
114
163
  }
115
164
  /**
116
165
  * The account routes.
@@ -31,6 +31,11 @@ export interface GatewayHttpOptions {
31
31
  readonly userAgent: string;
32
32
  readonly fetch?: typeof globalThis.fetch;
33
33
  readonly sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
34
+ /** Told about every request the gateway accepted (2xx), after the fact. */
35
+ readonly onAccepted?: (request: {
36
+ readonly method: string;
37
+ readonly path: string;
38
+ }) => void;
34
39
  }
35
40
  /** One request. */
36
41
  export interface GatewayRequest {
package/lib/index.js CHANGED
@@ -19,15 +19,41 @@ import { defineTool } from "@deepseek-ai/dsh-tools";
19
19
  //#region src/account/index.ts
20
20
  /** Path prefix of the plugin's Host routes. */
21
21
  const ACCOUNT_ROUTE_PREFIX = "/api/dsh-vibedev/account";
22
- /** How long a balance read is reused before the gateway is asked again. */
23
- const BALANCE_TTL_MS = 6e4;
22
+ /**
23
+ * How long a balance read is reused as it is. After that the cached value is
24
+ * still answered at once while a new read runs in the background (the page
25
+ * hears the result through the long poll), so a page never waits on the gateway
26
+ * when it already has a balance.
27
+ */
28
+ const BALANCE_TTL_MS = 3e4;
29
+ /** How long `?since=` holds a request open when nothing changes. */
30
+ const WAIT_MS = 25e3;
31
+ /**
32
+ * When the balance is read again after a charge: the gateway settles a charge
33
+ * shortly after the request ends, so it is read a moment later, and once more
34
+ * if the first read still shows the old amount.
35
+ */
36
+ const SETTLE_READS_MS = [1500, 8e3];
37
+ /** Reads after charges are at least this far apart. */
38
+ const MIN_READ_SPACING_MS = 5e3;
24
39
  const record$8 = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
25
40
  const text$8 = (value) => typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
26
41
  /** The account state behind the pages. */
27
42
  var AccountService = class {
28
43
  options;
29
44
  balanceCache;
45
+ /** A background read of a stale balance, so only one runs at a time. */
46
+ revalidating;
30
47
  now;
48
+ revision = 0;
49
+ /** What the last read showed, to tell whether the next one changed anything. */
50
+ lastSeen;
51
+ waiters = /* @__PURE__ */ new Set();
52
+ settleTimers = [];
53
+ /** Grows with every credential change; a read started before one is dropped. */
54
+ epoch = 0;
55
+ /** When the last read after a charge started, to space them out. */
56
+ lastSpendReadAt = Number.NEGATIVE_INFINITY;
31
57
  /**
32
58
  * @param options - origin, credential chain, the plugin's sign-in and the route's model state.
33
59
  */
@@ -35,6 +61,73 @@ var AccountService = class {
35
61
  this.options = options;
36
62
  this.now = options.now ?? Date.now;
37
63
  }
64
+ timeout(callback, ms) {
65
+ return (this.options.setTimeout ?? ((run, delay) => {
66
+ const handle = globalThis.setTimeout(run, delay);
67
+ handle.unref?.();
68
+ return handle;
69
+ }))(callback, ms);
70
+ }
71
+ cancelTimeout(handle) {
72
+ (this.options.clearTimeout ?? ((value) => {
73
+ globalThis.clearTimeout(value);
74
+ }))(handle);
75
+ }
76
+ /** Something the pages show changed: answer every waiting `?since=` request. */
77
+ changed() {
78
+ this.revision++;
79
+ const waiting = [...this.waiters];
80
+ this.waiters.clear();
81
+ for (const wake of waiting) wake();
82
+ }
83
+ /** The account changed hands (sign-in, sign-out, switch): forget the balance and tell the pages. */
84
+ credentialChanged() {
85
+ this.epoch++;
86
+ this.balanceCache = void 0;
87
+ this.revalidating = void 0;
88
+ this.lastSeen = void 0;
89
+ this.changed();
90
+ }
91
+ /**
92
+ * A request that may have cost money ended (a chat reply, a generation, a
93
+ * transcription): read the balance again shortly, and once more later if the
94
+ * charge had not settled yet. Repeated calls in a burst share the reads.
95
+ */
96
+ spent() {
97
+ for (const handle of this.settleTimers) this.cancelTimeout(handle);
98
+ const before = this.lastSeen;
99
+ const spacing = Math.max(0, this.lastSpendReadAt + MIN_READ_SPACING_MS - this.now());
100
+ this.settleTimers = SETTLE_READS_MS.map((delay, index) => this.timeout(() => {
101
+ if (index > 0 && this.lastSeen !== before) return;
102
+ this.readNow();
103
+ }, Math.max(delay, spacing + (index === 0 ? 0 : delay - SETTLE_READS_MS[0]))));
104
+ }
105
+ /** Read the balance of the current credential now (the pages hear about a change through the long poll). */
106
+ async readNow() {
107
+ this.lastSpendReadAt = this.now();
108
+ const credential = await this.options.chain.resolve().catch(() => void 0);
109
+ if (credential !== void 0) await this.me(credential.token, true);
110
+ }
111
+ /**
112
+ * Wait until the view changes past `since`, or a while.
113
+ * @param since - the revision the page shows.
114
+ * @param signal - the page went away.
115
+ * @returns once there is something new, or the wait ran out.
116
+ */
117
+ async wait(since, signal) {
118
+ if (since !== this.revision || signal?.aborted === true) return;
119
+ await new Promise((resolve) => {
120
+ const done = () => {
121
+ this.waiters.delete(done);
122
+ this.cancelTimeout(timer);
123
+ signal?.removeEventListener("abort", done);
124
+ resolve();
125
+ };
126
+ const timer = this.timeout(done, WAIT_MS);
127
+ this.waiters.add(done);
128
+ signal?.addEventListener("abort", done, { once: true });
129
+ });
130
+ }
38
131
  /** Links to the gateway's pages. */
39
132
  links() {
40
133
  const origin = this.options.origin;
@@ -62,20 +155,39 @@ var AccountService = class {
62
155
  ...pending === void 0 ? {} : { pending },
63
156
  ...attempt === void 0 ? {} : { attempt }
64
157
  };
65
- if (credential === void 0) return base;
66
- const grantUser = credential.kind === "plugin" ? await this.options.login.user().catch(() => void 0) : void 0;
67
- const me = await this.me(credential.token, fresh);
158
+ if (credential === void 0) return {
159
+ ...base,
160
+ revision: this.revision
161
+ };
162
+ const [grantUser, me] = await Promise.all([credential.kind === "plugin" ? this.options.login.user().catch(() => void 0) : Promise.resolve(void 0), this.me(credential.token, fresh)]);
68
163
  const user = me.user ?? userOf(grantUser);
69
164
  return {
70
165
  ...base,
71
166
  ...user === void 0 ? {} : { user },
72
- ...me.balance === void 0 ? {} : { balance: me.balance }
167
+ ...me.balance === void 0 ? {} : { balance: me.balance },
168
+ revision: this.revision
73
169
  };
74
170
  }
75
- /** `GET /v1/account/auth/me` for one token, reused for a minute. */
171
+ /**
172
+ * `GET /v1/account/auth/me` for one token. A read younger than the TTL is
173
+ * reused; an older one is answered at once while a new read runs behind it.
174
+ */
76
175
  me(token, fresh) {
77
176
  const cached = this.balanceCache;
78
- if (!fresh && cached !== void 0 && cached.token === token && this.now() - cached.at < BALANCE_TTL_MS) return cached.value;
177
+ if (!fresh && cached !== void 0 && cached.token === token) {
178
+ if (this.now() - cached.at >= BALANCE_TTL_MS && this.revalidating === void 0) {
179
+ const next = this.read(token);
180
+ this.revalidating = next;
181
+ next.finally(() => {
182
+ if (this.revalidating === next) this.revalidating = void 0;
183
+ });
184
+ }
185
+ return cached.value;
186
+ }
187
+ return this.read(token);
188
+ }
189
+ read(token) {
190
+ const epoch = this.epoch;
79
191
  const value = (async () => {
80
192
  try {
81
193
  const response = await (this.options.fetch ?? globalThis.fetch)(`${this.options.origin}/v1/account/auth/me`, {
@@ -111,13 +223,28 @@ var AccountService = class {
111
223
  return {};
112
224
  }
113
225
  })();
114
- this.balanceCache = {
226
+ const previous = this.balanceCache;
227
+ if (previous === void 0 || previous.token !== token) this.balanceCache = {
115
228
  token,
116
229
  at: this.now(),
117
230
  value
118
231
  };
119
232
  value.then((read) => {
120
- if (read.balance === void 0 && this.balanceCache?.value === value) this.balanceCache = void 0;
233
+ if (epoch !== this.epoch) return;
234
+ if (read.balance === void 0) {
235
+ if (this.balanceCache?.value === value) this.balanceCache = void 0;
236
+ return;
237
+ }
238
+ this.balanceCache = {
239
+ token,
240
+ at: this.now(),
241
+ value
242
+ };
243
+ const seen = JSON.stringify(read);
244
+ if (this.lastSeen !== void 0 && this.lastSeen !== seen) {
245
+ this.lastSeen = seen;
246
+ this.changed();
247
+ } else this.lastSeen = seen;
121
248
  });
122
249
  return value;
123
250
  }
@@ -145,9 +272,17 @@ var AccountService = class {
145
272
  async signOut() {
146
273
  if ((await this.options.chain.resolve().catch(() => void 0))?.kind === "account") return false;
147
274
  await this.options.login.signOut();
148
- this.balanceCache = void 0;
275
+ this.credentialChanged();
149
276
  return true;
150
277
  }
278
+ /** Stop the pending reads after a charge (the plugin is going away). */
279
+ dispose() {
280
+ for (const handle of this.settleTimers) this.cancelTimeout(handle);
281
+ this.settleTimers = [];
282
+ const waiting = [...this.waiters];
283
+ this.waiters.clear();
284
+ for (const wake of waiting) wake();
285
+ }
151
286
  };
152
287
  function userOf(user) {
153
288
  if (user === void 0) return void 0;
@@ -189,7 +324,10 @@ function accountRoutes(account) {
189
324
  requestBody: "buffered",
190
325
  fetch: async (request) => {
191
326
  try {
192
- return json(200, await account.view(new URL(request.url, "http://host").searchParams.get("fresh") === "1"));
327
+ const query = new URL(request.url, "http://host").searchParams;
328
+ const since = query.get("since");
329
+ if (since !== null && /^\d{1,15}$/.test(since)) await account.wait(Number(since), request.signal);
330
+ return json(200, await account.view(query.get("fresh") === "1"));
193
331
  } catch (error) {
194
332
  return failure(error);
195
333
  }
@@ -2761,6 +2899,7 @@ var GatewayAdapter = class extends LlmAdapter {
2761
2899
  const upstream = options.signal === void 0 ? consumer.signal : AbortSignal.any([options.signal, consumer.signal]);
2762
2900
  const streamIdleTimeoutMs = this.config.streamIdleTimeoutMs ?? 3e5;
2763
2901
  const watchdog = _usingCtx$1.u(idleWatchdog(upstream, streamIdleTimeoutMs, "LLM_STREAM_IDLE_TIMEOUT"));
2902
+ let answered = false;
2764
2903
  try {
2765
2904
  const containsImage = options.messages.some((message) => contentHasImage(message.content));
2766
2905
  if (containsImage && !model.input.includes("image")) throw new LlmError(`VibeDev model "${model.id}" does not support image input`, "UNSUPPORTED_CONTENT");
@@ -2797,6 +2936,7 @@ var GatewayAdapter = class extends LlmAdapter {
2797
2936
  ...init,
2798
2937
  redirect: "error"
2799
2938
  });
2939
+ answered = true;
2800
2940
  response = {
2801
2941
  status: received.status,
2802
2942
  headers: Object.fromEntries(received.headers.entries())
@@ -2882,6 +3022,9 @@ var GatewayAdapter = class extends LlmAdapter {
2882
3022
  throw error;
2883
3023
  } finally {
2884
3024
  consumer.abort("gateway stream consumer stopped");
3025
+ if (answered) try {
3026
+ this.config.onRequestEnded?.();
3027
+ } catch {}
2885
3028
  }
2886
3029
  } catch (_) {
2887
3030
  _usingCtx$1.e = _;
@@ -3034,7 +3177,8 @@ function installGatewayModels(ctx, options) {
3034
3177
  resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess(attachments, (hostPath) => ctx.get("fs")?.processPathFromHostPath(hostPath), ref),
3035
3178
  onReplayDegrade: ({ provider, model, reason }) => {
3036
3179
  ctx.logger.warn(`dsh-vibedev: unusable replay state on assistant history for "${provider}/${model}"; sending provider-neutral content (${reason})`);
3037
- }
3180
+ },
3181
+ ...options.onRequestEnded === void 0 ? {} : { onRequestEnded: options.onRequestEnded }
3038
3182
  });
3039
3183
  const registration = ctx.llm.registerAdapter([VIBEDEV_ROUTE], adapter);
3040
3184
  ctx.inject(["settings"], (child) => {
@@ -3380,12 +3524,21 @@ var GatewayHttp = class {
3380
3524
  */
3381
3525
  async send(path, request = {}) {
3382
3526
  const url = this.url(path);
3383
- let credential = request.anonymous !== true && this.isGatewayUrl(url) ? await this.credential() : void 0;
3527
+ const authenticate = request.anonymous !== true && this.isGatewayUrl(url);
3528
+ let credential = authenticate ? await this.credential() : void 0;
3384
3529
  let refreshed = false;
3385
3530
  let busyRetries = request.busyRetries ?? 2;
3386
3531
  for (;;) {
3387
3532
  const response = await this.attempt(url, request, credential);
3388
- if (response.ok) return response;
3533
+ if (response.ok) {
3534
+ if (authenticate) try {
3535
+ this.options.onAccepted?.({
3536
+ method: request.method ?? "GET",
3537
+ path: new URL(url).pathname
3538
+ });
3539
+ } catch {}
3540
+ return response;
3541
+ }
3389
3542
  const failure = parseGatewayFailure(await response.clone().json().catch(() => void 0));
3390
3543
  if (response.status === 401 && credential !== void 0 && credential.kind !== "key" && !refreshed) {
3391
3544
  refreshed = true;
@@ -7353,11 +7506,15 @@ var TaskTracker = class {
7353
7506
  }
7354
7507
  async finish(id, patch) {
7355
7508
  this.downloadFailures.delete(id);
7356
- return this.update(id, {
7509
+ const record = await this.update(id, {
7357
7510
  ...patch,
7358
7511
  progress: void 0,
7359
7512
  finishedAt: this.now()
7360
7513
  });
7514
+ try {
7515
+ this.options.onFinished?.(record);
7516
+ } catch {}
7517
+ return record;
7361
7518
  }
7362
7519
  /** Hold (or keep) this process's claim on a task, renewing it while held. */
7363
7520
  async holdClaim(id) {
@@ -8415,6 +8572,8 @@ function audioTranscribeTool(runtime) {
8415
8572
  * ```
8416
8573
  * @module dsh-vibedev
8417
8574
  */
8575
+ /** Requests that are charged: generations and transcriptions (uploads and reads are free). */
8576
+ const PAID_PATH = /^\/v1\/(?:images|videos|audio)(?:\/|$)/;
8418
8577
  /** The package version, sent in the `User-Agent` the gateway attributes plugin traffic by. */
8419
8578
  const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
8420
8579
  const name = "dsh-vibedev";
@@ -8509,17 +8668,25 @@ function apply(ctx, config) {
8509
8668
  account: () => ctx.get("deepseekAccount"),
8510
8669
  apiKey: () => developmentKey(origin, config.apiKeyEnv)
8511
8670
  });
8671
+ let account;
8672
+ const spent = () => {
8673
+ account?.spent();
8674
+ };
8512
8675
  const http = new GatewayHttp({
8513
8676
  origin,
8514
8677
  userAgent,
8515
8678
  resolveCredential: () => chain.resolve(),
8516
- rejectToken: (credential) => chain.reject(credential)
8679
+ rejectToken: (credential) => chain.reject(credential),
8680
+ onAccepted: ({ method, path }) => {
8681
+ if (method === "POST" && PAID_PATH.test(path)) spent();
8682
+ }
8517
8683
  });
8518
8684
  const store = new TaskStore(gatewayStateDirectory(stateDir, origin));
8519
8685
  const tracker = new TaskTracker({
8520
8686
  http,
8521
8687
  store,
8522
- log: warn
8688
+ log: warn,
8689
+ onFinished: spent
8523
8690
  });
8524
8691
  const slots = {
8525
8692
  image: config.imageModel,
@@ -8573,7 +8740,8 @@ function apply(ctx, config) {
8573
8740
  deviceId: () => storage.deviceId(),
8574
8741
  preferredModels: () => config.preferredModels,
8575
8742
  onCredentialChange: (listener) => login.onChange(listener),
8576
- catalogRefreshMinutes: config.catalogRefreshMinutes
8743
+ catalogRefreshMinutes: config.catalogRefreshMinutes,
8744
+ onRequestEnded: spent
8577
8745
  });
8578
8746
  llmCtx.effect(() => () => {
8579
8747
  route = void 0;
@@ -8590,7 +8758,7 @@ function apply(ctx, config) {
8590
8758
  maxRetryWaitMs: 1e4
8591
8759
  }));
8592
8760
  });
8593
- const account = new AccountService({
8761
+ const accountService = new AccountService({
8594
8762
  origin,
8595
8763
  userAgent,
8596
8764
  chain,
@@ -8601,8 +8769,9 @@ function apply(ctx, config) {
8601
8769
  ...route?.hidden() === void 0 ? {} : { hidden: route.hidden() }
8602
8770
  })
8603
8771
  });
8772
+ account = accountService;
8604
8773
  ctx.inject(["connection"], (scoped) => {
8605
- for (const accountRoute of accountRoutes(account)) scoped.effect(() => scoped.connection.fetch.register(accountRoute), `dsh-vibedev: ${accountRoute.path}`);
8774
+ for (const accountRoute of accountRoutes(accountService)) scoped.effect(() => scoped.connection.fetch.register(accountRoute), `dsh-vibedev: ${accountRoute.path}`);
8606
8775
  });
8607
8776
  ctx.effect(() => {
8608
8777
  tracker.resume().catch((error) => {
@@ -8610,9 +8779,11 @@ function apply(ctx, config) {
8610
8779
  });
8611
8780
  const unsubscribe = login.onChange(() => {
8612
8781
  runtime.invalidateCatalog();
8782
+ accountService.credentialChanged();
8613
8783
  });
8614
8784
  return () => {
8615
8785
  unsubscribe();
8786
+ accountService.dispose();
8616
8787
  tracker.dispose();
8617
8788
  login.dispose();
8618
8789
  };
@@ -8622,7 +8793,10 @@ function apply(ctx, config) {
8622
8793
  const controller = new AbortController();
8623
8794
  (async () => {
8624
8795
  try {
8625
- for await (const _view of accountCtx.deepseekAccount.watch(controller.signal)) runtime.invalidateCatalog();
8796
+ for await (const _view of accountCtx.deepseekAccount.watch(controller.signal)) {
8797
+ runtime.invalidateCatalog();
8798
+ accountService.credentialChanged();
8799
+ }
8626
8800
  } catch {}
8627
8801
  })();
8628
8802
  return () => {
@@ -87,6 +87,8 @@ export interface GatewayAdapterOptions {
87
87
  model: string;
88
88
  reason: string;
89
89
  }) => void;
90
+ /** A request reached the gateway and ended, however it ended: it may have been charged. */
91
+ readonly onRequestEnded?: () => void;
90
92
  }
91
93
  /**
92
94
  * The gateway as an LLM adapter. Each operation reads the current catalog, so a refresh reaches
@@ -43,6 +43,8 @@ export interface GatewayModelsOptions {
43
43
  readonly catalogRefreshMinutes?: number;
44
44
  readonly catalogTimeoutMs?: number;
45
45
  readonly cacheRetention?: 'none' | 'short' | 'long';
46
+ /** A model request that may have been charged ended (the account reads the balance again). */
47
+ readonly onRequestEnded?: () => void;
46
48
  }
47
49
  /** The route's live state, for status lines and tests. */
48
50
  export interface GatewayModels {
@@ -47,6 +47,8 @@ export interface TrackerOptions {
47
47
  readonly maxDownloadBytes?: number;
48
48
  /** A claim not renewed for this long is taken over by another process. Defaults to 60 seconds. */
49
49
  readonly claimStaleMs?: number;
50
+ /** Told when a task ends (completed, failed or lost). */
51
+ readonly onFinished?: (record: TaskRecord) => void;
50
52
  }
51
53
  /**
52
54
  * How a failed submission may be taken further.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibedev-si/dsh-vibedev",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "VibeDev 账号与模型:在 DeepSeek Harness 里登录 VibeDev,与 DeepSeek 账号并存,使用 VibeDev 网关的模型(Claude、GPT 等),并生成图片、视频、音乐和播客 · VibeDev account and models for DeepSeek Harness: sign in to VibeDev next to the DeepSeek account and use the VibeDev gateway's models (Claude, GPT and more), plus image, video, music and podcast generation",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",