dsh-plugin-subscription-oauth 0.17.5 → 0.17.6

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.
@@ -94,6 +94,18 @@ export interface WorkBuddyAdapterOptions {
94
94
  spendFile?: string;
95
95
  fetchFn?: FetchFn;
96
96
  }
97
+ /** A message content's signature: its text, and a digest of every part that isn't text. */
98
+ export declare function signatureOf(content: unknown): string;
99
+ /** The conversation a chat belongs to: the session the harness names, else the first user message. */
100
+ export declare function conversationKey(messages: Record<string, unknown>[], session: string | undefined): string;
101
+ /** The four ids one chat carries, named as WorkBuddy's own client names them. */
102
+ export interface ConversationIds {
103
+ message: string;
104
+ turn: string;
105
+ conversation: string | undefined;
106
+ }
107
+ /** Derive the conversation ids one request's body carries. */
108
+ export declare function conversationIdsFor(body: Record<string, unknown>, options: GenerateOptions): ConversationIds;
97
109
  /** The WorkBuddy China wire adapter serving the `workbuddy` route. */
98
110
  export declare class WorkBuddyAdapter extends LlmAdapter {
99
111
  private readonly options;
@@ -21,6 +21,7 @@ import { proxiedFetch } from '../http.js';
21
21
  import { composeCatalog, findEntry, liveEntry } from './catalog.js';
22
22
  import { WORKBUDDY_ENDPOINT, workbuddyHeaders } from '../auth/workbuddy-flow.js';
23
23
  import { recordSpend } from './workbuddy-credits.js';
24
+ import { createHash } from 'node:crypto';
24
25
  import { appendFileSync } from 'node:fs';
25
26
  import { homedir } from 'node:os';
26
27
  import path from 'node:path';
@@ -250,6 +251,95 @@ function randomHex() {
250
251
  crypto.getRandomValues(bytes);
251
252
  return [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
252
253
  }
254
+ // ---- the conversation's ids -------------------------------------------------
255
+ //
256
+ // WorkBuddy's own client names each chat to its gateway four ways: the
257
+ // conversation it belongs to, the user send (turn) it answers, the message
258
+ // itself, and the request that carried it. Its backend counts a user send as
259
+ // one request by `X-Conversation-Request-ID`: a chat carrying a new one every
260
+ // time is counted on its own, and — what this adapter exists to fix — a chat
261
+ // that presents a conversation the backend has never seen, under no stable
262
+ // conversation id, is treated as an unaffiliated request and can be cut
263
+ // mid-stream ("夹断"). The ids below are made from what names the conversation
264
+ // and the turn, so every step of one send carries the same turn id and the
265
+ // user's next message carries another, as WorkBuddy's own client does.
266
+ /** Salt the ids are derived with, so an id says nothing of the words it was made from. */
267
+ const ID_SALT = randomHex();
268
+ /** The 32 hex digits WorkBuddy's own ids are made of. */
269
+ function idFor(kind, key) {
270
+ return createHash('sha256').update(`${ID_SALT}|${kind}|${key}`).digest('hex').slice(0, 32);
271
+ }
272
+ /** A message content's signature: its text, and a digest of every part that isn't text. */
273
+ export function signatureOf(content) {
274
+ if (typeof content === 'string')
275
+ return content;
276
+ if (!Array.isArray(content))
277
+ return '';
278
+ let out = '';
279
+ for (const part of content) {
280
+ if (!isSchemaRecord(part))
281
+ continue;
282
+ const type = typeof part['type'] === 'string' ? part['type'] : '';
283
+ if (type === '' || type === 'text')
284
+ out += typeof part['text'] === 'string' ? part['text'] : '';
285
+ else if (type !== 'tool_result' && type !== 'tool-result')
286
+ out += `[${type}:${createHash('sha256').update(JSON.stringify(part)).digest('hex').slice(0, 8)}]`;
287
+ }
288
+ return out;
289
+ }
290
+ /** Whether `messages[index]` answers the message before it (a tool result, or an assistant's calls). */
291
+ function answeredBefore(messages, index) {
292
+ const prev = messages[index - 1];
293
+ if (prev === null || typeof prev !== 'object')
294
+ return false;
295
+ if (prev['role'] === 'tool')
296
+ return true;
297
+ return prev['role'] === 'assistant' && Array.isArray(prev['tool_calls']) && prev['tool_calls'].length > 0;
298
+ }
299
+ /** The body of one assistant/tool message's text, for the turn key. */
300
+ function messageText(message) {
301
+ return signatureOf(message['content']);
302
+ }
303
+ /** The last user message's signable signature, or '' when none is. */
304
+ function turnKey(messages) {
305
+ for (let index = messages.length - 1; index >= 0; index--) {
306
+ if (messages[index]?.['role'] !== 'user' || answeredBefore(messages, index))
307
+ continue;
308
+ const signature = messageText(messages[index]);
309
+ return signature === '' ? '' : `u${index}:${signature}`;
310
+ }
311
+ return '';
312
+ }
313
+ /** The conversation a chat belongs to: the session the harness names, else the first user message. */
314
+ export function conversationKey(messages, session) {
315
+ if (session !== undefined && session.trim() !== '')
316
+ return `session:${session.trim().slice(0, 128)}`;
317
+ for (const message of messages) {
318
+ if (message['role'] !== 'user')
319
+ continue;
320
+ const signature = messageText(message);
321
+ if (signature !== '')
322
+ return `first:${signature}`;
323
+ }
324
+ return '';
325
+ }
326
+ /** Derive the conversation ids one request's body carries. */
327
+ export function conversationIdsFor(body, options) {
328
+ const messages = Array.isArray(body['messages']) ? body['messages'] : [];
329
+ const message = randomHex();
330
+ // The loop names a session per request (one-shot title/compaction calls may not):
331
+ // one conversation's chats carry one conversation id, never a made-up one.
332
+ const conversation = conversationKey(messages, options.sessionId === undefined ? undefined : String(options.sessionId));
333
+ const turn = turnKey(messages);
334
+ return {
335
+ message,
336
+ // A turn is a conversation's own: two conversations whose user message reads the
337
+ // same are still two sends, not one. An empty turn (no signable user message)
338
+ // falls back to a fresh id, as WorkBuddy's own client makes one.
339
+ turn: turn === '' ? randomHex() : idFor('turn', `${conversation}|${turn}`),
340
+ conversation: conversation === '' ? undefined : idFor('conversation', conversation),
341
+ };
342
+ }
253
343
  // DSH runs a turn and a session-title request at the same time on one adapter instance,
254
344
  // so nothing a request needs may live on the instance: the tool rename map, the history
255
345
  // it rewrites, the text it accumulates, and the credit it bills are all locals, built and
@@ -652,16 +742,20 @@ export class WorkBuddyAdapter extends LlmAdapter {
652
742
  diag(`stream start: model=${options.model} account=${held.account} expiresAt=${held.expiresAt} now=${Date.now()}`);
653
743
  try {
654
744
  const { body, names } = this.requestBody(options, history);
745
+ // The conversation ids one user send carries, stable across this stream's retries
746
+ // and the tool-result step of one turn, as WorkBuddy's own client keeps them.
747
+ const ids = conversationIdsFor(body, options);
655
748
  diag(`body: keys=${Object.keys(body).join(',')} messages=${Array.isArray(body.messages) ? body.messages.length : 0} model=${String(body.model)}`);
656
749
  // Post once with the resolved token. A 401/403 can mean the access token lapsed
657
750
  // between resolve() and now: force one refresh and post again, as WorkBuddy's own
658
751
  // client does — a 403 with a genuinely dead credential keeps its meaning below.
659
- let response = await this.post(body, session ?? held, watchdog.signal);
752
+ // The retry carries the same turn id (ids.turn), as WorkBuddy's own client does.
753
+ let response = await this.post(body, session ?? held, watchdog.signal, ids);
660
754
  if (response.status === 401 || response.status === 403) {
661
755
  diag(`response: ${response.status} on resolved token; forcing one refresh and retrying`);
662
756
  const refreshed = await this.options.tokens.resolve(held.account, true);
663
757
  const fresh = await this.sessionFor(refreshed.accessToken);
664
- response = await this.post(body, fresh ?? refreshed, watchdog.signal);
758
+ response = await this.post(body, fresh ?? refreshed, watchdog.signal, ids);
665
759
  diag(`response after refresh: status=${response.status} ok=${response.ok} ct=${response.headers.get('content-type') ?? ''}`);
666
760
  }
667
761
  else {
@@ -689,17 +783,17 @@ export class WorkBuddyAdapter extends LlmAdapter {
689
783
  * message, the request, the turn, and the conversation. Without these the gateway
690
784
  * counts the request under no client and can cut the stream.
691
785
  */
692
- post(body, token, signal) {
786
+ post(body, token, signal, ids) {
693
787
  return this.fetchFn(WORKBUDDY_API, {
694
788
  method: 'POST',
695
789
  headers: {
696
790
  'Content-Type': 'application/json',
697
791
  'Accept': 'text/event-stream',
698
792
  ...workbuddyHeaders(token),
699
- 'X-Conversation-Message-ID': randomHex(),
700
- 'X-Request-ID': randomHex(),
701
- 'X-Conversation-Request-ID': randomHex(),
702
- 'X-Conversation-ID': randomHex(),
793
+ 'X-Conversation-Message-ID': ids.message,
794
+ 'X-Request-ID': ids.message,
795
+ 'X-Conversation-Request-ID': ids.turn,
796
+ ...ids.conversation === undefined ? {} : { 'X-Conversation-ID': ids.conversation },
703
797
  },
704
798
  body: JSON.stringify(body),
705
799
  signal,
@@ -899,6 +993,11 @@ export class WorkBuddyAdapter extends LlmAdapter {
899
993
  let bytesSeen = 0;
900
994
  let firstBytes = '';
901
995
  let sawToolCall = false;
996
+ // The wire's own terminal reason, when a frame states one. A real finish_reason is
997
+ // honoured ('length' reads as max-tokens, an unexpected stop/error reads as an error)
998
+ // instead of answering every stream with a bare 'stop' that hides a truncated answer
999
+ // as a clean, complete one. Absent, the shape decides as before.
1000
+ let wireFinish;
902
1001
  diag(`stream open: status=${response.status} content-type=${response.headers.get('content-type') ?? ''}`);
903
1002
  try {
904
1003
  while (true) {
@@ -954,6 +1053,10 @@ export class WorkBuddyAdapter extends LlmAdapter {
954
1053
  // Restore the tool names the model called to the ones the client declared, before
955
1054
  // the chunk is read as a stream event.
956
1055
  this.namedBack(chunk, names);
1056
+ // The terminal reason a frame states, kept for the finish below.
1057
+ const statedFinish = chunk.choices?.[0]?.finish_reason;
1058
+ if (typeof statedFinish === 'string' && statedFinish !== '')
1059
+ wireFinish = statedFinish;
957
1060
  // A tool-call delta: emit it so the harness can run the tool. The id and name ride
958
1061
  // the first delta of each call; later deltas carry argument fragments.
959
1062
  const toolCalls = chunk.choices?.[0]?.delta?.tool_calls;
@@ -1024,13 +1127,20 @@ export class WorkBuddyAdapter extends LlmAdapter {
1024
1127
  block: { type: 'tool-call', id: ToolCallId(open.id), name: open.name, arguments: open.args },
1025
1128
  };
1026
1129
  }
1027
- diag(`stream done: frames=${framesSeen} bytes=${bytesSeen} started=${started} text=${text.length} calls=${calls.size} sawToolCall=${sawToolCall} firstBytes=${JSON.stringify(firstBytes.slice(0, 80))}`);
1130
+ diag(`stream done: frames=${framesSeen} bytes=${bytesSeen} started=${started} text=${text.length} calls=${calls.size} sawToolCall=${sawToolCall} finish=${wireFinish ?? ''} firstBytes=${JSON.stringify(firstBytes.slice(0, 80))}`);
1028
1131
  // A turn that called a tool ends with the tool-call reason, not stop: the harness
1029
1132
  // runs the tool and feeds the result back. A stop reason here would read as a clean,
1030
- // complete answer and the tool would never run.
1031
- yield sawToolCall
1032
- ? { type: 'finish', reason: { kind: 'tool-calls' } }
1033
- : { type: 'finish', reason: { kind: 'stop' } };
1133
+ // complete answer and the tool would never run. Otherwise the wire's own terminal
1134
+ // reason decides, so a length-capped answer is not mistaken for a complete one.
1135
+ if (sawToolCall)
1136
+ yield { type: 'finish', reason: { kind: 'tool-calls' } };
1137
+ else if (wireFinish === 'length')
1138
+ yield { type: 'finish', reason: { kind: 'max-tokens' } };
1139
+ else if (wireFinish !== undefined && wireFinish !== 'stop') {
1140
+ yield { type: 'finish', reason: { kind: 'error', failure: { message: `WorkBuddy stream ended with finish_reason ${wireFinish}`, code: 'STREAM_FINISH' } } };
1141
+ }
1142
+ else
1143
+ yield { type: 'finish', reason: { kind: 'stop' } };
1034
1144
  }
1035
1145
  finally {
1036
1146
  reader.cancel().catch(() => { });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-subscription-oauth",
3
- "version": "0.17.5",
3
+ "version": "0.17.6",
4
4
  "description": "Use subscription accounts as DeepSeek Harness LLM providers — Kimi Code (OAuth device flow), Grok / X Premium (OIDC + PKCE), Google Antigravity (Google OAuth + Cloud Code Assist) and GLM / BigModel (ZCode's browser sign-in) — with per-model visibility, drag-to-reorder Auto fallback and plan quotas from the web Settings page",
5
5
  "license": "MIT",
6
6
  "author": "EricFL1998",