dsh-plugin-subscription-oauth 0.9.0 → 0.10.0
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/auth/workbuddy-flow.d.ts +95 -0
- package/lib/auth/workbuddy-flow.js +265 -0
- package/lib/index.d.ts +2 -2
- package/lib/index.js +13 -2
- package/lib/providers/workbuddy-provider.d.ts +32 -0
- package/lib/providers/workbuddy-provider.js +181 -0
- package/lib/providers/workbuddy.d.ts +70 -0
- package/lib/providers/workbuddy.js +269 -0
- package/package.json +1 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkBuddy China's sign-in and token handling.
|
|
3
|
+
*
|
|
4
|
+
* A WorkBuddy account signs in in the browser (Tencent's page, polled for the
|
|
5
|
+
* token as WorkBuddy's desktop app does). Chats are chat completions at
|
|
6
|
+
* `<endpoint>/v2`, streamed only, and the API answers `{code, msg, data}` where a
|
|
7
|
+
* non-zero code is an error. Adapted from magpie's `workbuddy` for this plugin's
|
|
8
|
+
* typed flow/store contracts.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-plugin-subscription-oauth/auth/workbuddy-flow
|
|
11
|
+
*/
|
|
12
|
+
/** WorkBuddy China build's endpoints. */
|
|
13
|
+
export declare const WORKBUDDY_ENDPOINT = "https://copilot.tencent.com";
|
|
14
|
+
export declare const WORKBUDDY_PLATFORM = "workbuddy";
|
|
15
|
+
/** The version the sign-in page is opened with. */
|
|
16
|
+
export declare const WORKBUDDY_APP_VERSION = "2.0.0";
|
|
17
|
+
/** The User-Agent version the chat API accepts; another is refused (10085). */
|
|
18
|
+
export declare const WORKBUDDY_UA_VERSION = "5.5.6";
|
|
19
|
+
/** A token this close to its end is refreshed before a request. */
|
|
20
|
+
export declare const WORKBUDDY_EARLY_MS = 60000;
|
|
21
|
+
/** A sign-in attempt is abandoned after this long. */
|
|
22
|
+
export declare const WORKBUDDY_SIGN_IN_MS: number;
|
|
23
|
+
/** One signed-in WorkBuddy session, as stored. */
|
|
24
|
+
export interface WorkBuddySession {
|
|
25
|
+
accessToken: string;
|
|
26
|
+
refreshToken?: string;
|
|
27
|
+
expiresAt: number;
|
|
28
|
+
accountId?: string;
|
|
29
|
+
email?: string;
|
|
30
|
+
uid?: string;
|
|
31
|
+
enterpriseId?: string;
|
|
32
|
+
domain?: string;
|
|
33
|
+
tokenType?: string;
|
|
34
|
+
refreshExpiresAt?: number;
|
|
35
|
+
}
|
|
36
|
+
/** Validate one stored value as a WorkBuddy session, or undefined when it is not one. */
|
|
37
|
+
export declare function parseWorkBuddySession(value: unknown): WorkBuddySession | undefined;
|
|
38
|
+
/** A WorkBuddy API failure, carrying the wire code and magpie's phrasing. */
|
|
39
|
+
export declare class WBError extends Error {
|
|
40
|
+
readonly code: number | string | undefined;
|
|
41
|
+
readonly status: number;
|
|
42
|
+
readonly said: string;
|
|
43
|
+
constructor(message: string, code: number | string | undefined, status: number, said: string);
|
|
44
|
+
}
|
|
45
|
+
/** The chat completions endpoint for the China build. */
|
|
46
|
+
export declare const workbuddyApi: (endpoint: string) => string;
|
|
47
|
+
/**
|
|
48
|
+
* Make one request of WorkBuddy's plugin API, which answers `{code, msg, data}`.
|
|
49
|
+
* A non-zero `code`, or a non-2xx status, is an error.
|
|
50
|
+
*
|
|
51
|
+
* @param endpoint - the site base, e.g. `https://copilot.tencent.com`.
|
|
52
|
+
* @param method - HTTP method.
|
|
53
|
+
* @param path - path beneath the base.
|
|
54
|
+
* @param headers - extra headers.
|
|
55
|
+
* @param body - JSON body, when present.
|
|
56
|
+
* @param fetchFn - fetch implementation.
|
|
57
|
+
* @returns the response's `data`.
|
|
58
|
+
*/
|
|
59
|
+
export declare function workbuddyCall(endpoint: string, method: string, path: string, headers: Record<string, string>, body: unknown, fetchFn: typeof fetch): Promise<unknown>;
|
|
60
|
+
/** The state WorkBuddy's sign-in page starts from. */
|
|
61
|
+
export interface WorkBuddySignInStart {
|
|
62
|
+
state: string;
|
|
63
|
+
url: string;
|
|
64
|
+
expiresAt: number;
|
|
65
|
+
}
|
|
66
|
+
/** Start a browser sign-in: the page URL to open, and the state to poll for the token. */
|
|
67
|
+
export declare function startWorkBuddySignIn(fetchFn: typeof fetch): Promise<WorkBuddySignInStart>;
|
|
68
|
+
/** One WorkBuddy token's fields, normalised from the sign-in or refresh response. */
|
|
69
|
+
export interface WorkBuddyTokenSet {
|
|
70
|
+
accessToken: string;
|
|
71
|
+
refreshToken?: string;
|
|
72
|
+
expiresAt: number;
|
|
73
|
+
refreshExpiresAt?: number;
|
|
74
|
+
domain?: string;
|
|
75
|
+
tokenType?: string;
|
|
76
|
+
uid?: string;
|
|
77
|
+
enterpriseId?: string;
|
|
78
|
+
}
|
|
79
|
+
/** Merge a token WorkBuddy gave into the stored sign-in's fields. */
|
|
80
|
+
export declare function mergeWorkBuddyToken(base: WorkBuddyTokenSet, got: Record<string, unknown>): WorkBuddyTokenSet;
|
|
81
|
+
/** Poll for the token a finished browser sign-in produced. */
|
|
82
|
+
export declare function finishWorkBuddySignIn(start: WorkBuddySignInStart, fetchFn: typeof fetch): Promise<{
|
|
83
|
+
token: WorkBuddyTokenSet;
|
|
84
|
+
name: string;
|
|
85
|
+
}>;
|
|
86
|
+
/** The domain a token names, else the API host. */
|
|
87
|
+
export declare function workbuddyDomain(token: Pick<WorkBuddyTokenSet, 'domain'>): string;
|
|
88
|
+
/** The headers WorkBuddy's desktop app sends with a chat and its account. */
|
|
89
|
+
export declare function workbuddyHeaders(token: WorkBuddyTokenSet): Record<string, string>;
|
|
90
|
+
/** Whether a token is close enough to its end that a request should refresh it. */
|
|
91
|
+
export declare function workbuddyStale(token: Pick<WorkBuddyTokenSet, 'expiresAt'>): boolean;
|
|
92
|
+
/** Whether a token can still be refreshed. */
|
|
93
|
+
export declare function workbuddyRefreshable(token: WorkBuddyTokenSet): boolean;
|
|
94
|
+
/** Spend a refresh token for a new access token. */
|
|
95
|
+
export declare function refreshWorkBuddyToken(token: WorkBuddyTokenSet, fetchFn: typeof fetch): Promise<WorkBuddyTokenSet>;
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkBuddy China's sign-in and token handling.
|
|
3
|
+
*
|
|
4
|
+
* A WorkBuddy account signs in in the browser (Tencent's page, polled for the
|
|
5
|
+
* token as WorkBuddy's desktop app does). Chats are chat completions at
|
|
6
|
+
* `<endpoint>/v2`, streamed only, and the API answers `{code, msg, data}` where a
|
|
7
|
+
* non-zero code is an error. Adapted from magpie's `workbuddy` for this plugin's
|
|
8
|
+
* typed flow/store contracts.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-plugin-subscription-oauth/auth/workbuddy-flow
|
|
11
|
+
*/
|
|
12
|
+
/** WorkBuddy China build's endpoints. */
|
|
13
|
+
export const WORKBUDDY_ENDPOINT = 'https://copilot.tencent.com';
|
|
14
|
+
export const WORKBUDDY_PLATFORM = 'workbuddy';
|
|
15
|
+
/** The version the sign-in page is opened with. */
|
|
16
|
+
export const WORKBUDDY_APP_VERSION = '2.0.0';
|
|
17
|
+
/** The User-Agent version the chat API accepts; another is refused (10085). */
|
|
18
|
+
export const WORKBUDDY_UA_VERSION = '5.5.6';
|
|
19
|
+
/** A token this close to its end is refreshed before a request. */
|
|
20
|
+
export const WORKBUDDY_EARLY_MS = 60_000;
|
|
21
|
+
/** A sign-in attempt is abandoned after this long. */
|
|
22
|
+
export const WORKBUDDY_SIGN_IN_MS = 5 * 60 * 1000;
|
|
23
|
+
const POLL_MS = 1000;
|
|
24
|
+
/** Validate one stored value as a WorkBuddy session, or undefined when it is not one. */
|
|
25
|
+
export function parseWorkBuddySession(value) {
|
|
26
|
+
if (typeof value !== 'object' || value === null)
|
|
27
|
+
return undefined;
|
|
28
|
+
const record = value;
|
|
29
|
+
if (typeof record.accessToken !== 'string' || record.accessToken.length === 0)
|
|
30
|
+
return undefined;
|
|
31
|
+
if (typeof record.expiresAt !== 'number')
|
|
32
|
+
return undefined;
|
|
33
|
+
return {
|
|
34
|
+
accessToken: record.accessToken,
|
|
35
|
+
expiresAt: record.expiresAt,
|
|
36
|
+
...typeof record.refreshToken === 'string' ? { refreshToken: record.refreshToken } : {},
|
|
37
|
+
...typeof record.accountId === 'string' ? { accountId: record.accountId } : {},
|
|
38
|
+
...typeof record.email === 'string' ? { email: record.email } : {},
|
|
39
|
+
...typeof record.uid === 'string' ? { uid: record.uid } : {},
|
|
40
|
+
...typeof record.enterpriseId === 'string' ? { enterpriseId: record.enterpriseId } : {},
|
|
41
|
+
...typeof record.domain === 'string' ? { domain: record.domain } : {},
|
|
42
|
+
...typeof record.tokenType === 'string' ? { tokenType: record.tokenType } : {},
|
|
43
|
+
...typeof record.refreshExpiresAt === 'number' ? { refreshExpiresAt: record.refreshExpiresAt } : {},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** A WorkBuddy API failure, carrying the wire code and magpie's phrasing. */
|
|
47
|
+
export class WBError extends Error {
|
|
48
|
+
code;
|
|
49
|
+
status;
|
|
50
|
+
said;
|
|
51
|
+
constructor(message, code, status, said) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.code = code;
|
|
54
|
+
this.status = status;
|
|
55
|
+
this.said = said;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
59
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
60
|
+
/** The chat completions endpoint for the China build. */
|
|
61
|
+
export const workbuddyApi = (endpoint) => `${endpoint}/v2`;
|
|
62
|
+
/**
|
|
63
|
+
* Make one request of WorkBuddy's plugin API, which answers `{code, msg, data}`.
|
|
64
|
+
* A non-zero `code`, or a non-2xx status, is an error.
|
|
65
|
+
*
|
|
66
|
+
* @param endpoint - the site base, e.g. `https://copilot.tencent.com`.
|
|
67
|
+
* @param method - HTTP method.
|
|
68
|
+
* @param path - path beneath the base.
|
|
69
|
+
* @param headers - extra headers.
|
|
70
|
+
* @param body - JSON body, when present.
|
|
71
|
+
* @param fetchFn - fetch implementation.
|
|
72
|
+
* @returns the response's `data`.
|
|
73
|
+
*/
|
|
74
|
+
export async function workbuddyCall(endpoint, method, path, headers, body, fetchFn) {
|
|
75
|
+
const response = await fetchFn(endpoint + path, {
|
|
76
|
+
method,
|
|
77
|
+
headers: {
|
|
78
|
+
'Content-Type': 'application/json',
|
|
79
|
+
'Accept': 'application/json',
|
|
80
|
+
'User-Agent': `WorkBuddy/${WORKBUDDY_UA_VERSION}`,
|
|
81
|
+
...headers,
|
|
82
|
+
},
|
|
83
|
+
...body === undefined ? {} : { body: JSON.stringify(body) },
|
|
84
|
+
signal: AbortSignal.timeout(30_000),
|
|
85
|
+
});
|
|
86
|
+
const text = await response.text();
|
|
87
|
+
let env = {};
|
|
88
|
+
try {
|
|
89
|
+
env = JSON.parse(text);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
env = {};
|
|
93
|
+
}
|
|
94
|
+
const code = Number.parseInt(String(env.code ?? ''), 10) || 0;
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
const why = String(env.msg ?? env.message ?? (isRecord(env.error) ? env.error.message : '') ?? '').trim() ||
|
|
97
|
+
text.slice(0, 200) || response.statusText;
|
|
98
|
+
const said = code ? String(env.msg ?? `error ${code}`) : String(env.msg ?? `${response.status}`);
|
|
99
|
+
throw new WBError(`WorkBuddy: ${why} (HTTP ${response.status}${env.code ? `, code ${String(env.code)}` : ''})`, env.code, response.status, said);
|
|
100
|
+
}
|
|
101
|
+
if (code !== 0) {
|
|
102
|
+
throw new WBError(`WorkBuddy: ${String(env.msg ?? 'error')} (${String(env.code)})`, env.code, response.status, String(env.msg ?? `error ${code}`));
|
|
103
|
+
}
|
|
104
|
+
return env.data;
|
|
105
|
+
}
|
|
106
|
+
/** Start a browser sign-in: the page URL to open, and the state to poll for the token. */
|
|
107
|
+
export async function startWorkBuddySignIn(fetchFn) {
|
|
108
|
+
const data = await workbuddyCall(WORKBUDDY_ENDPOINT, 'POST', `/v2/plugin/auth/state?platform=${encodeURIComponent(WORKBUDDY_PLATFORM)}`, {
|
|
109
|
+
'X-No-Authorization': 'true',
|
|
110
|
+
'X-No-User-Id': 'true',
|
|
111
|
+
'X-No-Enterprise-Id': 'true',
|
|
112
|
+
'X-No-Department-Info': 'true',
|
|
113
|
+
}, {}, fetchFn);
|
|
114
|
+
if (!isRecord(data) || typeof data.state !== 'string' || typeof data.authUrl !== 'string') {
|
|
115
|
+
throw new Error('WorkBuddy gave no sign-in page');
|
|
116
|
+
}
|
|
117
|
+
const url = new URL(data.authUrl);
|
|
118
|
+
if (url.protocol !== 'https:')
|
|
119
|
+
throw new Error("WorkBuddy's sign-in page isn't https");
|
|
120
|
+
url.searchParams.set('version', WORKBUDDY_APP_VERSION);
|
|
121
|
+
url.searchParams.set('loginSessionId', randomHex());
|
|
122
|
+
return { state: data.state, url: url.toString(), expiresAt: Date.now() + WORKBUDDY_SIGN_IN_MS };
|
|
123
|
+
}
|
|
124
|
+
const randomHex = () => {
|
|
125
|
+
const bytes = new Uint8Array(16);
|
|
126
|
+
crypto.getRandomValues(bytes);
|
|
127
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
128
|
+
};
|
|
129
|
+
/** Ask a path until it answers data, retrying the codes that mean "not yet". */
|
|
130
|
+
async function workbuddyPoll(path, headers, retry, deadline, fetchFn) {
|
|
131
|
+
for (;;) {
|
|
132
|
+
if (Date.now() > deadline)
|
|
133
|
+
throw new Error('WorkBuddy sign-in timed out');
|
|
134
|
+
try {
|
|
135
|
+
const data = await workbuddyCall(WORKBUDDY_ENDPOINT, 'GET', path, headers, undefined, fetchFn);
|
|
136
|
+
if (isRecord(data) && Object.keys(data).length > 0)
|
|
137
|
+
return data;
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (!(error instanceof WBError && (retry.includes(Number(error.code)) || error.status === 408 || error.status === 429))) {
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
await sleep(POLL_MS);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Merge a token WorkBuddy gave into the stored sign-in's fields. */
|
|
148
|
+
export function mergeWorkBuddyToken(base, got) {
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
const out = { ...base, accessToken: String(got.accessToken ?? '') };
|
|
151
|
+
if (typeof got.refreshToken === 'string' && got.refreshToken !== '')
|
|
152
|
+
out.refreshToken = got.refreshToken;
|
|
153
|
+
if (typeof got.domain === 'string' && got.domain !== '')
|
|
154
|
+
out.domain = got.domain;
|
|
155
|
+
if (typeof got.tokenType === 'string' && got.tokenType !== '')
|
|
156
|
+
out.tokenType = got.tokenType;
|
|
157
|
+
const expiresAt = Number(got.expiresAt);
|
|
158
|
+
const expiresIn = Number(got.expiresIn);
|
|
159
|
+
if (expiresAt > 0)
|
|
160
|
+
out.expiresAt = expiresAt;
|
|
161
|
+
else if (expiresIn > 0)
|
|
162
|
+
out.expiresAt = now + expiresIn * 1000;
|
|
163
|
+
const refreshExpiresAt = Number(got.refreshExpiresAt);
|
|
164
|
+
const refreshExpiresIn = Number(got.refreshExpiresIn);
|
|
165
|
+
if (refreshExpiresAt > 0)
|
|
166
|
+
out.refreshExpiresAt = refreshExpiresAt;
|
|
167
|
+
else if (refreshExpiresIn > 0)
|
|
168
|
+
out.refreshExpiresAt = now + refreshExpiresIn * 1000;
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
/** Poll for the token a finished browser sign-in produced. */
|
|
172
|
+
export async function finishWorkBuddySignIn(start, fetchFn) {
|
|
173
|
+
const deadline = start.expiresAt;
|
|
174
|
+
const query = encodeURIComponent(start.state);
|
|
175
|
+
const tokenData = await workbuddyPoll(`/v2/plugin/auth/token?state=${query}`, {}, [11217], deadline, fetchFn);
|
|
176
|
+
if (typeof tokenData.accessToken !== 'string' || tokenData.accessToken === '') {
|
|
177
|
+
throw new Error('WorkBuddy sent back no token');
|
|
178
|
+
}
|
|
179
|
+
const token = mergeWorkBuddyToken({ accessToken: '', expiresAt: 0 }, tokenData);
|
|
180
|
+
const who = await workbuddyPoll(`/v2/plugin/login/account?state=${query}`, {
|
|
181
|
+
'Authorization': `Bearer ${token.accessToken}`,
|
|
182
|
+
'X-Domain': workbuddyDomain(token),
|
|
183
|
+
'X-No-User-Id': 'true',
|
|
184
|
+
'X-No-Enterprise-Id': 'true',
|
|
185
|
+
}, [12151], deadline, fetchFn);
|
|
186
|
+
token.uid = typeof who.uid === 'string' ? who.uid : '';
|
|
187
|
+
token.enterpriseId = await workbuddyEnterprise(token, who, fetchFn);
|
|
188
|
+
const name = String(who.nickname ?? who.phoneNumber ?? who.uid ?? '');
|
|
189
|
+
return { token, name };
|
|
190
|
+
}
|
|
191
|
+
/** The domain a token names, else the API host. */
|
|
192
|
+
export function workbuddyDomain(token) {
|
|
193
|
+
return token.domain || new URL(workbuddyApi(WORKBUDDY_ENDPOINT)).host;
|
|
194
|
+
}
|
|
195
|
+
/** The enterprise an account signed in as, when it isn't a personal account. */
|
|
196
|
+
async function workbuddyEnterprise(token, who, fetchFn) {
|
|
197
|
+
const own = typeof who.enterpriseId === 'string' ? who.enterpriseId.trim() : '';
|
|
198
|
+
if (own)
|
|
199
|
+
return own;
|
|
200
|
+
const type = String(who.type ?? '').trim().toLowerCase();
|
|
201
|
+
if (type === '' || type === 'personal')
|
|
202
|
+
return '';
|
|
203
|
+
let list;
|
|
204
|
+
try {
|
|
205
|
+
list = await workbuddyCall(WORKBUDDY_ENDPOINT, 'GET', '/v2/plugin/accounts', { 'Authorization': `Bearer ${token.accessToken}`, 'X-Domain': workbuddyDomain(token) }, undefined, fetchFn);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return '';
|
|
209
|
+
}
|
|
210
|
+
const accounts = isRecord(list) && Array.isArray(list.accounts) ? list.accounts.filter(isRecord) : [];
|
|
211
|
+
const mine = accounts.filter((entry) => entry.uid === who.uid && typeof entry.enterpriseId === 'string' && entry.enterpriseId.trim() !== '');
|
|
212
|
+
const same = mine.filter((entry) => String(entry.type ?? '').trim().toLowerCase() === type);
|
|
213
|
+
const last = mine.filter((entry) => entry.lastLogin);
|
|
214
|
+
const pick = same.length === 1 ? same[0] : last.length === 1 ? last[0] : mine.length === 1 ? mine[0] : undefined;
|
|
215
|
+
return pick && typeof pick.enterpriseId === 'string' ? pick.enterpriseId.trim() : '';
|
|
216
|
+
}
|
|
217
|
+
/** The headers WorkBuddy's desktop app sends with a chat and its account. */
|
|
218
|
+
export function workbuddyHeaders(token) {
|
|
219
|
+
const headers = {
|
|
220
|
+
'Authorization': `Bearer ${token.accessToken}`,
|
|
221
|
+
'X-User-Id': token.uid ?? '',
|
|
222
|
+
'X-Domain': workbuddyDomain(token),
|
|
223
|
+
'X-Product': 'SaaS',
|
|
224
|
+
'X-IDE-Type': 'WorkBuddy',
|
|
225
|
+
'User-Agent': `WorkBuddy/${WORKBUDDY_UA_VERSION}`,
|
|
226
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
227
|
+
'X-Agent-Intent': 'craft',
|
|
228
|
+
'X-Agent-Purpose': 'conversation',
|
|
229
|
+
'X-IDE-Name': 'WorkBuddy',
|
|
230
|
+
'X-IDE-Version': WORKBUDDY_UA_VERSION,
|
|
231
|
+
};
|
|
232
|
+
const enterprise = typeof token.enterpriseId === 'string' ? token.enterpriseId.trim() : '';
|
|
233
|
+
if (enterprise !== '') {
|
|
234
|
+
headers['X-Enterprise-Id'] = enterprise;
|
|
235
|
+
headers['X-Tenant-Id'] = enterprise;
|
|
236
|
+
}
|
|
237
|
+
return headers;
|
|
238
|
+
}
|
|
239
|
+
/** Whether a token is close enough to its end that a request should refresh it. */
|
|
240
|
+
export function workbuddyStale(token) {
|
|
241
|
+
return token.expiresAt > 0 && Date.now() >= token.expiresAt - WORKBUDDY_EARLY_MS;
|
|
242
|
+
}
|
|
243
|
+
/** Whether a token can still be refreshed. */
|
|
244
|
+
export function workbuddyRefreshable(token) {
|
|
245
|
+
if (typeof token.refreshToken !== 'string' || token.refreshToken === '')
|
|
246
|
+
return false;
|
|
247
|
+
return !(token.refreshExpiresAt !== undefined && Date.now() >= token.refreshExpiresAt);
|
|
248
|
+
}
|
|
249
|
+
/** Spend a refresh token for a new access token. */
|
|
250
|
+
export async function refreshWorkBuddyToken(token, fetchFn) {
|
|
251
|
+
const data = await workbuddyCall(WORKBUDDY_ENDPOINT, 'POST', '/v2/plugin/auth/token/refresh', {
|
|
252
|
+
'X-Refresh-Token': token.refreshToken ?? '',
|
|
253
|
+
'X-Auth-Refresh-Source': 'plugin',
|
|
254
|
+
'X-Domain': workbuddyDomain(token),
|
|
255
|
+
...workbuddyEnterpriseHeaders(token),
|
|
256
|
+
}, {}, fetchFn);
|
|
257
|
+
if (!isRecord(data) || typeof data.accessToken !== 'string' || data.accessToken === '') {
|
|
258
|
+
throw new Error('WorkBuddy gave no refreshed token');
|
|
259
|
+
}
|
|
260
|
+
return mergeWorkBuddyToken(token, data);
|
|
261
|
+
}
|
|
262
|
+
function workbuddyEnterpriseHeaders(token) {
|
|
263
|
+
const enterprise = typeof token.enterpriseId === 'string' ? token.enterpriseId.trim() : '';
|
|
264
|
+
return enterprise === '' ? {} : { 'X-Enterprise-Id': enterprise, 'X-Tenant-Id': enterprise };
|
|
265
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -16,9 +16,9 @@ export declare const name = "dsh-plugin-subscription-oauth";
|
|
|
16
16
|
/** The provider routes need the LLM runtime; everything else is optional. */
|
|
17
17
|
export declare const inject: string[];
|
|
18
18
|
/** Every route key this plugin accepts in configuration. */
|
|
19
|
-
export declare const PROVIDER_IDS: readonly ["kimi", "grok", "antigravity", "glm", "ark", "mimo"];
|
|
19
|
+
export declare const PROVIDER_IDS: readonly ["kimi", "grok", "antigravity", "glm", "ark", "mimo", "workbuddy"];
|
|
20
20
|
/** The routes this build can actually register. */
|
|
21
|
-
export declare const IMPLEMENTED_PROVIDER_IDS: readonly ["kimi", "grok", "antigravity", "glm", "ark", "mimo"];
|
|
21
|
+
export declare const IMPLEMENTED_PROVIDER_IDS: readonly ["kimi", "grok", "antigravity", "glm", "ark", "mimo", "workbuddy"];
|
|
22
22
|
/** One route key. */
|
|
23
23
|
export type ProviderId = typeof PROVIDER_IDS[number];
|
|
24
24
|
/** Plugin configuration, as accepted from `cordis.patch.yml`. */
|
package/lib/index.js
CHANGED
|
@@ -23,15 +23,16 @@ import { createKimiProvider } from './providers/kimi-provider.js';
|
|
|
23
23
|
import { createGlmProvider } from './providers/glm-provider.js';
|
|
24
24
|
import { createArkProvider } from './providers/ark-provider.js';
|
|
25
25
|
import { createMimoProvider } from './providers/mimo-provider.js';
|
|
26
|
+
import { createWorkBuddyProvider } from './providers/workbuddy-provider.js';
|
|
26
27
|
import { ModelPreferences } from './settings.js';
|
|
27
28
|
/** Plugin id; also the name every effect label and log line is prefixed with. */
|
|
28
29
|
export const name = 'dsh-plugin-subscription-oauth';
|
|
29
30
|
/** The provider routes need the LLM runtime; everything else is optional. */
|
|
30
31
|
export const inject = ['llm'];
|
|
31
32
|
/** Every route key this plugin accepts in configuration. */
|
|
32
|
-
export const PROVIDER_IDS = ['kimi', 'grok', 'antigravity', 'glm', 'ark', 'mimo'];
|
|
33
|
+
export const PROVIDER_IDS = ['kimi', 'grok', 'antigravity', 'glm', 'ark', 'mimo', 'workbuddy'];
|
|
33
34
|
/** The routes this build can actually register. */
|
|
34
|
-
export const IMPLEMENTED_PROVIDER_IDS = ['kimi', 'grok', 'antigravity', 'glm', 'ark', 'mimo'];
|
|
35
|
+
export const IMPLEMENTED_PROVIDER_IDS = ['kimi', 'grok', 'antigravity', 'glm', 'ark', 'mimo', 'workbuddy'];
|
|
35
36
|
/** Configuration schema, with the config file's own defaults applied. */
|
|
36
37
|
export const Config = z.object({
|
|
37
38
|
providers: z.array(z.union([...PROVIDER_IDS])).default([...IMPLEMENTED_PROVIDER_IDS]),
|
|
@@ -150,6 +151,16 @@ export function apply(ctx, config = {}) {
|
|
|
150
151
|
fetchFn: proxiedFetch,
|
|
151
152
|
}));
|
|
152
153
|
break;
|
|
154
|
+
case 'workbuddy':
|
|
155
|
+
routes.push(createWorkBuddyProvider({
|
|
156
|
+
credentialFile,
|
|
157
|
+
streamIdleTimeoutMs,
|
|
158
|
+
preferences,
|
|
159
|
+
onCatalogChanged: () => { reannounce('workbuddy'); },
|
|
160
|
+
onWarn,
|
|
161
|
+
fetchFn: proxiedFetch,
|
|
162
|
+
}));
|
|
163
|
+
break;
|
|
153
164
|
default:
|
|
154
165
|
// A configured route with no module yet must not silently disappear.
|
|
155
166
|
onWarn(`provider ${id} is configured but not implemented in this build`);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `workbuddy` provider route's wiring (WorkBuddy China build):
|
|
3
|
+
* credential store, browser sign-in, token refresh, and its Settings card facts.
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-plugin-subscription-oauth/providers/workbuddy-provider
|
|
6
|
+
*/
|
|
7
|
+
import type { ProviderRoute } from '../auth/registry.js';
|
|
8
|
+
import type { ModelPreferences } from '../settings.js';
|
|
9
|
+
import type { FetchFn } from '../http.js';
|
|
10
|
+
/** Everything the WorkBuddy route needs from the plugin configuration. */
|
|
11
|
+
export interface WorkBuddyProviderOptions {
|
|
12
|
+
/** Absolute path of `auth.json`. */
|
|
13
|
+
credentialFile: string;
|
|
14
|
+
/** Abandon a stream after this much silence. */
|
|
15
|
+
streamIdleTimeoutMs: number;
|
|
16
|
+
/** Model the picker preselects. */
|
|
17
|
+
defaultModel?: string;
|
|
18
|
+
/** Shared model-visibility preferences. */
|
|
19
|
+
preferences: ModelPreferences;
|
|
20
|
+
/** Invoked when the catalog or its visibility changed. */
|
|
21
|
+
onCatalogChanged?: () => void;
|
|
22
|
+
onWarn?: (message: string) => void;
|
|
23
|
+
/** Fetch implementation; defaults to the proxy-aware fetch. */
|
|
24
|
+
fetchFn?: FetchFn;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Build the `workbuddy` route: its adapter and its login/lifecycle controller.
|
|
28
|
+
*
|
|
29
|
+
* @param options - resolved paths and shared services.
|
|
30
|
+
* @returns the route, ready to register and to serve over RPC.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createWorkBuddyProvider(options: WorkBuddyProviderOptions): ProviderRoute;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `workbuddy` provider route's wiring (WorkBuddy China build):
|
|
3
|
+
* credential store, browser sign-in, token refresh, and its Settings card facts.
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-plugin-subscription-oauth/providers/workbuddy-provider
|
|
6
|
+
*/
|
|
7
|
+
import { REAUTH_REQUIRED, ProviderError } from '../errors.js';
|
|
8
|
+
import { TokenStore } from '../auth/store.js';
|
|
9
|
+
import { TokenManager } from '../auth/tokens.js';
|
|
10
|
+
import { LOGIN_CANCELLED_MESSAGE } from '../auth/device-flow.js';
|
|
11
|
+
import { finishWorkBuddySignIn, parseWorkBuddySession, refreshWorkBuddyToken, startWorkBuddySignIn, workbuddyRefreshable, workbuddyStale, WORKBUDDY_EARLY_MS, } from '../auth/workbuddy-flow.js';
|
|
12
|
+
import { WorkBuddyAdapter, WORKBUDDY_CODE_URL, WORKBUDDY_CATALOG, WORKBUDDY_DEFAULT_MODEL, WORKBUDDY_PROVIDER_ID, WORKBUDDY_PROVIDER_NAME, } from './workbuddy.js';
|
|
13
|
+
import { ProviderController } from '../auth/provider-controller.js';
|
|
14
|
+
import { proxiedFetch } from '../http.js';
|
|
15
|
+
/** Wait, honouring cancellation. */
|
|
16
|
+
function sleep(ms, signal) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
if (signal.aborted) {
|
|
19
|
+
reject(new Error(LOGIN_CANCELLED_MESSAGE));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const timer = setTimeout(() => {
|
|
23
|
+
signal.removeEventListener('abort', onAbort);
|
|
24
|
+
resolve();
|
|
25
|
+
}, ms);
|
|
26
|
+
function onAbort() {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
reject(new Error(LOGIN_CANCELLED_MESSAGE));
|
|
29
|
+
}
|
|
30
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Build the `workbuddy` route: its adapter and its login/lifecycle controller.
|
|
35
|
+
*
|
|
36
|
+
* @param options - resolved paths and shared services.
|
|
37
|
+
* @returns the route, ready to register and to serve over RPC.
|
|
38
|
+
*/
|
|
39
|
+
export function createWorkBuddyProvider(options) {
|
|
40
|
+
const fetchFn = options.fetchFn ?? proxiedFetch;
|
|
41
|
+
const store = new TokenStore(options.credentialFile, WORKBUDDY_PROVIDER_ID, parseWorkBuddySession);
|
|
42
|
+
const toSession = (token, name) => ({
|
|
43
|
+
accessToken: token.accessToken,
|
|
44
|
+
expiresAt: token.expiresAt,
|
|
45
|
+
...token.refreshToken === undefined ? {} : { refreshToken: token.refreshToken },
|
|
46
|
+
...name === '' ? {} : { accountId: name },
|
|
47
|
+
...token.uid === undefined ? {} : { uid: token.uid },
|
|
48
|
+
...token.enterpriseId === undefined ? {} : { enterpriseId: token.enterpriseId },
|
|
49
|
+
...token.domain === undefined ? {} : { domain: token.domain },
|
|
50
|
+
...token.tokenType === undefined ? {} : { tokenType: token.tokenType },
|
|
51
|
+
...token.refreshExpiresAt === undefined ? {} : { refreshExpiresAt: token.refreshExpiresAt },
|
|
52
|
+
});
|
|
53
|
+
/** The in-flight attempt, so `pending()` is stable across calls. */
|
|
54
|
+
let current;
|
|
55
|
+
const flow = {
|
|
56
|
+
async start() {
|
|
57
|
+
const start = await startWorkBuddySignIn(fetchFn);
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
const session = (async () => {
|
|
60
|
+
while (true) {
|
|
61
|
+
if (controller.signal.aborted)
|
|
62
|
+
throw new Error(LOGIN_CANCELLED_MESSAGE);
|
|
63
|
+
try {
|
|
64
|
+
const { token, name } = await finishWorkBuddySignIn(start, fetchFn);
|
|
65
|
+
return toSession(token, name);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (controller.signal.aborted)
|
|
69
|
+
throw new Error(LOGIN_CANCELLED_MESSAGE);
|
|
70
|
+
// A "not yet" poll answer is normal; anything else fails the attempt.
|
|
71
|
+
if (error instanceof Error && /timed out/i.test(error.message))
|
|
72
|
+
throw error;
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
})();
|
|
77
|
+
const attempt = {
|
|
78
|
+
prompt: {
|
|
79
|
+
url: start.url,
|
|
80
|
+
expiresAt: start.expiresAt,
|
|
81
|
+
mode: 'open-url',
|
|
82
|
+
},
|
|
83
|
+
session,
|
|
84
|
+
cancel: () => { controller.abort(); },
|
|
85
|
+
};
|
|
86
|
+
current = attempt;
|
|
87
|
+
void session.catch(() => undefined).finally(() => {
|
|
88
|
+
if (current === attempt)
|
|
89
|
+
current = undefined;
|
|
90
|
+
});
|
|
91
|
+
return attempt;
|
|
92
|
+
},
|
|
93
|
+
pending() {
|
|
94
|
+
return current;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
const tokens = new TokenManager({
|
|
98
|
+
store,
|
|
99
|
+
displayName: WORKBUDDY_PROVIDER_NAME,
|
|
100
|
+
// Refresh a minute before the hour runs out, as the app does.
|
|
101
|
+
preemptMs: WORKBUDDY_EARLY_MS,
|
|
102
|
+
refresh: async (session) => {
|
|
103
|
+
const token = {
|
|
104
|
+
accessToken: session.accessToken,
|
|
105
|
+
expiresAt: session.expiresAt,
|
|
106
|
+
...session.refreshToken === undefined ? {} : { refreshToken: session.refreshToken },
|
|
107
|
+
...session.uid === undefined ? {} : { uid: session.uid },
|
|
108
|
+
...session.enterpriseId === undefined ? {} : { enterpriseId: session.enterpriseId },
|
|
109
|
+
...session.domain === undefined ? {} : { domain: session.domain },
|
|
110
|
+
...session.tokenType === undefined ? {} : { tokenType: session.tokenType },
|
|
111
|
+
...session.refreshExpiresAt === undefined ? {} : { refreshExpiresAt: session.refreshExpiresAt },
|
|
112
|
+
};
|
|
113
|
+
if (!workbuddyRefreshable(token)) {
|
|
114
|
+
throw new ProviderError(`${WORKBUDDY_PROVIDER_NAME} sign-in has expired; connect the account again`, REAUTH_REQUIRED);
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const renewed = await refreshWorkBuddyToken(token, fetchFn);
|
|
118
|
+
return { accessToken: renewed.accessToken, expiresAt: renewed.expiresAt };
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
throw new ProviderError(`${WORKBUDDY_PROVIDER_NAME} token refresh failed: ${error instanceof Error ? error.message : String(error)}`, REAUTH_REQUIRED);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
const adapter = new WorkBuddyAdapter({
|
|
126
|
+
streamIdleTimeoutMs: options.streamIdleTimeoutMs,
|
|
127
|
+
tokens,
|
|
128
|
+
...options.onWarn === undefined ? {} : { onWarn: options.onWarn },
|
|
129
|
+
hiddenModels: () => options.preferences.hiddenIds(WORKBUDDY_PROVIDER_ID),
|
|
130
|
+
userModels: () => options.preferences.extraIds(WORKBUDDY_PROVIDER_ID),
|
|
131
|
+
fetchFn,
|
|
132
|
+
});
|
|
133
|
+
const spec = {
|
|
134
|
+
id: WORKBUDDY_PROVIDER_ID,
|
|
135
|
+
displayName: WORKBUDDY_PROVIDER_NAME,
|
|
136
|
+
codeUrl: WORKBUDDY_CODE_URL,
|
|
137
|
+
credentialFile: options.credentialFile,
|
|
138
|
+
// WorkBuddy ships a fixed catalog; there is no discovery endpoint.
|
|
139
|
+
discovery: false,
|
|
140
|
+
defaultModel: options.defaultModel ?? WORKBUDDY_DEFAULT_MODEL,
|
|
141
|
+
catalog: () => adapter.catalogEntries(),
|
|
142
|
+
catalogOwn: () => adapter.ownCatalogEntries(),
|
|
143
|
+
fallbackContextWindow: WORKBUDDY_CATALOG[0]?.contextWindow ?? 0,
|
|
144
|
+
discover: () => Promise.resolve(undefined),
|
|
145
|
+
usage: () => Promise.resolve({ windows: [] }),
|
|
146
|
+
details: () => [
|
|
147
|
+
{ key: 'provider', value: WORKBUDDY_PROVIDER_NAME },
|
|
148
|
+
{ key: 'endpoint', value: 'https://copilot.tencent.com' },
|
|
149
|
+
{ key: 'credentialFile', value: options.credentialFile },
|
|
150
|
+
],
|
|
151
|
+
applyDiscovery: () => { },
|
|
152
|
+
};
|
|
153
|
+
const controller = new ProviderController({
|
|
154
|
+
spec,
|
|
155
|
+
store,
|
|
156
|
+
tokens,
|
|
157
|
+
flow,
|
|
158
|
+
preferences: options.preferences,
|
|
159
|
+
...options.onCatalogChanged === undefined ? {} : { onCatalogChanged: options.onCatalogChanged },
|
|
160
|
+
...options.onWarn === undefined ? {} : { onWarn: options.onWarn },
|
|
161
|
+
});
|
|
162
|
+
return {
|
|
163
|
+
id: controller.id,
|
|
164
|
+
displayName: controller.displayName,
|
|
165
|
+
credentialFile: controller.credentialFile,
|
|
166
|
+
adapter,
|
|
167
|
+
status: () => controller.status(),
|
|
168
|
+
login: (account) => controller.login(account),
|
|
169
|
+
cancel: () => controller.cancel(),
|
|
170
|
+
logout: (account) => controller.logout(account),
|
|
171
|
+
setDefault: (account) => controller.setDefault(account),
|
|
172
|
+
refresh: (account) => controller.refresh(account),
|
|
173
|
+
discover: (force) => controller.discover(force),
|
|
174
|
+
setModelVisible: (id, visible) => controller.setModelVisible(id, visible),
|
|
175
|
+
setModelOrder: (ids) => controller.setModelOrder(ids),
|
|
176
|
+
addModel: (id) => controller.addModel(id),
|
|
177
|
+
removeModel: (id) => controller.removeModel(id),
|
|
178
|
+
usage: (force) => controller.usage(force),
|
|
179
|
+
reload: () => controller.reload(),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The WorkBuddy China wire adapter serving the `workbuddy` route.
|
|
3
|
+
*
|
|
4
|
+
* WorkBuddy chats are chat completions at `https://copilot.tencent.com/v2/chat/completions`,
|
|
5
|
+
* streamed only, and the API refuses a request whose User-Agent is not
|
|
6
|
+
* `WorkBuddy/<version>`. The route ships the China build's catalog (Hy, GLM, Kimi,
|
|
7
|
+
* DeepSeek, MiniMax). Adapted from magpie's `workbuddy` for this plugin's contracts.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-plugin-subscription-oauth/providers/workbuddy
|
|
10
|
+
*/
|
|
11
|
+
import { LlmAdapter, type GenerateOptions, type LlmModelInfo, type LlmProviderInfo, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm';
|
|
12
|
+
import { type FetchFn } from '../http.js';
|
|
13
|
+
import type { TokenManager } from '../auth/tokens.js';
|
|
14
|
+
import type { CatalogEntry } from './catalog.js';
|
|
15
|
+
import { type WorkBuddySession } from '../auth/workbuddy-flow.js';
|
|
16
|
+
/** The `workbuddy` provider route key (China build). */
|
|
17
|
+
export declare const WORKBUDDY_PROVIDER_ID = "workbuddy";
|
|
18
|
+
/** Human name for the route and its cards. */
|
|
19
|
+
export declare const WORKBUDDY_PROVIDER_NAME = "WorkBuddy";
|
|
20
|
+
/** Where the Settings page sends someone to get an account. */
|
|
21
|
+
export declare const WORKBUDDY_CODE_URL = "https://copilot.tencent.com";
|
|
22
|
+
/** The model the picker preselects. */
|
|
23
|
+
export declare const WORKBUDDY_DEFAULT_MODEL = "hy4-preview-f";
|
|
24
|
+
/** One signed-in WorkBuddy session, as stored. */
|
|
25
|
+
export interface WorkBuddyStored {
|
|
26
|
+
accessToken: string;
|
|
27
|
+
refreshToken?: string;
|
|
28
|
+
expiresAt: number;
|
|
29
|
+
accountId?: string;
|
|
30
|
+
email?: string;
|
|
31
|
+
uid?: string;
|
|
32
|
+
enterpriseId?: string;
|
|
33
|
+
domain?: string;
|
|
34
|
+
tokenType?: string;
|
|
35
|
+
refreshExpiresAt?: number;
|
|
36
|
+
}
|
|
37
|
+
/** The catalog the route serves. */
|
|
38
|
+
export declare const WORKBUDDY_CATALOG: readonly CatalogEntry[];
|
|
39
|
+
/** Constructor dependencies for {@link WorkBuddyAdapter}. */
|
|
40
|
+
export interface WorkBuddyAdapterOptions {
|
|
41
|
+
streamIdleTimeoutMs: number;
|
|
42
|
+
tokens: TokenManager<WorkBuddySession>;
|
|
43
|
+
onWarn?: (message: string) => void;
|
|
44
|
+
hiddenModels?: () => Promise<ReadonlySet<string>>;
|
|
45
|
+
userModels?: () => Promise<readonly string[]>;
|
|
46
|
+
fetchFn?: FetchFn;
|
|
47
|
+
}
|
|
48
|
+
/** The WorkBuddy China wire adapter serving the `workbuddy` route. */
|
|
49
|
+
export declare class WorkBuddyAdapter extends LlmAdapter {
|
|
50
|
+
private readonly options;
|
|
51
|
+
private readonly fetchFn;
|
|
52
|
+
constructor(options: WorkBuddyAdapterOptions);
|
|
53
|
+
providerInfo(provider: string): LlmProviderInfo;
|
|
54
|
+
listModels(_provider: string): Promise<readonly LlmModelInfo[]>;
|
|
55
|
+
private hidden;
|
|
56
|
+
/** The catalog the Settings list reads. */
|
|
57
|
+
catalogEntries(): readonly CatalogEntry[];
|
|
58
|
+
/** The entries the route itself serves, without hand-added extras. */
|
|
59
|
+
ownCatalogEntries(): readonly CatalogEntry[];
|
|
60
|
+
resolveModel(_provider: string, model: string): Promise<LlmResolvedModelInfo>;
|
|
61
|
+
providerRetryPolicy(): undefined;
|
|
62
|
+
stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
|
|
63
|
+
/** The stored session's fields as the token the request signs with. */
|
|
64
|
+
private tokenOf;
|
|
65
|
+
private requestBody;
|
|
66
|
+
private toChatMessage;
|
|
67
|
+
/** Turn a non-2xx into an LlmError, naming WorkBuddy's specific refusals. */
|
|
68
|
+
private errorOf;
|
|
69
|
+
private readStream;
|
|
70
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The WorkBuddy China wire adapter serving the `workbuddy` route.
|
|
3
|
+
*
|
|
4
|
+
* WorkBuddy chats are chat completions at `https://copilot.tencent.com/v2/chat/completions`,
|
|
5
|
+
* streamed only, and the API refuses a request whose User-Agent is not
|
|
6
|
+
* `WorkBuddy/<version>`. The route ships the China build's catalog (Hy, GLM, Kimi,
|
|
7
|
+
* DeepSeek, MiniMax). Adapted from magpie's `workbuddy` for this plugin's contracts.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-plugin-subscription-oauth/providers/workbuddy
|
|
10
|
+
*/
|
|
11
|
+
import { LlmAdapter, errorChain, ReasoningEffortId, } from '@deepseek-ai/dsh-llm';
|
|
12
|
+
import { idleWatchdog, mapFetchFailure } from './common.js';
|
|
13
|
+
import { proxiedFetch } from '../http.js';
|
|
14
|
+
import { findEntry } from './catalog.js';
|
|
15
|
+
import { WORKBUDDY_ENDPOINT, WORKBUDDY_UA_VERSION, workbuddyHeaders } from '../auth/workbuddy-flow.js';
|
|
16
|
+
/** The `workbuddy` provider route key (China build). */
|
|
17
|
+
export const WORKBUDDY_PROVIDER_ID = 'workbuddy';
|
|
18
|
+
/** Human name for the route and its cards. */
|
|
19
|
+
export const WORKBUDDY_PROVIDER_NAME = 'WorkBuddy';
|
|
20
|
+
/** Where the Settings page sends someone to get an account. */
|
|
21
|
+
export const WORKBUDDY_CODE_URL = 'https://copilot.tencent.com';
|
|
22
|
+
/** The chat completions endpoint. */
|
|
23
|
+
const WORKBUDDY_API = `${WORKBUDDY_ENDPOINT}/v2/chat/completions`;
|
|
24
|
+
/** The model the picker preselects. */
|
|
25
|
+
export const WORKBUDDY_DEFAULT_MODEL = 'hy4-preview-f';
|
|
26
|
+
/** The China build's catalog, [id, name, context, efforts?]. */
|
|
27
|
+
const CN_MODELS = [
|
|
28
|
+
['auto', 'Auto', 168_000],
|
|
29
|
+
['hy4-preview-f', 'Hy4 preview', 1_000_000, ['high']],
|
|
30
|
+
['hy3', 'Hy3', 192_000, ['low', 'high']],
|
|
31
|
+
['hy3-x', 'Hy3-X', 192_000, ['low', 'high']],
|
|
32
|
+
['deepseek-v4.1-flash', 'Deepseek-V4.1-Flash', 1_000_000],
|
|
33
|
+
['glm-5.3', 'GLM-5.3', 1_000_000, ['low', 'high', 'max']],
|
|
34
|
+
['glm-5.3-flash', 'GLM-5.3-Flash', 1_000_000, ['low', 'high', 'max']],
|
|
35
|
+
['glm-5.2', 'GLM-5.2', 1_000_000, ['high', 'xhigh']],
|
|
36
|
+
['glm-5.1', 'GLM-5.1', 200_000],
|
|
37
|
+
['glm-5v-turbo', 'GLM-5v-Turbo', 200_000],
|
|
38
|
+
['minimax-m3', 'MiniMax-M3', 512_000],
|
|
39
|
+
['kimi-k3-1', 'Kimi-K3', 1_000_000, ['low', 'high', 'xhigh']],
|
|
40
|
+
['kimi-k2.7', 'Kimi-K2.7-Code', 256_000],
|
|
41
|
+
['kimi-k2.6', 'Kimi-K2.6', 256_000],
|
|
42
|
+
['deepseek-v4-pro', 'Deepseek-V4-Pro', 1_000_000, ['none', 'high', 'xhigh']],
|
|
43
|
+
];
|
|
44
|
+
/** The catalog the route serves. */
|
|
45
|
+
export const WORKBUDDY_CATALOG = CN_MODELS.map((entry) => {
|
|
46
|
+
const [id, name, contextWindow, efforts] = entry;
|
|
47
|
+
return {
|
|
48
|
+
id,
|
|
49
|
+
name,
|
|
50
|
+
contextWindow,
|
|
51
|
+
...efforts === undefined ? {} : { reasoningEfforts: efforts },
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
/** The WorkBuddy China wire adapter serving the `workbuddy` route. */
|
|
55
|
+
export class WorkBuddyAdapter extends LlmAdapter {
|
|
56
|
+
options;
|
|
57
|
+
fetchFn;
|
|
58
|
+
constructor(options) {
|
|
59
|
+
super();
|
|
60
|
+
this.options = options;
|
|
61
|
+
this.fetchFn = options.fetchFn ?? proxiedFetch;
|
|
62
|
+
}
|
|
63
|
+
providerInfo(provider) {
|
|
64
|
+
return { id: provider, name: WORKBUDDY_PROVIDER_NAME };
|
|
65
|
+
}
|
|
66
|
+
async listModels(_provider) {
|
|
67
|
+
if (await this.options.tokens.session() === undefined)
|
|
68
|
+
return [];
|
|
69
|
+
const hidden = await this.hidden();
|
|
70
|
+
return WORKBUDDY_CATALOG.filter((entry) => !hidden.has(entry.id)).map((entry) => ({
|
|
71
|
+
provider: WORKBUDDY_PROVIDER_ID,
|
|
72
|
+
id: entry.id,
|
|
73
|
+
name: entry.name,
|
|
74
|
+
...entry.description === undefined ? {} : { description: entry.description },
|
|
75
|
+
...entry.inputModalities === undefined ? {} : { inputModalities: entry.inputModalities },
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
async hidden() {
|
|
79
|
+
if (this.options.hiddenModels === undefined)
|
|
80
|
+
return new Set();
|
|
81
|
+
try {
|
|
82
|
+
return await this.options.hiddenModels();
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return new Set();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** The catalog the Settings list reads. */
|
|
89
|
+
catalogEntries() {
|
|
90
|
+
return WORKBUDDY_CATALOG;
|
|
91
|
+
}
|
|
92
|
+
/** The entries the route itself serves, without hand-added extras. */
|
|
93
|
+
ownCatalogEntries() {
|
|
94
|
+
return WORKBUDDY_CATALOG;
|
|
95
|
+
}
|
|
96
|
+
async resolveModel(_provider, model) {
|
|
97
|
+
const entry = findEntry(WORKBUDDY_CATALOG, model);
|
|
98
|
+
if (entry === undefined) {
|
|
99
|
+
return {
|
|
100
|
+
provider: WORKBUDDY_PROVIDER_ID,
|
|
101
|
+
id: model,
|
|
102
|
+
name: model,
|
|
103
|
+
context: { contextWindow: 0 },
|
|
104
|
+
inputModalities: ['text', 'image'],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
provider: WORKBUDDY_PROVIDER_ID,
|
|
109
|
+
id: entry.id,
|
|
110
|
+
name: entry.name,
|
|
111
|
+
...entry.description === undefined ? {} : { description: entry.description },
|
|
112
|
+
...entry.inputModalities === undefined ? {} : { inputModalities: entry.inputModalities },
|
|
113
|
+
context: { contextWindow: entry.contextWindow },
|
|
114
|
+
...entry.reasoningEfforts === undefined || entry.reasoningEfforts.length === 0
|
|
115
|
+
? {}
|
|
116
|
+
: { reasoning: { efforts: entry.reasoningEfforts.map((id) => ({ id: ReasoningEffortId(id), name: id })) } },
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
providerRetryPolicy() {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
async *stream(options) {
|
|
123
|
+
const held = await this.options.tokens.session();
|
|
124
|
+
if (held === undefined)
|
|
125
|
+
throw new Error('WorkBuddy is not signed in');
|
|
126
|
+
const token = this.tokenOf(held.session);
|
|
127
|
+
const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
|
|
128
|
+
try {
|
|
129
|
+
const response = await this.fetchFn(WORKBUDDY_API, {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: {
|
|
132
|
+
'Content-Type': 'application/json',
|
|
133
|
+
'Accept': 'text/event-stream',
|
|
134
|
+
...workbuddyHeaders(token),
|
|
135
|
+
},
|
|
136
|
+
body: JSON.stringify(this.requestBody(options)),
|
|
137
|
+
signal: watchdog.signal,
|
|
138
|
+
});
|
|
139
|
+
if (!response.ok || response.body === null) {
|
|
140
|
+
throw await this.errorOf(response);
|
|
141
|
+
}
|
|
142
|
+
yield* this.readStream(response, watchdog);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
throw mapFetchFailure(WORKBUDDY_PROVIDER_NAME, error, watchdog, options.signal);
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
watchdog.stop();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** The stored session's fields as the token the request signs with. */
|
|
152
|
+
tokenOf(session) {
|
|
153
|
+
return {
|
|
154
|
+
accessToken: session.accessToken,
|
|
155
|
+
expiresAt: session.expiresAt,
|
|
156
|
+
...session.refreshToken === undefined ? {} : { refreshToken: session.refreshToken },
|
|
157
|
+
...session.uid === undefined ? {} : { uid: session.uid },
|
|
158
|
+
...session.enterpriseId === undefined ? {} : { enterpriseId: session.enterpriseId },
|
|
159
|
+
...session.domain === undefined ? {} : { domain: session.domain },
|
|
160
|
+
...session.tokenType === undefined ? {} : { tokenType: session.tokenType },
|
|
161
|
+
...session.refreshExpiresAt === undefined ? {} : { refreshExpiresAt: session.refreshExpiresAt },
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
requestBody(options) {
|
|
165
|
+
const messages = [];
|
|
166
|
+
if (options.system !== undefined && options.system.trim() !== '') {
|
|
167
|
+
messages.push({ role: 'system', content: options.system });
|
|
168
|
+
}
|
|
169
|
+
for (const message of options.messages) {
|
|
170
|
+
messages.push(this.toChatMessage(message));
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
model: options.model,
|
|
174
|
+
messages,
|
|
175
|
+
...options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens },
|
|
176
|
+
stream: true,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
toChatMessage(message) {
|
|
180
|
+
const role = message.role === 'assistant' ? 'assistant' : 'user';
|
|
181
|
+
const textParts = [];
|
|
182
|
+
for (const block of message.content) {
|
|
183
|
+
if (block.type === 'text')
|
|
184
|
+
textParts.push(block.text);
|
|
185
|
+
}
|
|
186
|
+
return { role, content: textParts.join('\n') };
|
|
187
|
+
}
|
|
188
|
+
/** Turn a non-2xx into an LlmError, naming WorkBuddy's specific refusals. */
|
|
189
|
+
async errorOf(response) {
|
|
190
|
+
const text = await response.text().catch(() => '');
|
|
191
|
+
let body = {};
|
|
192
|
+
try {
|
|
193
|
+
body = JSON.parse(text);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
body = {};
|
|
197
|
+
}
|
|
198
|
+
const code = Number(body.code ?? body.data?.code ?? 0);
|
|
199
|
+
const message = String(body.msg ?? body.error?.message ?? body.message ?? text.slice(0, 200) ?? `HTTP ${response.status}`);
|
|
200
|
+
if (/unapproved channel|illegal api invocation/i.test(text)) {
|
|
201
|
+
return new Error(`${message} — WorkBuddy refuses chats from Codex and Claude Code; use another client or add another provider`);
|
|
202
|
+
}
|
|
203
|
+
if (code === 11140 || /account has been restricted due to unusual activity/i.test(text)) {
|
|
204
|
+
return new Error(`${message} — WorkBuddy restricted this account; it stays out until WorkBuddy lifts that`);
|
|
205
|
+
}
|
|
206
|
+
if (code === 14018 || /credits exhausted|额度已用尽/i.test(text)) {
|
|
207
|
+
return new Error(`${message} — WorkBuddy requires an active credit package even for 0-rate free models`);
|
|
208
|
+
}
|
|
209
|
+
const { LlmError } = await import('@deepseek-ai/dsh-llm');
|
|
210
|
+
return new LlmError(`${WORKBUDDY_PROVIDER_NAME} request failed (HTTP ${response.status}): ${message}`, response.status === 401 || response.status === 403 ? 'INVALID_CREDENTIAL'
|
|
211
|
+
: response.status === 429 ? 'RATE_LIMIT'
|
|
212
|
+
: response.status >= 500 ? 'SERVER' : 'HTTP_ERROR', { status: response.status });
|
|
213
|
+
}
|
|
214
|
+
async *readStream(response, watchdog) {
|
|
215
|
+
const reader = response.body.getReader();
|
|
216
|
+
const decoder = new TextDecoder();
|
|
217
|
+
let buffer = '';
|
|
218
|
+
let started = false;
|
|
219
|
+
try {
|
|
220
|
+
while (true) {
|
|
221
|
+
const { done, value } = await reader.read();
|
|
222
|
+
if (done)
|
|
223
|
+
break;
|
|
224
|
+
watchdog.pulse();
|
|
225
|
+
buffer += decoder.decode(value, { stream: true });
|
|
226
|
+
const frames = buffer.split('\n\n');
|
|
227
|
+
buffer = frames.pop() ?? '';
|
|
228
|
+
for (const frame of frames) {
|
|
229
|
+
const data = frame
|
|
230
|
+
.split('\n')
|
|
231
|
+
.filter((line) => line.startsWith('data:'))
|
|
232
|
+
.map((line) => line.slice(5).trim())
|
|
233
|
+
.join('\n');
|
|
234
|
+
if (data === '' || data === '[DONE]')
|
|
235
|
+
continue;
|
|
236
|
+
let chunk;
|
|
237
|
+
try {
|
|
238
|
+
chunk = JSON.parse(data);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const delta = chunk.choices?.[0]?.delta?.content;
|
|
244
|
+
if (typeof delta === 'string' && delta !== '') {
|
|
245
|
+
if (!started) {
|
|
246
|
+
yield { type: 'block-start', index: 0, blockType: 'text' };
|
|
247
|
+
started = true;
|
|
248
|
+
}
|
|
249
|
+
yield { type: 'text-delta', index: 0, text: delta };
|
|
250
|
+
}
|
|
251
|
+
if (chunk.usage !== undefined) {
|
|
252
|
+
const input = chunk.usage.prompt_tokens ?? 0;
|
|
253
|
+
const output = chunk.usage.completion_tokens ?? 0;
|
|
254
|
+
yield {
|
|
255
|
+
type: 'usage',
|
|
256
|
+
usage: { inputTokens: input, outputTokens: output, totalTokens: chunk.usage.total_tokens ?? input + output },
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (started)
|
|
262
|
+
yield { type: 'block-end', index: 0, block: { type: 'text', text: '' } };
|
|
263
|
+
yield { type: 'finish', reason: { kind: 'stop' } };
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
reader.cancel().catch(() => { });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-subscription-oauth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
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",
|