dsh-plugin-subscriptions 0.1.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/README.md +93 -0
- package/README.zh.md +93 -0
- package/cordis.patch.yml +12 -0
- package/lib/auth/jwt.d.ts +10 -0
- package/lib/auth/jwt.js +25 -0
- package/lib/auth/oauth-flow.d.ts +91 -0
- package/lib/auth/oauth-flow.js +227 -0
- package/lib/auth/pkce.d.ts +31 -0
- package/lib/auth/pkce.js +35 -0
- package/lib/auth/rpc.d.ts +51 -0
- package/lib/auth/rpc.js +83 -0
- package/lib/auth/store.d.ts +90 -0
- package/lib/auth/store.js +137 -0
- package/lib/client/SubscriptionsSection.d.ts +30 -0
- package/lib/client/SubscriptionsSection.js +290 -0
- package/lib/client/index.d.ts +31 -0
- package/lib/client/index.js +35 -0
- package/lib/client/locales.d.ts +45 -0
- package/lib/client/locales.js +43 -0
- package/lib/client.js +546 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +34 -0
- package/lib/index.js +2932 -0
- package/lib/providers/claude.d.ts +60 -0
- package/lib/providers/claude.js +243 -0
- package/lib/providers/codex.d.ts +96 -0
- package/lib/providers/codex.js +391 -0
- package/lib/providers/common.d.ts +185 -0
- package/lib/providers/common.js +302 -0
- package/lib/providers/grok.d.ts +90 -0
- package/lib/providers/grok.js +337 -0
- package/lib/tools/image-generate.d.ts +60 -0
- package/lib/tools/image-generate.js +142 -0
- package/lib/tools/x-search.d.ts +58 -0
- package/lib/tools/x-search.js +195 -0
- package/lib/translate/anthropic.d.ts +120 -0
- package/lib/translate/anthropic.js +370 -0
- package/lib/translate/resolved.d.ts +35 -0
- package/lib/translate/resolved.js +40 -0
- package/lib/translate/responses.d.ts +127 -0
- package/lib/translate/responses.js +352 -0
- package/lib/translate/sse.d.ts +21 -0
- package/lib/translate/sse.js +56 -0
- package/package.json +83 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grok (X Premium / xAI) subscription provider: OIDC-discovered OAuth against
|
|
3
|
+
* auth.x.ai with the Grok CLI client id, and streaming against the xAI
|
|
4
|
+
* Responses-style endpoint.
|
|
5
|
+
*/
|
|
6
|
+
import { attributionHeaders, EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm';
|
|
7
|
+
import { decodeJwtPayload } from '../auth/jwt.js';
|
|
8
|
+
import { resolveImages } from '../translate/resolved.js';
|
|
9
|
+
import { streamResponses, toResponsesInput, toResponsesTools } from '../translate/responses.js';
|
|
10
|
+
import { httpLlmError, idleWatchdog, mapFetchFailure, ModelCatalogCache, oauthEndpointError, OAuthEndpointError, TokenManager, } from './common.js';
|
|
11
|
+
export const GROK_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828';
|
|
12
|
+
export const GROK_DISCOVERY_URL = 'https://auth.x.ai/.well-known/openid-configuration';
|
|
13
|
+
export const GROK_API_URL = 'https://api.x.ai/v1/responses';
|
|
14
|
+
const GROK_SCOPE = 'openid profile email offline_access grok-cli:access api:access';
|
|
15
|
+
const GROK_CALLBACK_PATH = '/callback';
|
|
16
|
+
const GROK_CONTEXT_WINDOW = 256_000;
|
|
17
|
+
const GROK_DEFAULT_MAX_TOKENS = 32_000;
|
|
18
|
+
/** Refresh when the access token has less than this much life left. */
|
|
19
|
+
export const GROK_PREEMPT_MS = 2 * 60_000;
|
|
20
|
+
/** A discovered URL must be https on x.ai or a subdomain; anything else is a hostile document. */
|
|
21
|
+
function assertXaiEndpoint(url, field) {
|
|
22
|
+
let parsed;
|
|
23
|
+
try {
|
|
24
|
+
parsed = new URL(url);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new Error(`grok OIDC discovery returned an invalid ${field}`);
|
|
28
|
+
}
|
|
29
|
+
if (parsed.protocol !== 'https:'
|
|
30
|
+
|| (parsed.hostname !== 'x.ai' && !parsed.hostname.endsWith('.x.ai'))) {
|
|
31
|
+
throw new Error(`grok OIDC discovery returned a non-x.ai ${field}: ${url}`);
|
|
32
|
+
}
|
|
33
|
+
return url;
|
|
34
|
+
}
|
|
35
|
+
let discoveryCache;
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the xAI OIDC endpoints (cached after the first fetch).
|
|
38
|
+
* @returns validated authorization and token endpoints.
|
|
39
|
+
*/
|
|
40
|
+
export async function grokDiscovery() {
|
|
41
|
+
if (discoveryCache !== undefined)
|
|
42
|
+
return discoveryCache;
|
|
43
|
+
const response = await fetch(GROK_DISCOVERY_URL);
|
|
44
|
+
if (!response.ok)
|
|
45
|
+
throw await oauthEndpointError(response, 'grok OIDC discovery');
|
|
46
|
+
const document = await response.json();
|
|
47
|
+
if (typeof document.authorization_endpoint !== 'string' || typeof document.token_endpoint !== 'string') {
|
|
48
|
+
throw new Error('grok OIDC discovery document is missing endpoints');
|
|
49
|
+
}
|
|
50
|
+
discoveryCache = {
|
|
51
|
+
authorizationEndpoint: assertXaiEndpoint(document.authorization_endpoint, 'authorization_endpoint'),
|
|
52
|
+
tokenEndpoint: assertXaiEndpoint(document.token_endpoint, 'token_endpoint'),
|
|
53
|
+
};
|
|
54
|
+
return discoveryCache;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Build the grok flow facts for the OAuth flow engine (async because the
|
|
58
|
+
* authorize URL comes from OIDC discovery).
|
|
59
|
+
* @returns the flow spec for one attempt.
|
|
60
|
+
*/
|
|
61
|
+
export async function grokFlow() {
|
|
62
|
+
const discovery = await grokDiscovery();
|
|
63
|
+
return {
|
|
64
|
+
callbackPath: GROK_CALLBACK_PATH,
|
|
65
|
+
listen: { host: '127.0.0.1', ports: [56121] },
|
|
66
|
+
buildAuthorizeUrl({ redirectUri, state, pkce, nonce }) {
|
|
67
|
+
const params = new URLSearchParams({
|
|
68
|
+
response_type: 'code',
|
|
69
|
+
client_id: GROK_CLIENT_ID,
|
|
70
|
+
redirect_uri: redirectUri,
|
|
71
|
+
scope: GROK_SCOPE,
|
|
72
|
+
code_challenge: pkce.challenge,
|
|
73
|
+
code_challenge_method: 'S256',
|
|
74
|
+
state,
|
|
75
|
+
nonce,
|
|
76
|
+
plan: 'generic',
|
|
77
|
+
referrer: 'dsh-plugin-subscriptions',
|
|
78
|
+
});
|
|
79
|
+
return `${discovery.authorizationEndpoint}?${params.toString()}`;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Pick a display account from an id token's claims. */
|
|
84
|
+
function grokAccount(idToken) {
|
|
85
|
+
const payload = idToken === undefined ? undefined : decodeJwtPayload(idToken);
|
|
86
|
+
const claim = payload?.email ?? payload?.preferred_username ?? payload?.name ?? payload?.sub;
|
|
87
|
+
return typeof claim === 'string' && claim.length > 0 ? claim : undefined;
|
|
88
|
+
}
|
|
89
|
+
/** Build a session from a token response. */
|
|
90
|
+
function grokSession(tokens, tokenEndpoint, fallbackRefreshToken) {
|
|
91
|
+
if (typeof tokens.access_token !== 'string' || tokens.access_token.length === 0) {
|
|
92
|
+
throw new Error('grok token endpoint returned no access token');
|
|
93
|
+
}
|
|
94
|
+
const refreshToken = tokens.refresh_token ?? fallbackRefreshToken;
|
|
95
|
+
if (refreshToken === undefined)
|
|
96
|
+
throw new Error('grok token endpoint returned no refresh token');
|
|
97
|
+
if (typeof tokens.expires_in !== 'number' || tokens.expires_in <= 0) {
|
|
98
|
+
throw new Error('grok token endpoint returned no usable expiry');
|
|
99
|
+
}
|
|
100
|
+
const account = grokAccount(tokens.id_token);
|
|
101
|
+
return {
|
|
102
|
+
accessToken: tokens.access_token,
|
|
103
|
+
refreshToken,
|
|
104
|
+
expiresAt: Date.now() + tokens.expires_in * 1000,
|
|
105
|
+
tokenEndpoint,
|
|
106
|
+
...typeof tokens.scope === 'string' ? { scopes: tokens.scope } : {},
|
|
107
|
+
...account === undefined ? {} : { account },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Exchange an authorization code for a grok session (form-encoded grant that
|
|
112
|
+
* echoes the PKCE challenge as well as the verifier, per the xAI flow).
|
|
113
|
+
* A 403 here means the X plan lacks the API OAuth entitlement.
|
|
114
|
+
* @param code - the authorization code from the callback.
|
|
115
|
+
* @param verifier - the PKCE verifier minted for the attempt.
|
|
116
|
+
* @param redirectUri - the attempt's redirect URI.
|
|
117
|
+
* @param challenge - the PKCE challenge sent at authorize time.
|
|
118
|
+
* @returns the session to store.
|
|
119
|
+
*/
|
|
120
|
+
export async function exchangeGrokCode(code, verifier, redirectUri, challenge) {
|
|
121
|
+
const discovery = await grokDiscovery();
|
|
122
|
+
const response = await fetch(discovery.tokenEndpoint, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
125
|
+
body: new URLSearchParams({
|
|
126
|
+
grant_type: 'authorization_code',
|
|
127
|
+
client_id: GROK_CLIENT_ID,
|
|
128
|
+
code,
|
|
129
|
+
redirect_uri: redirectUri,
|
|
130
|
+
code_verifier: verifier,
|
|
131
|
+
code_challenge: challenge,
|
|
132
|
+
code_challenge_method: 'S256',
|
|
133
|
+
}).toString(),
|
|
134
|
+
});
|
|
135
|
+
if (response.status === 403) {
|
|
136
|
+
throw new OAuthEndpointError('grok token endpoint refused the exchange (HTTP 403): your X plan does not include '
|
|
137
|
+
+ 'the API OAuth entitlement; an X Premium or xAI subscription with API access is required', 403);
|
|
138
|
+
}
|
|
139
|
+
if (!response.ok)
|
|
140
|
+
throw await oauthEndpointError(response, 'grok');
|
|
141
|
+
return grokSession(await response.json(), discovery.tokenEndpoint);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Refresh a grok session (form-encoded grant).
|
|
145
|
+
* @param session - the stored session.
|
|
146
|
+
* @returns the fresh session to store.
|
|
147
|
+
*/
|
|
148
|
+
export async function refreshGrok(session) {
|
|
149
|
+
const response = await fetch(session.tokenEndpoint, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
152
|
+
body: new URLSearchParams({
|
|
153
|
+
grant_type: 'refresh_token',
|
|
154
|
+
client_id: GROK_CLIENT_ID,
|
|
155
|
+
refresh_token: session.refreshToken,
|
|
156
|
+
}).toString(),
|
|
157
|
+
});
|
|
158
|
+
if (!response.ok)
|
|
159
|
+
throw await oauthEndpointError(response, 'grok');
|
|
160
|
+
const next = grokSession(await response.json(), session.tokenEndpoint, session.refreshToken);
|
|
161
|
+
return {
|
|
162
|
+
...next,
|
|
163
|
+
...session.account === undefined ? {} : { account: session.account },
|
|
164
|
+
...next.scopes === undefined && session.scopes !== undefined ? { scopes: session.scopes } : {},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Whether a grok refresh failure means the login is permanently gone.
|
|
169
|
+
* @param error - the thrown refresh error.
|
|
170
|
+
* @returns true when re-login is the only fix.
|
|
171
|
+
*/
|
|
172
|
+
export function isGrokPermanentRefreshError(error) {
|
|
173
|
+
return error instanceof OAuthEndpointError && error.oauthCode === 'invalid_grant';
|
|
174
|
+
}
|
|
175
|
+
export const GROK_MODELS_URL = 'https://api.x.ai/v1/models';
|
|
176
|
+
/**
|
|
177
|
+
* Input modalities for one grok model: chat models (grok-4 family) accept
|
|
178
|
+
* images; code and embedding models are text-only.
|
|
179
|
+
*/
|
|
180
|
+
function grokModalities(id) {
|
|
181
|
+
return /code|embed/i.test(id) ? ['text'] : ['text', 'image'];
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The /v1/models list also serves generation models that cannot chat
|
|
185
|
+
* (grok-imagine-image*, grok-imagine-video*) and embedding models; the picker
|
|
186
|
+
* must not offer them. Heuristic over the id substring, verified against the
|
|
187
|
+
* live catalog (grok-build-0.1 and the grok-4 family pass).
|
|
188
|
+
*/
|
|
189
|
+
function isChatModel(id) {
|
|
190
|
+
return !/imagine|image-|video|embed/i.test(id);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Fetch the live grok model list.
|
|
194
|
+
* @param session - the stored session (used as-is; never refreshed here).
|
|
195
|
+
* @param fetchFn - fetch implementation (injectable for tests).
|
|
196
|
+
* @returns discovered chat models in endpoint order (id doubles as the name).
|
|
197
|
+
*/
|
|
198
|
+
export async function fetchGrokModels(session, fetchFn = fetch) {
|
|
199
|
+
const response = await fetchFn(GROK_MODELS_URL, {
|
|
200
|
+
headers: {
|
|
201
|
+
'authorization': `Bearer ${session.accessToken}`,
|
|
202
|
+
'accept': 'application/json',
|
|
203
|
+
...attributionHeaders(),
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
if (!response.ok)
|
|
207
|
+
throw await oauthEndpointError(response, 'grok models');
|
|
208
|
+
const payload = await response.json();
|
|
209
|
+
if (!Array.isArray(payload.data))
|
|
210
|
+
throw new Error('grok models endpoint returned no data array');
|
|
211
|
+
const seen = new Set();
|
|
212
|
+
const discovered = [];
|
|
213
|
+
for (const entry of payload.data) {
|
|
214
|
+
if (typeof entry.id !== 'string' || entry.id.length === 0 || seen.has(entry.id))
|
|
215
|
+
continue;
|
|
216
|
+
if (!isChatModel(entry.id))
|
|
217
|
+
continue;
|
|
218
|
+
seen.add(entry.id);
|
|
219
|
+
discovered.push({ id: entry.id, name: entry.id });
|
|
220
|
+
}
|
|
221
|
+
// An empty catalog from a 200 response is treated as a discovery failure so
|
|
222
|
+
// the adapter falls back to the static catalog instead of vanishing from
|
|
223
|
+
// the picker.
|
|
224
|
+
if (discovered.length === 0)
|
|
225
|
+
throw new Error('grok models endpoint returned an empty catalog');
|
|
226
|
+
return discovered;
|
|
227
|
+
}
|
|
228
|
+
/** Grok wire adapter: one instance serves the `grok` provider route. */
|
|
229
|
+
export class GrokAdapter extends LlmAdapter {
|
|
230
|
+
options;
|
|
231
|
+
catalog = new ModelCatalogCache();
|
|
232
|
+
constructor(options) {
|
|
233
|
+
super();
|
|
234
|
+
this.options = options;
|
|
235
|
+
}
|
|
236
|
+
providerInfo(provider) {
|
|
237
|
+
return { id: provider, name: 'Grok (Subscription)' };
|
|
238
|
+
}
|
|
239
|
+
staticModels(provider) {
|
|
240
|
+
return this.options.models.map(model => ({
|
|
241
|
+
provider,
|
|
242
|
+
id: model.id,
|
|
243
|
+
name: model.name ?? model.id,
|
|
244
|
+
inputModalities: model.inputModalities ?? grokModalities(model.id),
|
|
245
|
+
}));
|
|
246
|
+
}
|
|
247
|
+
async listModels(provider) {
|
|
248
|
+
// Not logged in → empty catalog, so the web picker drops the provider.
|
|
249
|
+
const session = await this.options.tokens.peek();
|
|
250
|
+
if (session === undefined)
|
|
251
|
+
return [];
|
|
252
|
+
if (!this.options.discovery)
|
|
253
|
+
return this.staticModels(provider);
|
|
254
|
+
try {
|
|
255
|
+
const discovered = await this.catalog.get(() => fetchGrokModels(session, this.options.fetchFn));
|
|
256
|
+
return discovered.map(model => ({
|
|
257
|
+
provider,
|
|
258
|
+
id: model.id,
|
|
259
|
+
name: model.name,
|
|
260
|
+
inputModalities: grokModalities(model.id),
|
|
261
|
+
}));
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
if (error instanceof OAuthEndpointError && error.status === 401)
|
|
265
|
+
this.catalog.invalidate();
|
|
266
|
+
this.options.onWarn?.(`grok model discovery failed; using the built-in catalog (${errorChain(error)})`);
|
|
267
|
+
return this.staticModels(provider);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
resolveModel(provider, model) {
|
|
271
|
+
const discovered = this.options.discovery
|
|
272
|
+
? this.catalog.cached()?.find(entry => entry.id === model)
|
|
273
|
+
: undefined;
|
|
274
|
+
const configured = this.options.models.find(entry => entry.id === model);
|
|
275
|
+
return Promise.resolve({
|
|
276
|
+
provider,
|
|
277
|
+
id: model,
|
|
278
|
+
name: discovered?.name ?? configured?.name ?? model,
|
|
279
|
+
inputModalities: configured?.inputModalities ?? grokModalities(model),
|
|
280
|
+
context: { contextWindow: configured?.contextWindow ?? GROK_CONTEXT_WINDOW },
|
|
281
|
+
defaultMaxTokens: configured?.maxTokens ?? GROK_DEFAULT_MAX_TOKENS,
|
|
282
|
+
// No reasoning metadata: effort selection is not exposed for grok.
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
async *stream(options) {
|
|
286
|
+
const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
|
|
287
|
+
try {
|
|
288
|
+
let session = await this.options.tokens.session();
|
|
289
|
+
let response = await this.request(options, session, watchdog.signal);
|
|
290
|
+
if (response.status === 401) {
|
|
291
|
+
// One forced refresh + retry on an unexpired-but-rejected token.
|
|
292
|
+
session = await this.options.tokens.session(true);
|
|
293
|
+
response = await this.request(options, session, watchdog.signal);
|
|
294
|
+
}
|
|
295
|
+
if (!response.ok)
|
|
296
|
+
throw await httpLlmError(response, 'grok API');
|
|
297
|
+
if (response.body === null) {
|
|
298
|
+
throw new LlmError('grok API returned no response body', EMPTY_RESPONSE_CODE);
|
|
299
|
+
}
|
|
300
|
+
yield* streamResponses(response.body, () => { watchdog.pulse(); });
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
throw mapFetchFailure('grok API', error, watchdog, options.signal);
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
watchdog.stop();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
async request(options, session, signal) {
|
|
310
|
+
const messages = await resolveImages(options.messages, this.options.resolveAttachments?.(), signal);
|
|
311
|
+
const { instructions, input } = toResponsesInput(messages, options.system);
|
|
312
|
+
const body = {
|
|
313
|
+
model: options.model,
|
|
314
|
+
...instructions === undefined ? {} : { instructions },
|
|
315
|
+
input,
|
|
316
|
+
...options.tools !== undefined && options.tools.length > 0
|
|
317
|
+
? { tools: toResponsesTools(options.tools) }
|
|
318
|
+
: {},
|
|
319
|
+
tool_choice: 'auto',
|
|
320
|
+
parallel_tool_calls: true,
|
|
321
|
+
...options.maxTokens !== undefined ? { max_output_tokens: options.maxTokens } : {},
|
|
322
|
+
store: false,
|
|
323
|
+
stream: true,
|
|
324
|
+
};
|
|
325
|
+
return fetch(GROK_API_URL, {
|
|
326
|
+
method: 'POST',
|
|
327
|
+
headers: {
|
|
328
|
+
'authorization': `Bearer ${session.accessToken}`,
|
|
329
|
+
'accept': 'text/event-stream',
|
|
330
|
+
'content-type': 'application/json',
|
|
331
|
+
...attributionHeaders(),
|
|
332
|
+
},
|
|
333
|
+
body: JSON.stringify(body),
|
|
334
|
+
signal,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `image_generate` tool: generate images through the ChatGPT/Codex
|
|
3
|
+
* subscription's image endpoint and save them as PNG files under the harness
|
|
4
|
+
* home. Mirrors codex-rs `codex-api/src/images.rs`: POST
|
|
5
|
+
* `/backend-api/codex/images/generations` with the responses call's auth
|
|
6
|
+
* headers; the response carries base64 PNG data.
|
|
7
|
+
*/
|
|
8
|
+
import type { ToolDefinition } from '@deepseek-ai/dsh-tools';
|
|
9
|
+
import type { CodexSession } from '../auth/store.js';
|
|
10
|
+
import { TokenManager } from '../providers/common.js';
|
|
11
|
+
import type { FetchFn } from '../providers/common.js';
|
|
12
|
+
/** Endpoint the generation request is posted to. */
|
|
13
|
+
export declare const IMAGE_GENERATE_URL = "https://chatgpt.com/backend-api/codex/images/generations";
|
|
14
|
+
/** The image model the codex subscription endpoint serves. */
|
|
15
|
+
export declare const IMAGE_GENERATE_MODEL = "gpt-image-2";
|
|
16
|
+
/** Dependencies of the `image_generate` tool. */
|
|
17
|
+
export interface ImageGenerateToolOptions {
|
|
18
|
+
/** Codex session source; a missing session throws the log-in hint. */
|
|
19
|
+
tokens: TokenManager<CodexSession>;
|
|
20
|
+
/** Fetch implementation (injectable for tests). */
|
|
21
|
+
fetchFn?: FetchFn;
|
|
22
|
+
/** Directory override for saved images (defaults under the harness home). */
|
|
23
|
+
imagesDir?: string;
|
|
24
|
+
}
|
|
25
|
+
/** The wire request body for one generation call. */
|
|
26
|
+
export interface ImageGenerateRequestBody {
|
|
27
|
+
prompt: string;
|
|
28
|
+
model: string;
|
|
29
|
+
size?: string;
|
|
30
|
+
quality?: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Assemble the request body from tool arguments (hand-checks the non-empty
|
|
34
|
+
* prompt the schema DSL cannot express).
|
|
35
|
+
*/
|
|
36
|
+
export declare function buildImageGenerateBody(args: {
|
|
37
|
+
prompt: string;
|
|
38
|
+
size?: '1024x1024' | '1024x1536' | '1536x1024' | 'auto';
|
|
39
|
+
quality?: 'low' | 'medium' | 'high' | 'auto';
|
|
40
|
+
}): ImageGenerateRequestBody;
|
|
41
|
+
/** One generated image decoded from the response. */
|
|
42
|
+
export interface GeneratedImage {
|
|
43
|
+
/** PNG bytes. */
|
|
44
|
+
data: Buffer;
|
|
45
|
+
/** Provider-revised prompt, when the response carries one. */
|
|
46
|
+
revisedPrompt?: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Parse the generations response into decodable images. Throws when the
|
|
50
|
+
* payload carries no usable `b64_json` entries.
|
|
51
|
+
*/
|
|
52
|
+
export declare function parseImageGenerateResponse(payload: unknown): GeneratedImage[];
|
|
53
|
+
/** Directory the generated PNG files are written to. */
|
|
54
|
+
export declare function imagesDirectory(): string;
|
|
55
|
+
/**
|
|
56
|
+
* Build the `image_generate` tool definition.
|
|
57
|
+
* @param options - codex session source, fetch implementation, and image directory.
|
|
58
|
+
* @returns the tool to register on `ctx.tools`.
|
|
59
|
+
*/
|
|
60
|
+
export declare function createImageGenerateTool(options: ImageGenerateToolOptions): ToolDefinition;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `image_generate` tool: generate images through the ChatGPT/Codex
|
|
3
|
+
* subscription's image endpoint and save them as PNG files under the harness
|
|
4
|
+
* home. Mirrors codex-rs `codex-api/src/images.rs`: POST
|
|
5
|
+
* `/backend-api/codex/images/generations` with the responses call's auth
|
|
6
|
+
* headers; the response carries base64 PNG data.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
|
|
11
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
12
|
+
import { httpLlmError, TokenManager } from '../providers/common.js';
|
|
13
|
+
/** Endpoint the generation request is posted to. */
|
|
14
|
+
export const IMAGE_GENERATE_URL = 'https://chatgpt.com/backend-api/codex/images/generations';
|
|
15
|
+
/** The image model the codex subscription endpoint serves. */
|
|
16
|
+
export const IMAGE_GENERATE_MODEL = 'gpt-image-2';
|
|
17
|
+
/**
|
|
18
|
+
* Assemble the request body from tool arguments (hand-checks the non-empty
|
|
19
|
+
* prompt the schema DSL cannot express).
|
|
20
|
+
*/
|
|
21
|
+
export function buildImageGenerateBody(args) {
|
|
22
|
+
const prompt = args.prompt.trim();
|
|
23
|
+
if (prompt.length === 0)
|
|
24
|
+
throw new Error('image_generate: prompt must be a non-empty string');
|
|
25
|
+
return {
|
|
26
|
+
prompt,
|
|
27
|
+
model: IMAGE_GENERATE_MODEL,
|
|
28
|
+
...args.size === undefined ? {} : { size: args.size },
|
|
29
|
+
...args.quality === undefined ? {} : { quality: args.quality },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Parse the generations response into decodable images. Throws when the
|
|
34
|
+
* payload carries no usable `b64_json` entries.
|
|
35
|
+
*/
|
|
36
|
+
export function parseImageGenerateResponse(payload) {
|
|
37
|
+
const body = typeof payload === 'object' && payload !== null ? payload : {};
|
|
38
|
+
const entries = Array.isArray(body.data) ? body.data : [];
|
|
39
|
+
const images = [];
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
if (typeof entry !== 'object' || entry === null)
|
|
42
|
+
continue;
|
|
43
|
+
const record = entry;
|
|
44
|
+
if (typeof record.b64_json !== 'string' || record.b64_json.length === 0)
|
|
45
|
+
continue;
|
|
46
|
+
images.push({
|
|
47
|
+
data: Buffer.from(record.b64_json, 'base64'),
|
|
48
|
+
...typeof record.revised_prompt === 'string' && record.revised_prompt.length > 0
|
|
49
|
+
? { revisedPrompt: record.revised_prompt }
|
|
50
|
+
: {},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
if (images.length === 0)
|
|
54
|
+
throw new Error('image_generate: the response carried no image data');
|
|
55
|
+
return images;
|
|
56
|
+
}
|
|
57
|
+
/** Directory the generated PNG files are written to. */
|
|
58
|
+
export function imagesDirectory() {
|
|
59
|
+
return dshHomePath('plugins', 'subscriptions', 'images');
|
|
60
|
+
}
|
|
61
|
+
/** Timestamped, collision-safe file name for one generated image. */
|
|
62
|
+
function imageFileName(index) {
|
|
63
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
64
|
+
return `image-${stamp}-${Math.random().toString(36).slice(2, 8)}-${index}.png`;
|
|
65
|
+
}
|
|
66
|
+
/** Bound a call-card title's prompt. */
|
|
67
|
+
function truncate(text, max = 60) {
|
|
68
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Build the `image_generate` tool definition.
|
|
72
|
+
* @param options - codex session source, fetch implementation, and image directory.
|
|
73
|
+
* @returns the tool to register on `ctx.tools`.
|
|
74
|
+
*/
|
|
75
|
+
export function createImageGenerateTool(options) {
|
|
76
|
+
return defineTool({
|
|
77
|
+
name: 'image_generate',
|
|
78
|
+
description: 'Generate an image with the ChatGPT subscription (gpt-image-2) and save it as a PNG file. '
|
|
79
|
+
+ 'Returns the saved file paths.',
|
|
80
|
+
parameters: {
|
|
81
|
+
prompt: { type: 'string', required: true, description: 'What the image should show.' },
|
|
82
|
+
size: {
|
|
83
|
+
type: 'string',
|
|
84
|
+
enum: ['1024x1024', '1024x1536', '1536x1024', 'auto'],
|
|
85
|
+
description: 'Image dimensions; omit for the provider default.',
|
|
86
|
+
},
|
|
87
|
+
quality: {
|
|
88
|
+
type: 'string',
|
|
89
|
+
enum: ['low', 'medium', 'high', 'auto'],
|
|
90
|
+
description: 'Rendering quality; omit for the provider default.',
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
output: {
|
|
94
|
+
schema: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: {
|
|
97
|
+
paths: { type: 'array', items: { type: 'string' }, required: true },
|
|
98
|
+
revisedPrompt: { type: 'string' },
|
|
99
|
+
},
|
|
100
|
+
additionalProperties: false,
|
|
101
|
+
},
|
|
102
|
+
render: (_args, value) => [{
|
|
103
|
+
type: 'text',
|
|
104
|
+
text: `Saved ${value.paths.length} image(s):\n${value.paths.map(path => `- ${path}`).join('\n')}`
|
|
105
|
+
+ (value.revisedPrompt === undefined ? '' : `\n\nRevised prompt: ${value.revisedPrompt}`),
|
|
106
|
+
}],
|
|
107
|
+
},
|
|
108
|
+
presentCall: args => ({
|
|
109
|
+
card: 'generic',
|
|
110
|
+
title: `image_generate: ${truncate(args.prompt)}`,
|
|
111
|
+
}),
|
|
112
|
+
async execute(args, exec) {
|
|
113
|
+
const body = buildImageGenerateBody(args);
|
|
114
|
+
const session = await options.tokens.session();
|
|
115
|
+
const response = await (options.fetchFn ?? fetch)(IMAGE_GENERATE_URL, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: {
|
|
118
|
+
'authorization': `Bearer ${session.accessToken}`,
|
|
119
|
+
'chatgpt-account-id': session.accountId,
|
|
120
|
+
'originator': 'codex_cli_rs',
|
|
121
|
+
'content-type': 'application/json',
|
|
122
|
+
'accept': 'application/json',
|
|
123
|
+
},
|
|
124
|
+
body: JSON.stringify(body),
|
|
125
|
+
signal: exec.signal,
|
|
126
|
+
});
|
|
127
|
+
if (!response.ok)
|
|
128
|
+
throw await httpLlmError(response, 'image_generate');
|
|
129
|
+
const images = parseImageGenerateResponse(await response.json());
|
|
130
|
+
const directory = options.imagesDir ?? imagesDirectory();
|
|
131
|
+
await mkdir(directory, { recursive: true });
|
|
132
|
+
const paths = [];
|
|
133
|
+
for (const [index, image] of images.entries()) {
|
|
134
|
+
const path = join(directory, imageFileName(index));
|
|
135
|
+
await writeFile(path, image.data);
|
|
136
|
+
paths.push(path);
|
|
137
|
+
}
|
|
138
|
+
const revisedPrompt = images.find(image => image.revisedPrompt !== undefined)?.revisedPrompt;
|
|
139
|
+
return { paths, ...revisedPrompt === undefined ? {} : { revisedPrompt } };
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `x_search` tool: run xAI's hosted X (Twitter) search through the grok
|
|
3
|
+
* subscription's OAuth session. The wire call is a non-streaming Responses
|
|
4
|
+
* request carrying the built-in `x_search` tool definition; the canonical
|
|
5
|
+
* output is `{ answer, citations }`.
|
|
6
|
+
*/
|
|
7
|
+
import type { ToolDefinition } from '@deepseek-ai/dsh-tools';
|
|
8
|
+
import type { GrokSession } from '../auth/store.js';
|
|
9
|
+
import { TokenManager } from '../providers/common.js';
|
|
10
|
+
import type { FetchFn } from '../providers/common.js';
|
|
11
|
+
/** Endpoint the search request is posted to. */
|
|
12
|
+
export declare const X_SEARCH_URL = "https://api.x.ai/v1/responses";
|
|
13
|
+
/** Grok model the search runs on (a catalog model of the grok provider). */
|
|
14
|
+
export declare const X_SEARCH_MODEL = "grok-4";
|
|
15
|
+
/** Dependencies of the `x_search` tool. */
|
|
16
|
+
export interface XSearchToolOptions {
|
|
17
|
+
/** Grok session source; a missing session throws the log-in hint. */
|
|
18
|
+
tokens: TokenManager<GrokSession>;
|
|
19
|
+
/** Fetch implementation (injectable for tests). */
|
|
20
|
+
fetchFn?: FetchFn;
|
|
21
|
+
}
|
|
22
|
+
/** Normalized, validated arguments of one search call. */
|
|
23
|
+
interface XSearchRequest {
|
|
24
|
+
query: string;
|
|
25
|
+
tool: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Validate and assemble the request facts from tool arguments. Throws plain
|
|
29
|
+
* Errors for argument problems the schema DSL cannot express (non-empty
|
|
30
|
+
* query, handle caps, mutually exclusive filters).
|
|
31
|
+
*/
|
|
32
|
+
export declare function buildXSearchRequest(args: {
|
|
33
|
+
query: string;
|
|
34
|
+
allowed_x_handles?: string[];
|
|
35
|
+
excluded_x_handles?: string[];
|
|
36
|
+
from_date?: string;
|
|
37
|
+
to_date?: string;
|
|
38
|
+
enable_image_understanding?: boolean;
|
|
39
|
+
enable_video_understanding?: boolean;
|
|
40
|
+
}): XSearchRequest;
|
|
41
|
+
/** The canonical output of one successful search. */
|
|
42
|
+
interface XSearchOutput {
|
|
43
|
+
answer: string;
|
|
44
|
+
citations: string[];
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Extract the answer text and citation URLs from a Responses payload: the
|
|
48
|
+
* `output_text` shortcut or message output parts for the answer, and both
|
|
49
|
+
* top-level `citations` and inline `url_citation` annotations for sources.
|
|
50
|
+
*/
|
|
51
|
+
export declare function parseXSearchResponse(payload: unknown): XSearchOutput;
|
|
52
|
+
/**
|
|
53
|
+
* Build the `x_search` tool definition.
|
|
54
|
+
* @param options - grok session source and fetch implementation.
|
|
55
|
+
* @returns the tool to register on `ctx.tools`.
|
|
56
|
+
*/
|
|
57
|
+
export declare function createXSearchTool(options: XSearchToolOptions): ToolDefinition;
|
|
58
|
+
export {};
|