@pure01fx/dsh-openai-codex-auth 0.8.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/CHANGELOG.md +18 -0
- package/CODEX-COMPATIBILITY.md +93 -0
- package/README.md +60 -1
- package/client.js +95 -12
- package/lib/catalog.d.ts +6 -0
- package/lib/catalog.js +24 -0
- package/lib/cloud-context.d.ts +20 -0
- package/lib/cloud-context.js +89 -0
- package/lib/cloud-http.d.ts +28 -0
- package/lib/cloud-http.js +170 -0
- package/lib/cloud-images.d.ts +51 -0
- package/lib/cloud-images.js +122 -0
- package/lib/cloud-media.d.ts +52 -0
- package/lib/cloud-media.js +112 -0
- package/lib/cloud-search.d.ts +31 -0
- package/lib/cloud-search.js +172 -0
- package/lib/cloud-tools.d.ts +25 -0
- package/lib/cloud-tools.js +129 -0
- package/lib/cloud-vision.d.ts +34 -0
- package/lib/cloud-vision.js +108 -0
- package/lib/cloud-web-tool.d.ts +12 -0
- package/lib/cloud-web-tool.js +241 -0
- package/lib/index.d.ts +21 -1
- package/lib/index.js +601 -149
- package/lib/native-adapter.d.ts +5 -0
- package/lib/native-adapter.js +22 -10
- package/lib/native-http.d.ts +2 -0
- package/lib/native-http.js +3 -0
- package/lib/native-websocket.js +11 -5
- package/lib/replay.d.ts +1 -0
- package/lib/replay.js +8 -2
- package/lib/response-usage.d.ts +4 -2
- package/lib/response-usage.js +26 -6
- package/lib/responses.d.ts +10 -1
- package/lib/responses.js +86 -10
- package/lib/upstream.d.ts +6 -4
- package/lib/upstream.js +6 -4
- package/lib/usage.d.ts +1 -0
- package/lib/usage.js +10 -3
- package/package.json +62 -9
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/** Bounded, nonstreaming Codex cloud requests. Never retry ambiguous POST failures. */
|
|
2
|
+
import { attributionHeaders, LlmError } from '@deepseek-ai/dsh-llm';
|
|
3
|
+
import { nativeCodexEndpoint } from './endpoint.js';
|
|
4
|
+
const fail = (message, code = 'CODEX_CLOUD_FAILED') => new LlmError(message, code);
|
|
5
|
+
function positive(value, fallback) {
|
|
6
|
+
if (value === undefined)
|
|
7
|
+
return fallback;
|
|
8
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > 2_147_483_647)
|
|
9
|
+
throw fail('Invalid cloud request limit', 'INVALID_ARGS');
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
function credentialCopy(value) {
|
|
13
|
+
if (!value || typeof value.accessToken !== 'string' || !value.accessToken.trim()
|
|
14
|
+
|| typeof value.accountId !== 'string' || !value.accountId.trim())
|
|
15
|
+
throw fail('Codex cloud credential unavailable', 'AUTH_REQUIRED');
|
|
16
|
+
return { accessToken: value.accessToken, accountId: value.accountId };
|
|
17
|
+
}
|
|
18
|
+
/** Race even injected transports/resolvers that do not implement AbortSignal. */
|
|
19
|
+
function cancellable(operation, signal) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const abort = () => reject(fail('Codex cloud request cancelled or timed out', 'ABORTED'));
|
|
22
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
23
|
+
if (signal.aborted)
|
|
24
|
+
abort();
|
|
25
|
+
operation.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export class NativeCodexCloudClient {
|
|
29
|
+
options;
|
|
30
|
+
endpoint;
|
|
31
|
+
constructor(options) {
|
|
32
|
+
this.options = options;
|
|
33
|
+
try {
|
|
34
|
+
this.endpoint = nativeCodexEndpoint(options.endpoint ?? 'https://chatgpt.com/backend-api/codex');
|
|
35
|
+
if (this.endpoint.search)
|
|
36
|
+
throw new Error();
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw fail('Invalid Codex cloud endpoint', 'INVALID_ARGS');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async resolveCredential(signal) {
|
|
43
|
+
if (signal?.aborted)
|
|
44
|
+
throw fail('Codex cloud request cancelled', 'ABORTED');
|
|
45
|
+
try {
|
|
46
|
+
const pending = this.options.resolveCredential(signal);
|
|
47
|
+
return credentialCopy(await (signal ? cancellable(pending, signal) : pending));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw fail(signal?.aborted ? 'Codex cloud request cancelled' : 'Codex cloud credential unavailable', signal?.aborted ? 'ABORTED' : 'AUTH_REQUIRED');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async post(path, body, options) {
|
|
54
|
+
if (!['alpha/search', 'images/generations', 'images/edits'].includes(path))
|
|
55
|
+
throw fail('Invalid Codex cloud path', 'INVALID_ARGS');
|
|
56
|
+
const timeoutMs = positive(options.timeoutMs ?? this.options.requestTimeoutMs, 120_000);
|
|
57
|
+
const maxBytes = positive(options.maxResponseBytes ?? this.options.maxResponseBytes, 48 * 1024 * 1024);
|
|
58
|
+
const requestLimit = positive(this.options.maxRequestBodyBytes, 48 * 1024 * 1024);
|
|
59
|
+
let encoded;
|
|
60
|
+
try {
|
|
61
|
+
encoded = JSON.stringify(body);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw fail('Invalid cloud JSON request', 'INVALID_ARGS');
|
|
65
|
+
}
|
|
66
|
+
if (typeof encoded !== 'string' || Buffer.byteLength(encoded) > requestLimit)
|
|
67
|
+
throw fail('Cloud request exceeds byte limit', 'INVALID_ARGS');
|
|
68
|
+
let credential = credentialCopy(options.credential);
|
|
69
|
+
const accountId = credential.accountId;
|
|
70
|
+
const controller = new AbortController();
|
|
71
|
+
const abort = () => controller.abort();
|
|
72
|
+
options.signal.addEventListener('abort', abort, { once: true });
|
|
73
|
+
if (options.signal.aborted)
|
|
74
|
+
abort();
|
|
75
|
+
const timer = setTimeout(abort, timeoutMs);
|
|
76
|
+
const signal = controller.signal;
|
|
77
|
+
const url = new URL(this.endpoint);
|
|
78
|
+
url.pathname = (url.pathname.endsWith('/') ? url.pathname.slice(0, -1) : url.pathname) + '/' + path;
|
|
79
|
+
try {
|
|
80
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
81
|
+
if (signal.aborted)
|
|
82
|
+
throw fail('Codex cloud request cancelled or timed out', 'ABORTED');
|
|
83
|
+
let response;
|
|
84
|
+
try {
|
|
85
|
+
const headers = new Headers({ ...attributionHeaders(), ...options.headers });
|
|
86
|
+
headers.set('authorization', 'Bearer ' + credential.accessToken);
|
|
87
|
+
headers.set('chatgpt-account-id', credential.accountId);
|
|
88
|
+
headers.set('originator', 'dsh');
|
|
89
|
+
headers.set('content-type', 'application/json');
|
|
90
|
+
headers.set('accept', 'application/json');
|
|
91
|
+
response = await cancellable((this.options.fetch ?? fetch)(url.toString(), {
|
|
92
|
+
method: 'POST', redirect: 'error', headers, body: encoded, signal,
|
|
93
|
+
}), signal);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
throw fail(signal.aborted ? 'Codex cloud request cancelled or timed out' : 'Codex cloud request failed; it was not retried', signal.aborted ? 'ABORTED' : 'CODEX_CLOUD_FAILED');
|
|
97
|
+
}
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
void response.body?.cancel().catch(() => { });
|
|
100
|
+
if (response.status === 401 && attempt === 0 && this.options.recoverCredential) {
|
|
101
|
+
let recovered;
|
|
102
|
+
try {
|
|
103
|
+
recovered = await cancellable(this.options.recoverCredential(credentialCopy(credential), signal), signal);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
throw fail(signal.aborted ? 'Codex cloud request cancelled or timed out' : 'Codex cloud credential recovery failed', signal.aborted ? 'ABORTED' : 'AUTH_REQUIRED');
|
|
107
|
+
}
|
|
108
|
+
if (recovered) {
|
|
109
|
+
credential = await this.resolveCredential(signal);
|
|
110
|
+
if (credential.accountId !== accountId)
|
|
111
|
+
throw fail('Codex account changed during request; start a new call with the active account', 'CODEX_ACCOUNT_CHANGED');
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
throw fail('Codex cloud service returned HTTP ' + response.status, response.status === 401 ? 'AUTH_REQUIRED' : 'CODEX_CLOUD_HTTP_ERROR');
|
|
116
|
+
}
|
|
117
|
+
const reader = response.body?.getReader();
|
|
118
|
+
if (!reader)
|
|
119
|
+
throw fail('Codex cloud response is empty');
|
|
120
|
+
const chunks = [];
|
|
121
|
+
let size = 0;
|
|
122
|
+
try {
|
|
123
|
+
const declared = response.headers.get('content-length');
|
|
124
|
+
if (declared !== null && Number(declared) > maxBytes)
|
|
125
|
+
throw fail('Codex cloud response exceeds byte limit');
|
|
126
|
+
while (true) {
|
|
127
|
+
let part;
|
|
128
|
+
try {
|
|
129
|
+
part = await cancellable(reader.read(), signal);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
throw fail(signal.aborted ? 'Codex cloud request cancelled or timed out' : 'Codex cloud response interrupted; it was not retried', signal.aborted ? 'ABORTED' : 'CODEX_CLOUD_FAILED');
|
|
133
|
+
}
|
|
134
|
+
if (part.done)
|
|
135
|
+
break;
|
|
136
|
+
size += part.value.byteLength;
|
|
137
|
+
if (size > maxBytes)
|
|
138
|
+
throw fail('Codex cloud response exceeds byte limit');
|
|
139
|
+
chunks.push(part.value);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
void reader.cancel().catch(() => { });
|
|
144
|
+
if (error instanceof LlmError)
|
|
145
|
+
throw error;
|
|
146
|
+
throw fail('Codex cloud response interrupted; it was not retried');
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
reader.releaseLock();
|
|
150
|
+
}
|
|
151
|
+
let parsed;
|
|
152
|
+
try {
|
|
153
|
+
parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
throw fail('Codex cloud service returned invalid JSON');
|
|
157
|
+
}
|
|
158
|
+
const requestId = path.startsWith('images/')
|
|
159
|
+
? response.headers.get('x-codex-imagegen-request-id') ?? response.headers.get('x-request-id')
|
|
160
|
+
: response.headers.get('x-request-id');
|
|
161
|
+
return { body: parsed, ...(requestId && /^[a-zA-Z0-9_.:-]{1,256}$/.test(requestId) && !requestId.includes(credential.accessToken) ? { requestId } : {}) };
|
|
162
|
+
}
|
|
163
|
+
throw fail('Codex cloud authentication failed', 'AUTH_REQUIRED');
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
options.signal.removeEventListener('abort', abort);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Pure image endpoint validation and bounded decoding. */
|
|
2
|
+
import type { ImageMediaType } from '@deepseek-ai/dsh-attachment';
|
|
3
|
+
export declare const MAX_IMAGE_BYTES: number;
|
|
4
|
+
export type ImageSource = {
|
|
5
|
+
attachment_id: string;
|
|
6
|
+
} | {
|
|
7
|
+
path: string;
|
|
8
|
+
};
|
|
9
|
+
export interface ImageSelection {
|
|
10
|
+
images?: ImageSource[];
|
|
11
|
+
num_last_images_to_include?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface ImageGenerateArgs {
|
|
14
|
+
prompt: string;
|
|
15
|
+
model: string;
|
|
16
|
+
quality: 'auto' | 'low' | 'medium' | 'high';
|
|
17
|
+
background: 'auto' | 'transparent' | 'opaque';
|
|
18
|
+
size: string;
|
|
19
|
+
n?: number;
|
|
20
|
+
}
|
|
21
|
+
export type ImageEditArgs = ImageGenerateArgs & ImageSelection;
|
|
22
|
+
export declare function validateImageSelection(value: unknown): ImageSelection;
|
|
23
|
+
export declare function validateImageGenerateArgs(value: unknown, defaultModel?: string): ImageGenerateArgs;
|
|
24
|
+
export declare function validateImageEditArgs(value: unknown, defaultModel?: string): ImageEditArgs;
|
|
25
|
+
export declare function buildImageGenerationRequest(value: unknown, defaultModel?: string): ImageGenerateArgs;
|
|
26
|
+
export declare function buildImageEditRequest(value: unknown, images: readonly {
|
|
27
|
+
image_url: string;
|
|
28
|
+
}[], defaultModel?: string): {
|
|
29
|
+
images: {
|
|
30
|
+
image_url: string;
|
|
31
|
+
}[];
|
|
32
|
+
prompt: string;
|
|
33
|
+
model: string;
|
|
34
|
+
quality: "auto" | "low" | "medium" | "high";
|
|
35
|
+
background: "auto" | "transparent" | "opaque";
|
|
36
|
+
size: string;
|
|
37
|
+
n?: number;
|
|
38
|
+
};
|
|
39
|
+
export declare function decodeImageBase64(value: unknown, maxBytes?: number): Uint8Array;
|
|
40
|
+
export declare function imageMediaType(data: Uint8Array): ImageMediaType;
|
|
41
|
+
export interface ParsedCloudImage {
|
|
42
|
+
data: Uint8Array;
|
|
43
|
+
mediaType: ImageMediaType;
|
|
44
|
+
generation_id?: string;
|
|
45
|
+
metadata: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
export interface ParsedImageResponse {
|
|
48
|
+
images: ParsedCloudImage[];
|
|
49
|
+
metadata: Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
export declare function parseImageResponse(value: unknown, requestId?: string): ParsedImageResponse;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export const MAX_IMAGE_BYTES = 32 * 1024 * 1024;
|
|
2
|
+
function object(value, label) {
|
|
3
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
4
|
+
throw new Error(label + ' must be an object');
|
|
5
|
+
return value;
|
|
6
|
+
}
|
|
7
|
+
function text(value, label, max = 32_000) {
|
|
8
|
+
if (typeof value !== 'string' || !value.trim() || value.length > max)
|
|
9
|
+
throw new Error(label + ' must be a nonempty bounded string');
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
function count(value, label, max) {
|
|
13
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > max)
|
|
14
|
+
throw new Error(label + ' must be an integer from 1 to ' + max);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
function choice(value, label, values) {
|
|
18
|
+
if (!values.includes(value))
|
|
19
|
+
throw new Error('Invalid ' + label);
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
export function validateImageSelection(value) {
|
|
23
|
+
const a = object(value, 'Image selection');
|
|
24
|
+
if ((a.images !== undefined) === (a.num_last_images_to_include !== undefined))
|
|
25
|
+
throw new Error('Provide exactly one of images or num_last_images_to_include');
|
|
26
|
+
if (a.images === undefined)
|
|
27
|
+
return { num_last_images_to_include: count(a.num_last_images_to_include, 'num_last_images_to_include', 5) };
|
|
28
|
+
if (!Array.isArray(a.images) || a.images.length < 1 || a.images.length > 5)
|
|
29
|
+
throw new Error('images must contain 1 to 5 references');
|
|
30
|
+
return { images: a.images.map((source) => {
|
|
31
|
+
const s = object(source, 'Image reference');
|
|
32
|
+
if (Object.keys(s).length !== 1)
|
|
33
|
+
throw new Error('Image reference must contain only attachment_id or path');
|
|
34
|
+
if (s.attachment_id !== undefined)
|
|
35
|
+
return { attachment_id: text(s.attachment_id, 'attachment_id', 512) };
|
|
36
|
+
return { path: text(s.path, 'path', 4096) };
|
|
37
|
+
}) };
|
|
38
|
+
}
|
|
39
|
+
export function validateImageGenerateArgs(value, defaultModel = 'gpt-image-2') {
|
|
40
|
+
const a = object(value, 'Image arguments');
|
|
41
|
+
const size = text(a.size ?? 'auto', 'size', 64);
|
|
42
|
+
if (size !== 'auto' && !/^[1-9][0-9]{0,4}x[1-9][0-9]{0,4}$/.test(size))
|
|
43
|
+
throw new Error('size must be auto or WIDTHxHEIGHT; model support is determined by the service');
|
|
44
|
+
return {
|
|
45
|
+
prompt: text(a.prompt, 'prompt'), model: text(a.model ?? defaultModel, 'model', 256),
|
|
46
|
+
quality: choice(a.quality ?? 'auto', 'quality', ['auto', 'low', 'medium', 'high']),
|
|
47
|
+
background: choice(a.background ?? 'auto', 'background', ['auto', 'transparent', 'opaque']), size,
|
|
48
|
+
...(a.n === undefined ? {} : { n: count(a.n, 'n', 4) }),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function validateImageEditArgs(value, defaultModel) {
|
|
52
|
+
return { ...validateImageGenerateArgs(value, defaultModel), ...validateImageSelection(value) };
|
|
53
|
+
}
|
|
54
|
+
export function buildImageGenerationRequest(value, defaultModel) {
|
|
55
|
+
return validateImageGenerateArgs(value, defaultModel);
|
|
56
|
+
}
|
|
57
|
+
export function buildImageEditRequest(value, images, defaultModel) {
|
|
58
|
+
const args = validateImageEditArgs(value, defaultModel);
|
|
59
|
+
const expected = args.images?.length ?? args.num_last_images_to_include;
|
|
60
|
+
if (images.length !== expected)
|
|
61
|
+
throw new Error('Resolved image count does not match selection');
|
|
62
|
+
for (const image of images) {
|
|
63
|
+
const match = /^data:(image[/](?:png|jpeg|webp|gif));base64,(.*)$/.exec(image.image_url);
|
|
64
|
+
if (!match)
|
|
65
|
+
throw new Error('Edit images must be inline supported image data');
|
|
66
|
+
if (imageMediaType(decodeImageBase64(match[2])) !== match[1])
|
|
67
|
+
throw new Error('Edit image media type does not match bytes');
|
|
68
|
+
}
|
|
69
|
+
return { ...validateImageGenerateArgs(value, defaultModel), images: images.map(({ image_url }) => ({ image_url })) };
|
|
70
|
+
}
|
|
71
|
+
export function decodeImageBase64(value, maxBytes = MAX_IMAGE_BYTES) {
|
|
72
|
+
const cap = Math.min(maxBytes, MAX_IMAGE_BYTES);
|
|
73
|
+
if (typeof value !== 'string' || !value.length || value.length > 4 * Math.ceil(cap / 3))
|
|
74
|
+
throw new Error('Image base64 is empty or exceeds byte limit');
|
|
75
|
+
// Check length before scanning or allocating the decoded payload.
|
|
76
|
+
if (value.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(value) || !/^[A-Za-z0-9+/]*={0,2}$/.test(value))
|
|
77
|
+
throw new Error('Invalid image base64');
|
|
78
|
+
const data = Buffer.from(value, 'base64');
|
|
79
|
+
if (!data.length || data.length > cap || data.toString('base64') !== value)
|
|
80
|
+
throw new Error('Invalid image base64 or byte limit exceeded');
|
|
81
|
+
return data;
|
|
82
|
+
}
|
|
83
|
+
export function imageMediaType(data) {
|
|
84
|
+
const b = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
|
85
|
+
if (b.length >= 8 && b.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
|
|
86
|
+
return 'image/png';
|
|
87
|
+
if (b.length >= 3 && b[0] === 255 && b[1] === 216 && b[2] === 255)
|
|
88
|
+
return 'image/jpeg';
|
|
89
|
+
if (b.length >= 12 && b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP')
|
|
90
|
+
return 'image/webp';
|
|
91
|
+
if (b.length >= 6 && ['GIF87a', 'GIF89a'].includes(b.toString('ascii', 0, 6)))
|
|
92
|
+
return 'image/gif';
|
|
93
|
+
throw new Error('Unsupported or invalid image bytes');
|
|
94
|
+
}
|
|
95
|
+
export function parseImageResponse(value, requestId) {
|
|
96
|
+
const response = object(value, 'Image response');
|
|
97
|
+
if (!Array.isArray(response.data) || !response.data.length || response.data.length > 4)
|
|
98
|
+
throw new Error('Image response must contain 1 to 4 data entries');
|
|
99
|
+
// JSON numbers cannot preserve the entire u64 range. Reject lossy timestamps.
|
|
100
|
+
if (typeof response.created !== 'number' || !Number.isSafeInteger(response.created) || response.created < 0)
|
|
101
|
+
throw new Error('Image response created must be a lossless nonnegative u64');
|
|
102
|
+
const metadata = { created: response.created };
|
|
103
|
+
if (response.background != null)
|
|
104
|
+
metadata.background = choice(response.background, 'response background', ['auto', 'transparent', 'opaque']);
|
|
105
|
+
if (response.quality != null)
|
|
106
|
+
metadata.quality = choice(response.quality, 'response quality', ['auto', 'low', 'medium', 'high']);
|
|
107
|
+
if (response.size != null)
|
|
108
|
+
metadata.size = text(response.size, 'response size', 64);
|
|
109
|
+
if (requestId !== undefined)
|
|
110
|
+
metadata.request_id = text(requestId, 'request_id', 512);
|
|
111
|
+
let total = 0;
|
|
112
|
+
const images = response.data.map((entry) => {
|
|
113
|
+
const item = object(entry, 'Image response data');
|
|
114
|
+
const data = decodeImageBase64(item.b64_json, MAX_IMAGE_BYTES - total);
|
|
115
|
+
total += data.length;
|
|
116
|
+
const generation_id = item.generation_id == null ? undefined : text(item.generation_id, 'generation_id', 512);
|
|
117
|
+
// Unknown fields may contain payloads or private service state; never copy them to history.
|
|
118
|
+
return { data, mediaType: imageMediaType(data), metadata: generation_id === undefined ? {} : { generation_id },
|
|
119
|
+
...(generation_id === undefined ? {} : { generation_id }) };
|
|
120
|
+
});
|
|
121
|
+
return { images, metadata };
|
|
122
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { AttachmentStore, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment';
|
|
2
|
+
import type { FileSystem } from '@deepseek-ai/dsh-fs';
|
|
3
|
+
import type { ShellExecutor } from '@deepseek-ai/dsh-shell';
|
|
4
|
+
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox';
|
|
5
|
+
import { type ImageSelection, type ParsedImageResponse } from './cloud-images.js';
|
|
6
|
+
export interface MediaSourceDependencies {
|
|
7
|
+
fs: Pick<FileSystem, 'resolve' | 'readBytes' | 'processPath'>;
|
|
8
|
+
attachments: Pick<AttachmentStore, 'readImage' | 'validateImage'>;
|
|
9
|
+
/** Only verified references visible in the caller session, in chronological order. */
|
|
10
|
+
visibleImages: readonly ImageAttachmentRef[];
|
|
11
|
+
workspace: string;
|
|
12
|
+
signal?: AbortSignal;
|
|
13
|
+
}
|
|
14
|
+
export interface ResolvedCloudImage {
|
|
15
|
+
image_url: string;
|
|
16
|
+
data: Uint8Array;
|
|
17
|
+
mediaType: ImageMediaType;
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
scaled: boolean;
|
|
21
|
+
attachment?: ImageAttachmentRef;
|
|
22
|
+
path?: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function resolveImageSources(value: ImageSelection, deps: MediaSourceDependencies): Promise<ResolvedCloudImage[]>;
|
|
25
|
+
export interface MediaOutputDependencies {
|
|
26
|
+
fs: Pick<FileSystem, 'resolve' | 'processPath' | 'contains'>;
|
|
27
|
+
shell: Pick<ShellExecutor, 'resolve' | 'run' | 'sandboxMode'>;
|
|
28
|
+
/** Resolve from the calling session with the public sandboxPolicy service. */
|
|
29
|
+
policy: SandboxExecutionPolicy;
|
|
30
|
+
attachments: Pick<AttachmentStore, 'validateImage' | 'saveImage'>;
|
|
31
|
+
workspace: string;
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
}
|
|
34
|
+
export declare function saveImageOutputs(response: ParsedImageResponse, deps: MediaOutputDependencies): Promise<{
|
|
35
|
+
images: {
|
|
36
|
+
attachment: ImageAttachmentRef;
|
|
37
|
+
preview: {
|
|
38
|
+
normalized: boolean;
|
|
39
|
+
scaled: boolean;
|
|
40
|
+
mediaType: ImageMediaType;
|
|
41
|
+
bytes: number;
|
|
42
|
+
width: number;
|
|
43
|
+
height: number;
|
|
44
|
+
};
|
|
45
|
+
width: number;
|
|
46
|
+
height: number;
|
|
47
|
+
generation_id?: string;
|
|
48
|
+
path: string;
|
|
49
|
+
mediaType: ImageMediaType;
|
|
50
|
+
bytes: number;
|
|
51
|
+
}[];
|
|
52
|
+
}>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** Caller-scoped image sources and policy-enforced original artifacts. */
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { posix, win32 } from 'node:path';
|
|
4
|
+
import { imageSize } from 'image-size';
|
|
5
|
+
import { MAX_IMAGE_BYTES, imageMediaType, validateImageSelection } from './cloud-images.js';
|
|
6
|
+
// Check both process-world grammars without resolving against the plugin host OS.
|
|
7
|
+
function absoluteWorldPath(path) {
|
|
8
|
+
return !path.includes('\0') && (posix.isAbsolute(path) || (win32.isAbsolute(path) && /^[A-Za-z]:|^\\\\[^\\]+\\[^\\]+/.test(path)));
|
|
9
|
+
}
|
|
10
|
+
function encodedDimensions(data) {
|
|
11
|
+
const { width, height } = imageSize(data);
|
|
12
|
+
if (![width, height].every((n) => Number.isSafeInteger(n) && n > 0))
|
|
13
|
+
throw new Error('Invalid decoded image dimensions');
|
|
14
|
+
return { width, height };
|
|
15
|
+
}
|
|
16
|
+
export async function resolveImageSources(value, deps) {
|
|
17
|
+
const selection = validateImageSelection(value);
|
|
18
|
+
const refs = new Map(deps.visibleImages.map((ref) => [String(ref.attachmentId), ref]));
|
|
19
|
+
const recent = selection.num_last_images_to_include;
|
|
20
|
+
if (recent !== undefined && deps.visibleImages.length < recent)
|
|
21
|
+
throw new Error('Not enough visible images in this session');
|
|
22
|
+
const sources = selection.images ?? deps.visibleImages.slice(-recent).map((ref) => ({ attachment_id: String(ref.attachmentId) }));
|
|
23
|
+
// Resolve every attachment id before performing any storage reads.
|
|
24
|
+
for (const source of sources)
|
|
25
|
+
if ('attachment_id' in source && !refs.has(source.attachment_id))
|
|
26
|
+
throw new Error('Image attachment is not visible in this session: ' + source.attachment_id);
|
|
27
|
+
const result = [];
|
|
28
|
+
let total = 0;
|
|
29
|
+
for (const source of sources) {
|
|
30
|
+
deps.signal?.throwIfAborted();
|
|
31
|
+
let data;
|
|
32
|
+
let attachment;
|
|
33
|
+
let path;
|
|
34
|
+
if ('attachment_id' in source) {
|
|
35
|
+
attachment = refs.get(source.attachment_id);
|
|
36
|
+
if (!Number.isSafeInteger(attachment.bytes) || attachment.bytes <= 0 || attachment.bytes > MAX_IMAGE_BYTES - total)
|
|
37
|
+
throw new Error('Image inputs exceed byte limit');
|
|
38
|
+
const stored = await deps.attachments.readImage(attachment, deps.signal);
|
|
39
|
+
data = stored.data;
|
|
40
|
+
if (stored.ref.attachmentId !== attachment.attachmentId || stored.ref.bytes !== attachment.bytes ||
|
|
41
|
+
stored.ref.mediaType !== attachment.mediaType || stored.ref.width !== attachment.width || stored.ref.height !== attachment.height ||
|
|
42
|
+
data.length !== attachment.bytes)
|
|
43
|
+
throw new Error('Stored image does not match visible attachment metadata');
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
if (!absoluteWorldPath(source.path))
|
|
47
|
+
throw new Error('Image path must be absolute');
|
|
48
|
+
const target = await deps.fs.resolve(source.path, { cwd: deps.workspace, ...(deps.signal ? { signal: deps.signal } : {}) });
|
|
49
|
+
data = await deps.fs.readBytes(target, deps.signal, MAX_IMAGE_BYTES - total);
|
|
50
|
+
path = deps.fs.processPath(target);
|
|
51
|
+
}
|
|
52
|
+
total += data.length;
|
|
53
|
+
if (!data.length || total > MAX_IMAGE_BYTES)
|
|
54
|
+
throw new Error('Image inputs are empty or exceed byte limit');
|
|
55
|
+
const mediaType = imageMediaType(data);
|
|
56
|
+
await deps.attachments.validateImage({ data, mediaType });
|
|
57
|
+
const dimensions = encodedDimensions(data);
|
|
58
|
+
if (attachment && (attachment.mediaType !== mediaType || attachment.width !== dimensions.width || attachment.height !== dimensions.height))
|
|
59
|
+
throw new Error('Stored image bytes do not match attachment dimensions or media type');
|
|
60
|
+
deps.signal?.throwIfAborted();
|
|
61
|
+
result.push({ ...dimensions, data, mediaType, image_url: 'data:' + mediaType + ';base64,' + Buffer.from(data).toString('base64'),
|
|
62
|
+
scaled: !!attachment?.originalDimensions,
|
|
63
|
+
...(attachment ? { attachment } : {}),
|
|
64
|
+
...(path ? { path } : {}) });
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
const extensions = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' };
|
|
69
|
+
function quote(value) { return "'" + value.replaceAll("'", "'" + '"' + "'" + '"' + "'") + "'"; }
|
|
70
|
+
// Bytes travel on stdin, never in command arguments, stdout, or the model history.
|
|
71
|
+
const WRITE_ORIGINAL = "const fs=require('node:fs');const path=require('node:path');let s='';process.stdin.setEncoding('utf8');process.stdin.on('data',c=>s+=c);process.stdin.on('end',()=>{const p=process.argv[1];fs.mkdirSync(path.dirname(p),{recursive:true});fs.writeFileSync(p,Buffer.from(s,'base64'),{flag:'wx',mode:0o600});});";
|
|
72
|
+
export async function saveImageOutputs(response, deps) {
|
|
73
|
+
if (!absoluteWorldPath(deps.workspace))
|
|
74
|
+
throw new Error('Caller workspace must be absolute');
|
|
75
|
+
if (!deps.policy || !deps.shell.sandboxMode)
|
|
76
|
+
throw new Error('Original image writes require a policy-enforcing DSH shell');
|
|
77
|
+
let total = 0;
|
|
78
|
+
const dimensions = [];
|
|
79
|
+
for (const image of response.images) {
|
|
80
|
+
deps.signal?.throwIfAborted();
|
|
81
|
+
total += image.data.length;
|
|
82
|
+
if (!image.data.length || total > MAX_IMAGE_BYTES)
|
|
83
|
+
throw new Error('Image outputs exceed byte limit');
|
|
84
|
+
if (imageMediaType(image.data) !== image.mediaType)
|
|
85
|
+
throw new Error('Output media type does not match bytes');
|
|
86
|
+
await deps.attachments.validateImage({ data: image.data, mediaType: image.mediaType });
|
|
87
|
+
dimensions.push(encodedDimensions(image.data));
|
|
88
|
+
}
|
|
89
|
+
const workspace = await deps.fs.resolve(deps.workspace, { ...(deps.signal ? { signal: deps.signal } : {}) });
|
|
90
|
+
const images = [];
|
|
91
|
+
for (const image of response.images) {
|
|
92
|
+
deps.signal?.throwIfAborted();
|
|
93
|
+
const name = randomUUID() + '.' + extensions[image.mediaType];
|
|
94
|
+
const target = await deps.fs.resolve('.dsh/codex-images/' + name, { cwd: deps.workspace, ...(deps.signal ? { signal: deps.signal } : {}) });
|
|
95
|
+
if (!deps.fs.contains(workspace, target))
|
|
96
|
+
throw new Error('Original image target escapes caller workspace');
|
|
97
|
+
const path = deps.fs.processPath(target);
|
|
98
|
+
const outcome = await deps.shell.run(deps.shell.resolve({
|
|
99
|
+
command: 'node -e ' + quote(WRITE_ORIGINAL) + ' -- ' + quote(path),
|
|
100
|
+
stdin: Buffer.from(image.data).toString('base64'), workdir: deps.fs.processPath(workspace),
|
|
101
|
+
sandboxPolicy: deps.policy, signal: deps.signal, timeoutMs: 30_000, stdoutMaxBytes: 1024,
|
|
102
|
+
}));
|
|
103
|
+
if (outcome.sandbox?.denied || outcome.sandbox?.runnerFailed || outcome.exitCode !== 0 || outcome.aborted || outcome.timedOut)
|
|
104
|
+
throw new Error('Original image write failed under caller filesystem policy');
|
|
105
|
+
const attachment = await deps.attachments.saveImage({ data: image.data, mediaType: image.mediaType, name });
|
|
106
|
+
images.push({ ...image.metadata, path, mediaType: image.mediaType, bytes: image.data.length,
|
|
107
|
+
...(image.generation_id ? { generation_id: image.generation_id } : {}),
|
|
108
|
+
...dimensions[images.length],
|
|
109
|
+
attachment, preview: { normalized: true, scaled: !!attachment.originalDimensions, mediaType: attachment.mediaType, bytes: attachment.bytes, width: attachment.width, height: attachment.height } });
|
|
110
|
+
}
|
|
111
|
+
return { ...response.metadata, images };
|
|
112
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
type Json = null | boolean | number | string | Json[] | {
|
|
2
|
+
[key: string]: Json;
|
|
3
|
+
};
|
|
4
|
+
export interface CloudSearchArgs {
|
|
5
|
+
commands: Record<string, Json>;
|
|
6
|
+
model?: string;
|
|
7
|
+
context?: string;
|
|
8
|
+
mode?: 'cached' | 'indexed' | 'live';
|
|
9
|
+
search_context_size?: 'low' | 'medium' | 'high';
|
|
10
|
+
user_location?: Record<string, string>;
|
|
11
|
+
filters?: Record<string, string[]>;
|
|
12
|
+
image_settings?: {
|
|
13
|
+
max_results?: number;
|
|
14
|
+
caption?: boolean;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export declare function parseCloudSearchArgs(value: unknown): CloudSearchArgs;
|
|
18
|
+
/** The caller selects the model and visible context using the same request credential. */
|
|
19
|
+
export declare function buildCloudSearchRequest(args: CloudSearchArgs, context: {
|
|
20
|
+
sessionId: string;
|
|
21
|
+
accountId: string;
|
|
22
|
+
model: string;
|
|
23
|
+
input?: unknown;
|
|
24
|
+
}): Record<string, unknown>;
|
|
25
|
+
/** Keep whole opaque result entries and identify any omitted data explicitly. */
|
|
26
|
+
export declare function parseCloudSearchResponse(body: unknown): {
|
|
27
|
+
output: string;
|
|
28
|
+
results?: unknown[];
|
|
29
|
+
truncated: boolean;
|
|
30
|
+
};
|
|
31
|
+
export {};
|