dsh-plugin-subscription-oauth 0.17.4 → 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.
|
@@ -609,6 +609,13 @@ export class AntigravityAdapter extends LlmAdapter {
|
|
|
609
609
|
diag.push(`403 on ${token.account}, another account already cooling; stopping the sweep`);
|
|
610
610
|
}
|
|
611
611
|
if (!response.ok) {
|
|
612
|
+
if (response.status === 400) {
|
|
613
|
+
// Google's 400 puts the offending field in error.details / fieldViolations, which
|
|
614
|
+
// the generic errorBodyMessage does not surface; log the whole body so the real
|
|
615
|
+
// cause is visible, since a bare "invalid argument" gives nothing to act on.
|
|
616
|
+
const body = await response.clone().text().catch(() => '');
|
|
617
|
+
this.options.onWarn?.(`antigravity: HTTP 400 body: ${body.slice(0, 600)}`);
|
|
618
|
+
}
|
|
612
619
|
diag.push(`fail ${response.status} on ${token.account} (no switch)`);
|
|
613
620
|
const error = await httpLlmError(response, 'Antigravity API');
|
|
614
621
|
// The diagnostic rides the message so it surfaces where the user sees the
|
package/lib/providers/common.js
CHANGED
|
@@ -96,7 +96,13 @@ async function errorBodyMessage(response) {
|
|
|
96
96
|
const record = parsed;
|
|
97
97
|
const error = record.error;
|
|
98
98
|
if (typeof error === 'object' && error !== null) {
|
|
99
|
-
const
|
|
99
|
+
const errorRecord = error;
|
|
100
|
+
const message = errorRecord.message;
|
|
101
|
+
// Google puts the offending field in details[].fieldViolations, which the bare
|
|
102
|
+
// message omits; surface it so a 400 names what to fix instead of "invalid argument".
|
|
103
|
+
const detail = extractFieldViolations(errorRecord.details);
|
|
104
|
+
if (detail !== undefined)
|
|
105
|
+
return `${String(message ?? '').trim()} — ${detail}`;
|
|
100
106
|
if (typeof message === 'string' && message.trim() !== '')
|
|
101
107
|
return message.trim();
|
|
102
108
|
}
|
|
@@ -115,6 +121,29 @@ async function errorBodyMessage(response) {
|
|
|
115
121
|
return undefined;
|
|
116
122
|
}
|
|
117
123
|
}
|
|
124
|
+
/** The offending-field text inside a Google RPC error's `details` list, when present. */
|
|
125
|
+
function extractFieldViolations(details) {
|
|
126
|
+
if (!Array.isArray(details))
|
|
127
|
+
return undefined;
|
|
128
|
+
const out = [];
|
|
129
|
+
for (const entry of details) {
|
|
130
|
+
if (typeof entry !== 'object' || entry === null)
|
|
131
|
+
continue;
|
|
132
|
+
const violations = entry.fieldViolations;
|
|
133
|
+
if (!Array.isArray(violations))
|
|
134
|
+
continue;
|
|
135
|
+
for (const violation of violations) {
|
|
136
|
+
if (typeof violation !== 'object' || violation === null)
|
|
137
|
+
continue;
|
|
138
|
+
const field = violation.field;
|
|
139
|
+
const description = violation.description;
|
|
140
|
+
if (typeof description === 'string' && description.trim() !== '') {
|
|
141
|
+
out.push(typeof field === 'string' && field.trim() !== '' ? `${field}: ${description.trim()}` : description.trim());
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return out.length === 0 ? undefined : out.join('; ');
|
|
146
|
+
}
|
|
118
147
|
/**
|
|
119
148
|
* Turn a non-2xx provider response into an LlmError carrying the status and
|
|
120
149
|
* any rate-limit hint the headers disclosed.
|
|
@@ -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.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",
|