dsh-plugin-subscription-oauth 0.17.5 → 0.17.7
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/lib/providers/antigravity.js +41 -1
- package/lib/providers/workbuddy.d.ts +12 -0
- package/lib/providers/workbuddy.js +122 -12
- package/package.json +1 -1
|
@@ -66,6 +66,8 @@ async function paceAntigravityAttempt(signal) {
|
|
|
66
66
|
const NETWORK_RETRY_ATTEMPTS = 3;
|
|
67
67
|
/** Base delay between network retries; it doubles each attempt. */
|
|
68
68
|
const NETWORK_RETRY_BASE_MS = 800;
|
|
69
|
+
/** How many times a stream that closes early is retried before the account is skipped. */
|
|
70
|
+
const STREAM_RETRY_ATTEMPTS = 3;
|
|
69
71
|
/**
|
|
70
72
|
* Whether an error from `fetch` is a transient network fault worth retrying.
|
|
71
73
|
*
|
|
@@ -79,6 +81,17 @@ function isTransientNetworkFault(error) {
|
|
|
79
81
|
const text = errorChain(error).toLowerCase();
|
|
80
82
|
return /fetch failed|connect timeout|connection reset|econnreset|etimedout|econnrefused|socket hang up|network error|timed out/.test(text);
|
|
81
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Whether an error raised while reading a live stream is a transient cut worth redispatching.
|
|
86
|
+
*
|
|
87
|
+
* `streamAntigravity` throws `STREAM_CLOSED` when the SSE body ends before a finish chunk —
|
|
88
|
+
* a dropped or reset connection mid-answer, not a refusal. The request arrived and the
|
|
89
|
+
* account is fine; the answer just never completed, so redispatching the same request is
|
|
90
|
+
* safe. Content refusals (`CONTENT_FILTER`), quota and credential failures are not retried.
|
|
91
|
+
*/
|
|
92
|
+
function isRetryableStreamError(error) {
|
|
93
|
+
return error instanceof LlmError && error.failure.code === 'STREAM_CLOSED';
|
|
94
|
+
}
|
|
82
95
|
/**
|
|
83
96
|
* Sleep for a backoff beat, honouring the caller's abort.
|
|
84
97
|
* @param ms - how long to wait.
|
|
@@ -626,7 +639,34 @@ export class AntigravityAdapter extends LlmAdapter {
|
|
|
626
639
|
if (response.body === null) {
|
|
627
640
|
throw new LlmError('Antigravity API returned no response body', EMPTY_RESPONSE_CODE);
|
|
628
641
|
}
|
|
629
|
-
|
|
642
|
+
// A stream that closes before a finish chunk is a dropped connection, not a
|
|
643
|
+
// refusal: the account is fine and the request arrived, so redispatch it a few
|
|
644
|
+
// times with backoff before giving up on the account. Content refusals and quota
|
|
645
|
+
// failures propagate untouched.
|
|
646
|
+
let lastStreamError;
|
|
647
|
+
for (let attempt = 0; attempt < STREAM_RETRY_ATTEMPTS; attempt++) {
|
|
648
|
+
try {
|
|
649
|
+
yield* streamAntigravity(response.body, () => { watchdog.pulse(); });
|
|
650
|
+
lastStreamError = undefined;
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
if (!isRetryableStreamError(error) || watchdog.signal.aborted)
|
|
655
|
+
throw error;
|
|
656
|
+
lastStreamError = error;
|
|
657
|
+
this.options.onWarn?.(`antigravity: stream closed early (${errorChain(error)}); redispatching`);
|
|
658
|
+
if (attempt < STREAM_RETRY_ATTEMPTS - 1) {
|
|
659
|
+
await backoff(NETWORK_RETRY_BASE_MS * 2 ** attempt, watchdog.signal);
|
|
660
|
+
response = await this.requestWithRetry(options, model, token.account, token.accessToken, watchdog.signal);
|
|
661
|
+
if (!response.ok)
|
|
662
|
+
throw await httpLlmError(response, 'Antigravity API');
|
|
663
|
+
if (response.body === null)
|
|
664
|
+
throw new LlmError('Antigravity API returned no response body', EMPTY_RESPONSE_CODE);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (lastStreamError !== undefined)
|
|
669
|
+
throw lastStreamError;
|
|
630
670
|
return;
|
|
631
671
|
}
|
|
632
672
|
// Every account was tried and none served. The headline names the most actionable
|
|
@@ -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
|
-
|
|
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':
|
|
700
|
-
'X-Request-ID':
|
|
701
|
-
'X-Conversation-Request-ID':
|
|
702
|
-
'X-Conversation-ID':
|
|
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
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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.
|
|
3
|
+
"version": "0.17.7",
|
|
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",
|