@qjp88995/provider-client 0.2.0 → 0.3.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/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/justone/client.d.ts +101 -0
- package/dist/justone/client.d.ts.map +1 -0
- package/dist/justone/client.js +272 -0
- package/dist/justone/client.js.map +1 -0
- package/dist/justone/endpoint-spec.d.ts +49 -0
- package/dist/justone/endpoint-spec.d.ts.map +1 -0
- package/dist/justone/endpoint-spec.js +2 -0
- package/dist/justone/endpoint-spec.js.map +1 -0
- package/dist/justone/endpoints.generated.d.ts +7927 -0
- package/dist/justone/endpoints.generated.d.ts.map +1 -0
- package/dist/justone/endpoints.generated.js +8508 -0
- package/dist/justone/endpoints.generated.js.map +1 -0
- package/dist/justone/errors.d.ts +52 -0
- package/dist/justone/errors.d.ts.map +1 -0
- package/dist/justone/errors.js +73 -0
- package/dist/justone/errors.js.map +1 -0
- package/dist/justone/index.d.ts +8 -0
- package/dist/justone/index.d.ts.map +1 -0
- package/dist/justone/index.js +7 -0
- package/dist/justone/index.js.map +1 -0
- package/dist/justone/params.d.ts +17 -0
- package/dist/justone/params.d.ts.map +1 -0
- package/dist/justone/params.js +72 -0
- package/dist/justone/params.js.map +1 -0
- package/dist/justone/pricing.d.ts +6 -0
- package/dist/justone/pricing.d.ts.map +1 -0
- package/dist/justone/pricing.generated.d.ts +303 -0
- package/dist/justone/pricing.generated.d.ts.map +1 -0
- package/dist/justone/pricing.generated.js +305 -0
- package/dist/justone/pricing.generated.js.map +1 -0
- package/dist/justone/pricing.js +11 -0
- package/dist/justone/pricing.js.map +1 -0
- package/dist/media/bibigpt.adapter.d.ts +43 -0
- package/dist/media/bibigpt.adapter.d.ts.map +1 -0
- package/dist/media/bibigpt.adapter.js +302 -0
- package/dist/media/bibigpt.adapter.js.map +1 -0
- package/dist/media/http.d.ts +21 -0
- package/dist/media/http.d.ts.map +1 -0
- package/dist/media/http.js +39 -0
- package/dist/media/http.js.map +1 -0
- package/dist/media/index.d.ts +44 -0
- package/dist/media/index.d.ts.map +1 -0
- package/dist/media/index.js +47 -0
- package/dist/media/index.js.map +1 -0
- package/dist/media/types.d.ts +207 -0
- package/dist/media/types.d.ts.map +1 -0
- package/dist/media/types.js +21 -0
- package/dist/media/types.js.map +1 -0
- package/dist/media/zhipu-parser-package.d.ts +37 -0
- package/dist/media/zhipu-parser-package.d.ts.map +1 -0
- package/dist/media/zhipu-parser-package.js +108 -0
- package/dist/media/zhipu-parser-package.js.map +1 -0
- package/dist/media/zhipu.adapter.d.ts +37 -0
- package/dist/media/zhipu.adapter.d.ts.map +1 -0
- package/dist/media/zhipu.adapter.js +361 -0
- package/dist/media/zhipu.adapter.js.map +1 -0
- package/dist/provider-client.d.ts +49 -0
- package/dist/provider-client.d.ts.map +1 -1
- package/dist/provider-client.js +129 -0
- package/dist/provider-client.js.map +1 -1
- package/dist/types.d.ts +5 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +10 -2
- package/dist/types.js.map +1 -1
- package/dist/web-search/deepseek.adapter.d.ts +19 -0
- package/dist/web-search/deepseek.adapter.d.ts.map +1 -0
- package/dist/web-search/deepseek.adapter.js +82 -0
- package/dist/web-search/deepseek.adapter.js.map +1 -0
- package/dist/web-search/http.d.ts +28 -0
- package/dist/web-search/http.d.ts.map +1 -0
- package/dist/web-search/http.js +95 -0
- package/dist/web-search/http.js.map +1 -0
- package/dist/web-search/index.d.ts +27 -0
- package/dist/web-search/index.d.ts.map +1 -0
- package/dist/web-search/index.js +36 -0
- package/dist/web-search/index.js.map +1 -0
- package/dist/web-search/minimax.adapter.d.ts +13 -0
- package/dist/web-search/minimax.adapter.d.ts.map +1 -0
- package/dist/web-search/minimax.adapter.js +82 -0
- package/dist/web-search/minimax.adapter.js.map +1 -0
- package/dist/web-search/moonshotai.adapter.d.ts +19 -0
- package/dist/web-search/moonshotai.adapter.d.ts.map +1 -0
- package/dist/web-search/moonshotai.adapter.js +151 -0
- package/dist/web-search/moonshotai.adapter.js.map +1 -0
- package/dist/web-search/types.d.ts +120 -0
- package/dist/web-search/types.d.ts.map +1 -0
- package/dist/web-search/types.js +60 -0
- package/dist/web-search/types.js.map +1 -0
- package/dist/web-search/zhipu.adapter.d.ts +14 -0
- package/dist/web-search/zhipu.adapter.d.ts.map +1 -0
- package/dist/web-search/zhipu.adapter.js +98 -0
- package/dist/web-search/zhipu.adapter.js.map +1 -0
- package/package.json +4 -3
- package/src/index.ts +99 -0
- package/src/justone/client.ts +462 -0
- package/src/justone/endpoint-spec.ts +66 -0
- package/src/justone/endpoints.generated.ts +9258 -0
- package/src/justone/errors.ts +97 -0
- package/src/justone/index.ts +34 -0
- package/src/justone/params.ts +91 -0
- package/src/justone/pricing.generated.ts +308 -0
- package/src/justone/pricing.ts +11 -0
- package/src/media/bibigpt.adapter.ts +372 -0
- package/src/media/http.ts +43 -0
- package/src/media/index.ts +114 -0
- package/src/media/types.ts +233 -0
- package/src/media/zhipu-parser-package.ts +149 -0
- package/src/media/zhipu.adapter.ts +510 -0
- package/src/provider-client.ts +228 -1
- package/src/types.ts +10 -2
- package/src/web-search/deepseek.adapter.ts +149 -0
- package/src/web-search/http.ts +108 -0
- package/src/web-search/index.ts +70 -0
- package/src/web-search/minimax.adapter.ts +148 -0
- package/src/web-search/moonshotai.adapter.ts +228 -0
- package/src/web-search/types.ts +172 -0
- package/src/web-search/zhipu.adapter.ts +184 -0
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import type { VendorFetch } from '../vendors/index.js';
|
|
2
|
+
import {
|
|
3
|
+
bearer,
|
|
4
|
+
errorMessage,
|
|
5
|
+
joinUrl,
|
|
6
|
+
throwIfCenterConfigError,
|
|
7
|
+
} from './http.js';
|
|
8
|
+
import {
|
|
9
|
+
type AudioOutcome,
|
|
10
|
+
type AudioRequest,
|
|
11
|
+
type CacheKeyOpts,
|
|
12
|
+
type FileOutcome,
|
|
13
|
+
type FileRequest,
|
|
14
|
+
type MediaAdapter,
|
|
15
|
+
type MediaCapability,
|
|
16
|
+
type MediaConnection,
|
|
17
|
+
type MediaInput,
|
|
18
|
+
type MediaSettings,
|
|
19
|
+
MediaUpstreamError,
|
|
20
|
+
type UploadLimitHint,
|
|
21
|
+
type ZhipuFileToolType,
|
|
22
|
+
} from './types.js';
|
|
23
|
+
import { parseParserPackage } from './zhipu-parser-package.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 智谱 BigModel 的文件解析与语音转写(agent-base 里叫 `bigmodel`),借智谱 LLM 的 key。根取自这家的 `media` 端点
|
|
27
|
+
* (`https://open.bigmodel.cn/api/paas/v4`),下挂 `/audio/transcriptions`、`/files/parser/…`。
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
type Settings = MediaSettings<'zhipu'>;
|
|
31
|
+
|
|
32
|
+
const DEFAULT_AUDIO_MODEL = 'glm-asr-2512';
|
|
33
|
+
const DEFAULT_TOOL_TYPE: ZhipuFileToolType = 'lite';
|
|
34
|
+
const DEFAULT_MAX_WAIT_MS = 60_000;
|
|
35
|
+
|
|
36
|
+
function resolved(settings: Settings) {
|
|
37
|
+
return {
|
|
38
|
+
audioModel: settings.audio?.model ?? DEFAULT_AUDIO_MODEL,
|
|
39
|
+
toolType: settings.file?.toolType ?? DEFAULT_TOOL_TYPE,
|
|
40
|
+
maxWaitMs: settings.file?.defaultMaxWaitMs ?? DEFAULT_MAX_WAIT_MS,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* MIME → 上传时给厂商的文件扩展名。别处若另有一张「收得下哪些格式」的表,它必须是本表的子集——两张表漂了的表现是
|
|
46
|
+
* 「上游收得下的格式,适配器造不出正确的文件名」,是静默的错误。
|
|
47
|
+
*/
|
|
48
|
+
export const ZHIPU_MIME_TO_EXT: Readonly<Record<string, string>> = {
|
|
49
|
+
'audio/mpeg': 'mp3',
|
|
50
|
+
'audio/mp3': 'mp3',
|
|
51
|
+
'audio/wav': 'wav',
|
|
52
|
+
'audio/wave': 'wav',
|
|
53
|
+
'audio/x-wav': 'wav',
|
|
54
|
+
'application/pdf': 'pdf',
|
|
55
|
+
'application/msword': 'doc',
|
|
56
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
|
|
57
|
+
'docx',
|
|
58
|
+
'application/vnd.ms-excel': 'xls',
|
|
59
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
|
|
60
|
+
'application/vnd.ms-powerpoint': 'ppt',
|
|
61
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation':
|
|
62
|
+
'pptx',
|
|
63
|
+
'text/plain': 'txt',
|
|
64
|
+
'text/markdown': 'md',
|
|
65
|
+
'text/csv': 'csv',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function extOf(mime: string): string | undefined {
|
|
69
|
+
return ZHIPU_MIME_TO_EXT[mime.toLowerCase()];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 厂商文档写的是「MB」且未注明进制,按十进制(1 MB = 1000²)取下界——若厂商实际按 2 的幂算,我们只是保守了 4.8%,
|
|
74
|
+
* 反过来就会把一份超限 4.8% 的文件白传一趟。
|
|
75
|
+
*/
|
|
76
|
+
const MB = 1000 * 1000;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 各上限**逐字来自厂商文档**,改前先核对(数字过时的表现是静默的:要么白传被拒、要么把能解析的文件挡在门外):
|
|
80
|
+
* - 文件解析 https://docs.bigmodel.cn/cn/guide/tools/file-parser.md :Lite ≤50M;Expert ≤100M(仅 PDF);
|
|
81
|
+
* Prime 按格式——PDF/DOC/DOCX/PPT ≤100MB、XLS/XLSX/CSV ≤10MB、图片 ≤20MB(图片不该送到这里:看图归 LLM)。
|
|
82
|
+
* txt/md 等 Prime 支持列表里有、尺寸表里没有的格式,取 Lite 同款 50MB 当下界。
|
|
83
|
+
* - 语音转写 https://docs.bigmodel.cn/api-reference/模型-api/语音转文本.md :单文件 ≤25MB(还有 ≤30 秒时长限制,
|
|
84
|
+
* 字节层看不出时长,超时长交给厂商响亮报错)。
|
|
85
|
+
*/
|
|
86
|
+
export const ZHIPU_AUDIO_MAX_BYTES = 25 * MB;
|
|
87
|
+
const FILE_PRIME_SPREADSHEET_MAX = 10 * MB;
|
|
88
|
+
const FILE_PRIME_DOCUMENT_MAX = 100 * MB;
|
|
89
|
+
const FILE_LITE_MAX = 50 * MB;
|
|
90
|
+
const FILE_EXPERT_MAX = 100 * MB;
|
|
91
|
+
|
|
92
|
+
const PRIME_SPREADSHEET_EXTS = new Set(['xls', 'xlsx', 'csv']);
|
|
93
|
+
const PRIME_DOCUMENT_EXTS = new Set(['pdf', 'doc', 'docx', 'ppt', 'pptx']);
|
|
94
|
+
|
|
95
|
+
async function loadInput(
|
|
96
|
+
input: MediaInput,
|
|
97
|
+
download: VendorFetch,
|
|
98
|
+
signal: AbortSignal | undefined
|
|
99
|
+
): Promise<{ bytes: Uint8Array; mediaType: string }> {
|
|
100
|
+
if (input.kind === 'bytes') {
|
|
101
|
+
return { bytes: input.bytes, mediaType: input.mediaType };
|
|
102
|
+
}
|
|
103
|
+
const r = await download(input.url, signal ? { signal } : undefined);
|
|
104
|
+
if (!r.ok) {
|
|
105
|
+
throw new MediaUpstreamError(`media URL fetch ${r.status}: ${input.url}`);
|
|
106
|
+
}
|
|
107
|
+
const mediaType =
|
|
108
|
+
r.headers.get('content-type')?.split(';')[0]?.trim() ||
|
|
109
|
+
'application/octet-stream';
|
|
110
|
+
return { bytes: new Uint8Array(await r.arrayBuffer()), mediaType };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function blobOf(bytes: Uint8Array, mediaType: string): Blob {
|
|
114
|
+
// 复制一份:调用方给的可能是 Buffer 池里的一段(`byteOffset` 不为 0),直接包会把整块池子都传上去。
|
|
115
|
+
return new Blob([new Uint8Array(bytes)], { type: mediaType });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface AudioBody {
|
|
119
|
+
text?: string;
|
|
120
|
+
duration?: number;
|
|
121
|
+
language?: string;
|
|
122
|
+
segments?: Array<{ start: number; end: number; text: string }>;
|
|
123
|
+
}
|
|
124
|
+
interface ParserCreateBody {
|
|
125
|
+
task_id?: string;
|
|
126
|
+
}
|
|
127
|
+
interface ParserResultBody {
|
|
128
|
+
status: 'processing' | 'succeeded' | 'failed';
|
|
129
|
+
content?: string;
|
|
130
|
+
message?: string;
|
|
131
|
+
task_id?: string;
|
|
132
|
+
parsing_result_url?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export class ZhipuMediaAdapter implements MediaAdapter<'zhipu'> {
|
|
136
|
+
readonly kind = 'zhipu' as const;
|
|
137
|
+
readonly endpointProtocol = 'media' as const;
|
|
138
|
+
readonly capabilities = ['audio', 'file'] as const;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* - `audio`:转写是确定性的,产出由模型与语言提示决定。
|
|
142
|
+
* - `file`:版面解析同样确定,产出只由档位决定;轮询预算等多久都不改变内容,不进键。
|
|
143
|
+
* `images: true` 是 agent-base 2026-09-07 加的**产出版本号**:expert/prime 从此带图片,与之前只取 `/text`
|
|
144
|
+
* 的缓存行不是同一种产出;lite 的产出没变,键也刻意不动。**照抄,别改**——改了 APP 里的存量缓存全部失效。
|
|
145
|
+
*/
|
|
146
|
+
cacheKeyParts(
|
|
147
|
+
capability: MediaCapability,
|
|
148
|
+
settings: Settings,
|
|
149
|
+
opts: CacheKeyOpts
|
|
150
|
+
): Record<string, unknown> | null {
|
|
151
|
+
const s = resolved(settings);
|
|
152
|
+
if (capability === 'audio') {
|
|
153
|
+
return { model: s.audioModel, languageHint: opts.languageHint ?? null };
|
|
154
|
+
}
|
|
155
|
+
if (capability === 'file') {
|
|
156
|
+
return s.toolType === 'lite'
|
|
157
|
+
? { toolType: 'lite' }
|
|
158
|
+
: { toolType: s.toolType, images: true };
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
maxUploadBytes(
|
|
164
|
+
capability: MediaCapability,
|
|
165
|
+
settings: Settings,
|
|
166
|
+
hint: UploadLimitHint
|
|
167
|
+
): number | null {
|
|
168
|
+
if (capability === 'audio') return ZHIPU_AUDIO_MAX_BYTES;
|
|
169
|
+
if (capability !== 'file') return null;
|
|
170
|
+
switch (resolved(settings).toolType) {
|
|
171
|
+
case 'lite':
|
|
172
|
+
return FILE_LITE_MAX;
|
|
173
|
+
case 'expert':
|
|
174
|
+
return FILE_EXPERT_MAX;
|
|
175
|
+
case 'prime': {
|
|
176
|
+
const ext = hint.mediaType ? extOf(hint.mediaType) : undefined;
|
|
177
|
+
if (ext && PRIME_SPREADSHEET_EXTS.has(ext)) {
|
|
178
|
+
return FILE_PRIME_SPREADSHEET_MAX;
|
|
179
|
+
}
|
|
180
|
+
if (ext && PRIME_DOCUMENT_EXTS.has(ext)) return FILE_PRIME_DOCUMENT_MAX;
|
|
181
|
+
// 类型未知或尺寸表没列的格式:取各档最小的文档上限当下界。
|
|
182
|
+
return FILE_LITE_MAX;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async transcribeAudio(
|
|
188
|
+
conn: MediaConnection,
|
|
189
|
+
settings: Settings,
|
|
190
|
+
req: AudioRequest
|
|
191
|
+
): Promise<AudioOutcome> {
|
|
192
|
+
const s = resolved(settings);
|
|
193
|
+
const download = conn.download ?? globalThis.fetch;
|
|
194
|
+
const { bytes, mediaType } = await loadInput(
|
|
195
|
+
req.input,
|
|
196
|
+
download,
|
|
197
|
+
req.signal
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const form = new FormData();
|
|
201
|
+
form.append('model', s.audioModel);
|
|
202
|
+
form.append(
|
|
203
|
+
'file',
|
|
204
|
+
blobOf(bytes, mediaType),
|
|
205
|
+
`upload.${extOf(mediaType) ?? 'bin'}`
|
|
206
|
+
);
|
|
207
|
+
if (req.languageHint && req.languageHint !== 'auto') {
|
|
208
|
+
form.append('language', req.languageHint);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const resp = await sendRetryingNetwork(
|
|
212
|
+
conn.openTask(),
|
|
213
|
+
joinUrl(conn.baseUrl, 'audio/transcriptions'),
|
|
214
|
+
{
|
|
215
|
+
method: 'POST',
|
|
216
|
+
headers: bearer(conn.apiKey),
|
|
217
|
+
body: form,
|
|
218
|
+
},
|
|
219
|
+
{ signal: req.signal }
|
|
220
|
+
);
|
|
221
|
+
if (!resp.ok) {
|
|
222
|
+
throw new MediaUpstreamError(
|
|
223
|
+
`bigmodel audio/transcriptions ${resp.status}: ${await resp.text()}`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
const json = (await resp.json()) as AudioBody;
|
|
227
|
+
return {
|
|
228
|
+
text: json.text ?? '',
|
|
229
|
+
rawResponse: json,
|
|
230
|
+
...(typeof json.duration === 'number'
|
|
231
|
+
? { audioDurationMs: Math.round(json.duration * 1000) }
|
|
232
|
+
: {}),
|
|
233
|
+
...(json.language !== undefined ? { language: json.language } : {}),
|
|
234
|
+
...(json.segments !== undefined ? { segments: json.segments } : {}),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async parseFile(
|
|
239
|
+
conn: MediaConnection,
|
|
240
|
+
settings: Settings,
|
|
241
|
+
req: FileRequest
|
|
242
|
+
): Promise<FileOutcome> {
|
|
243
|
+
const s = resolved(settings);
|
|
244
|
+
const download = conn.download ?? globalThis.fetch;
|
|
245
|
+
const { bytes, mediaType } = await loadInput(
|
|
246
|
+
req.input,
|
|
247
|
+
download,
|
|
248
|
+
req.signal
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
// 认不出 mime 就当场停下,别送一个 `file_type=bin` 上去:厂商回的是「文件格式暂不支持:支持类型为 pdf, doc,
|
|
252
|
+
// docx, …」——一句把 docx 列在支持列表里、却正是在拒绝一份 docx 的话,谁看了都会往错的方向查。
|
|
253
|
+
const ext = extOf(mediaType);
|
|
254
|
+
if (!ext) {
|
|
255
|
+
throw new MediaUpstreamError(
|
|
256
|
+
`bigmodel file parser: 不认识的文件类型 "${mediaType}",无法告诉厂商这是什么格式。` +
|
|
257
|
+
`本适配器能标注的是 ${[...new Set(Object.values(ZHIPU_MIME_TO_EXT))].join(' / ')}。`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
// 厂商文档:expert 只吃 PDF。送别的格式上去回的是一句泛泛的「格式不支持」,让人去怀疑文件本身。
|
|
261
|
+
if (s.toolType === 'expert' && ext !== 'pdf') {
|
|
262
|
+
throw new MediaUpstreamError(
|
|
263
|
+
`bigmodel file parser: expert 档只解析 PDF,这是一份 ${ext}。` +
|
|
264
|
+
'改用 prime 档(全格式)或 lite 档(无图片)。'
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 建任务与轮询必须落在同一把 key 上:任务属于那个账号。
|
|
269
|
+
const send = conn.openTask();
|
|
270
|
+
const createForm = new FormData();
|
|
271
|
+
createForm.append('file', blobOf(bytes, mediaType), `upload.${ext}`);
|
|
272
|
+
createForm.append('tool_type', s.toolType);
|
|
273
|
+
createForm.append('file_type', ext);
|
|
274
|
+
|
|
275
|
+
const createResp = await sendRetryingNetwork(
|
|
276
|
+
send,
|
|
277
|
+
joinUrl(conn.baseUrl, 'files/parser/create'),
|
|
278
|
+
{
|
|
279
|
+
method: 'POST',
|
|
280
|
+
headers: bearer(conn.apiKey),
|
|
281
|
+
body: createForm,
|
|
282
|
+
},
|
|
283
|
+
{ timeoutMs: CREATE_TIMEOUT_MS, signal: req.signal }
|
|
284
|
+
);
|
|
285
|
+
if (!createResp.ok) {
|
|
286
|
+
throw new MediaUpstreamError(
|
|
287
|
+
`bigmodel files/parser/create ${createResp.status}: ${await createResp.text()}`
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
const createJson = (await createResp.json()) as ParserCreateBody;
|
|
291
|
+
if (!createJson.task_id) {
|
|
292
|
+
throw new MediaUpstreamError(
|
|
293
|
+
`bigmodel files/parser/create returned no task_id: ${JSON.stringify(createJson)}`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
const taskId = createJson.task_id;
|
|
297
|
+
const budgetMs = req.maxWaitMs ?? s.maxWaitMs;
|
|
298
|
+
const vendorExtra = { taskId, toolType: s.toolType };
|
|
299
|
+
const poll = (ms: number, format: 'text' | 'download_link') =>
|
|
300
|
+
pollResult(conn, send, taskId, ms, format, req.signal);
|
|
301
|
+
|
|
302
|
+
// lite 不产出图片,`/text` 就是全部。
|
|
303
|
+
if (s.toolType === 'lite') {
|
|
304
|
+
const result = await poll(budgetMs, 'text');
|
|
305
|
+
return {
|
|
306
|
+
text: result.content ?? '',
|
|
307
|
+
markdown: result.content ?? '',
|
|
308
|
+
rawResponse: result,
|
|
309
|
+
vendorExtra,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// expert / prime:图片只在 `download_link` 的结果包里(`/text` 的正文与包里的 md 逐字节相同,但引用的
|
|
314
|
+
// `images/…` 在我们这边打不开)。包拿不到就退回 `/text` 并标 degraded——正文已经花了钱,不该为一次下载失败作废。
|
|
315
|
+
const link = await poll(budgetMs, 'download_link');
|
|
316
|
+
let degraded: string;
|
|
317
|
+
if (link.parsing_result_url) {
|
|
318
|
+
try {
|
|
319
|
+
const zip = await downloadWithRetry(
|
|
320
|
+
download,
|
|
321
|
+
link.parsing_result_url,
|
|
322
|
+
req.signal
|
|
323
|
+
);
|
|
324
|
+
const pkg = parseParserPackage(zip);
|
|
325
|
+
return {
|
|
326
|
+
text: pkg.markdown,
|
|
327
|
+
markdown: pkg.markdown,
|
|
328
|
+
rawResponse: link,
|
|
329
|
+
...(pkg.pageCount !== undefined ? { pageCount: pkg.pageCount } : {}),
|
|
330
|
+
images: pkg.images,
|
|
331
|
+
vendorExtra: {
|
|
332
|
+
...vendorExtra,
|
|
333
|
+
parsingResultUrl: link.parsing_result_url,
|
|
334
|
+
...(pkg.missingRefs.length > 0
|
|
335
|
+
? { missingImageRefs: pkg.missingRefs }
|
|
336
|
+
: {}),
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
} catch (err) {
|
|
340
|
+
if (req.signal?.aborted) throw err;
|
|
341
|
+
degraded = `结果包没拿到,图片缺失:${errorMessage(err)}`;
|
|
342
|
+
}
|
|
343
|
+
} else {
|
|
344
|
+
degraded = '厂商没有返回结果包链接(parsing_result_url 为空),图片缺失';
|
|
345
|
+
}
|
|
346
|
+
// 任务已经 succeeded,`/text` 是即时的;预算只是防御厂商侧抽风。
|
|
347
|
+
const result = await poll(30_000, 'text');
|
|
348
|
+
return {
|
|
349
|
+
text: result.content ?? '',
|
|
350
|
+
markdown: result.content ?? '',
|
|
351
|
+
rawResponse: result,
|
|
352
|
+
vendorExtra,
|
|
353
|
+
degraded,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function pollResult(
|
|
359
|
+
conn: MediaConnection,
|
|
360
|
+
send: VendorFetch,
|
|
361
|
+
taskId: string,
|
|
362
|
+
budgetMs: number,
|
|
363
|
+
format: 'text' | 'download_link',
|
|
364
|
+
signal: AbortSignal | undefined
|
|
365
|
+
): Promise<ParserResultBody> {
|
|
366
|
+
const start = Date.now();
|
|
367
|
+
let delay = 500;
|
|
368
|
+
let attempts = 0;
|
|
369
|
+
for (;;) {
|
|
370
|
+
attempts++;
|
|
371
|
+
const url = joinUrl(
|
|
372
|
+
conn.baseUrl,
|
|
373
|
+
`files/parser/result/${encodeURIComponent(taskId)}/${format}`
|
|
374
|
+
);
|
|
375
|
+
const resp = await sendRetryingNetwork(
|
|
376
|
+
send,
|
|
377
|
+
url,
|
|
378
|
+
{ method: 'GET', headers: bearer(conn.apiKey) },
|
|
379
|
+
{ timeoutMs: POLL_TIMEOUT_MS, signal }
|
|
380
|
+
);
|
|
381
|
+
if (!resp.ok) {
|
|
382
|
+
throw new MediaUpstreamError(
|
|
383
|
+
`bigmodel files/parser/result ${resp.status}: ${await resp.text()}`
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
const json = (await resp.json()) as ParserResultBody;
|
|
387
|
+
if (json.status === 'succeeded') return json;
|
|
388
|
+
if (json.status === 'failed') {
|
|
389
|
+
throw new MediaUpstreamError(
|
|
390
|
+
`bigmodel file parser failed: ${json.message ?? 'unknown'} (task_id=${taskId})`
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
const elapsed = Date.now() - start;
|
|
394
|
+
if (elapsed + delay > budgetMs) {
|
|
395
|
+
throw new MediaUpstreamError(
|
|
396
|
+
`bigmodel file parser timeout after ${elapsed}ms attempts=${attempts} task_id=${taskId}`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
await sleep(delay, signal);
|
|
400
|
+
delay = Math.min(delay * 2, 2000);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** 连不上厂商(没拿到任何响应)时再试的次数,含第一次。 */
|
|
405
|
+
const NETWORK_ATTEMPTS = 3;
|
|
406
|
+
/**
|
|
407
|
+
* 建解析任务(带文件上传)单次的超时。几 MB 的文件正常几秒内回;2026-09-25 实测一次上传被拖了 298 秒才回,
|
|
408
|
+
* 工具先在 300 秒处超时,任务在厂商侧建了、结果没人取。三次 × 60 秒仍落在工具的等待上限之内。
|
|
409
|
+
*/
|
|
410
|
+
const CREATE_TIMEOUT_MS = 60_000;
|
|
411
|
+
/** 查询解析结果单次的超时(只是一个 GET,正常百毫秒级)。 */
|
|
412
|
+
const POLL_TIMEOUT_MS = 15_000;
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* 发到厂商 API 的请求:**只在没拿到响应时重试**——网络层失败(`TypeError: fetch failed`)、给了 `timeoutMs`
|
|
416
|
+
* 时的单次超时、中心报 `upstream_error`(中心连不上厂商);拿到了厂商的响应(哪怕 4xx / 5xx)就原样返回、不重试:
|
|
417
|
+
* 那说明请求到了厂商那边。中心自己报的其余错误(没有可用 key……)抛 `ProviderClientError`。调用方的中止原样抛。
|
|
418
|
+
*
|
|
419
|
+
* 超时重试**可能**让建任务在厂商侧重复一次(请求其实到了、只是回应迟迟没来);不设超时的话工具整体超时后模型照样会
|
|
420
|
+
* 重试,而且还要先干等到工具超时。音频转写不给 `timeoutMs`:同步接口,长音频本来就要转一阵。
|
|
421
|
+
*/
|
|
422
|
+
async function sendRetryingNetwork(
|
|
423
|
+
send: VendorFetch,
|
|
424
|
+
url: string,
|
|
425
|
+
init: RequestInit,
|
|
426
|
+
opts: { timeoutMs?: number; signal?: AbortSignal }
|
|
427
|
+
): Promise<Response> {
|
|
428
|
+
let lastErr: unknown;
|
|
429
|
+
for (let attempt = 1; attempt <= NETWORK_ATTEMPTS; attempt++) {
|
|
430
|
+
const signals = [
|
|
431
|
+
...(opts.signal ? [opts.signal] : []),
|
|
432
|
+
...(opts.timeoutMs !== undefined
|
|
433
|
+
? [AbortSignal.timeout(opts.timeoutMs)]
|
|
434
|
+
: []),
|
|
435
|
+
];
|
|
436
|
+
try {
|
|
437
|
+
const res = await send(url, {
|
|
438
|
+
...init,
|
|
439
|
+
...(signals.length > 0 ? { signal: AbortSignal.any(signals) } : {}),
|
|
440
|
+
});
|
|
441
|
+
const center = await throwIfCenterConfigError(res);
|
|
442
|
+
if (!center) return res;
|
|
443
|
+
lastErr = new MediaUpstreamError(
|
|
444
|
+
`bigmodel ${new URL(url).pathname}: ${center.upstream}`
|
|
445
|
+
);
|
|
446
|
+
} catch (err) {
|
|
447
|
+
if (opts.signal?.aborted) throw err;
|
|
448
|
+
const timedOut = err instanceof Error && err.name === 'TimeoutError';
|
|
449
|
+
if (!(err instanceof TypeError) && !timedOut) throw err;
|
|
450
|
+
lastErr = err;
|
|
451
|
+
}
|
|
452
|
+
if (attempt < NETWORK_ATTEMPTS) await sleep(1000 * attempt, opts.signal);
|
|
453
|
+
}
|
|
454
|
+
throw lastErr;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** 单次下载的超时与重试次数;数字出处见下方注释。 */
|
|
458
|
+
const PACKAGE_DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
459
|
+
const PACKAGE_DOWNLOAD_ATTEMPTS = 3;
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* 下载结果包(不经中心:链接是厂商的对象存储,带签名、不要 key)。链接指向 UCloud 对象存储(明文 http,24h 有效)。
|
|
463
|
+
* **2026-09-07 探针里 Node 原生 fetch 约三分之一的次数在 70s 后 `read ETIMEDOUT`、同一链接换个连接立刻 1s 下完**,
|
|
464
|
+
* 是对端偶发的半开连接。所以每次给 30s、最多三次,而不是等 undici 自己 5 分钟的 body 超时。
|
|
465
|
+
*/
|
|
466
|
+
async function downloadWithRetry(
|
|
467
|
+
download: VendorFetch,
|
|
468
|
+
url: string,
|
|
469
|
+
signal: AbortSignal | undefined
|
|
470
|
+
): Promise<Uint8Array> {
|
|
471
|
+
let lastErr: unknown;
|
|
472
|
+
for (let attempt = 1; attempt <= PACKAGE_DOWNLOAD_ATTEMPTS; attempt++) {
|
|
473
|
+
const timeout = AbortSignal.timeout(PACKAGE_DOWNLOAD_TIMEOUT_MS);
|
|
474
|
+
try {
|
|
475
|
+
const r = await download(url, {
|
|
476
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
477
|
+
});
|
|
478
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
479
|
+
return new Uint8Array(await r.arrayBuffer());
|
|
480
|
+
} catch (err) {
|
|
481
|
+
if (signal?.aborted) throw err;
|
|
482
|
+
lastErr = err;
|
|
483
|
+
if (attempt < PACKAGE_DOWNLOAD_ATTEMPTS) {
|
|
484
|
+
await sleep(1000 * attempt, signal);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
throw new Error(
|
|
489
|
+
`结果包下载 ${PACKAGE_DOWNLOAD_ATTEMPTS} 次均失败:${errorMessage(lastErr)}`
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** 等一会儿;调用方中止时立刻抛出中止原因。 */
|
|
494
|
+
function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
|
495
|
+
return new Promise((resolve, reject) => {
|
|
496
|
+
if (signal?.aborted) {
|
|
497
|
+
reject(signal.reason as Error);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const timer = setTimeout(() => {
|
|
501
|
+
signal?.removeEventListener('abort', onAbort);
|
|
502
|
+
resolve();
|
|
503
|
+
}, ms);
|
|
504
|
+
const onAbort = () => {
|
|
505
|
+
clearTimeout(timer);
|
|
506
|
+
reject(signal!.reason as Error);
|
|
507
|
+
};
|
|
508
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
509
|
+
});
|
|
510
|
+
}
|