gangtise-openapi-cli 0.27.0 → 0.28.1

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.
@@ -90,38 +90,154 @@ export function parseChoiceList(values, optionName, allowed) {
90
90
  }
91
91
  return maybeArray(values);
92
92
  }
93
+ const YYYY_MM_DD = /^\d{4}-\d{2}-\d{2}$/;
94
+ /**
95
+ * Strict `YYYY-MM-DD` guard for Quote/Fundamental date options.
96
+ *
97
+ * Beyond the documented shape the server accepts two extra year-last formats
98
+ * whose day/month order is *opposite* to each other (probed 2026-07-20 on
99
+ * quote/kline/daily and fundamental/balance-sheet; other groups sharing these
100
+ * flags were not probed, so the guard is applied uniformly as the safe default):
101
+ *
102
+ * "07/01/2026" -> 2026-01-07 slash reads DD/MM/YYYY
103
+ * "07-01-2026" -> 2026-07-01 hyphen reads MM-DD-YYYY
104
+ *
105
+ * Same three digits, six months apart, both HTTP 200, and nothing in the
106
+ * response echoes which date the server actually used. Confirmed by the
107
+ * complement: "25/12/2026" parses while "12/25/2026" errors, and the hyphen
108
+ * forms behave exactly the other way round. Since the CLI cannot know which
109
+ * reading was meant, it forwards only the unambiguous form.
110
+ *
111
+ * Other unambiguous shapes (`20260701`, `2026/07/01`) are rejected too, even
112
+ * though the server handles them — one accepted form beats a per-shape allowlist
113
+ * that has to be re-probed per endpoint group. The message says which form is
114
+ * wanted rather than claiming the input itself was ambiguous.
115
+ *
116
+ * Datetime options (`--start-time`) are guarded separately: pass-through ones by
117
+ * `parseDatetimeOption` (a timezone-free field check that returns the string as-is),
118
+ * and the two conversion endpoints (A-share announcement / knowledge-batch) by
119
+ * `parseTimestamp13`.
120
+ */
121
+ export function parseDateOption(value, optionName) {
122
+ if (!YYYY_MM_DD.test(value)) {
123
+ throw new ValidationError(`Invalid ${optionName}: expected YYYY-MM-DD, got "${value}" — only that form is forwarded; some endpoints silently misread other layouts (e.g. "07/01/2026") as a different day`);
124
+ }
125
+ // Shape alone lets 2026-02-30 / 2026-13-01 through; round-trip to reject those.
126
+ // Built from the ISO string, not Date.UTC(y,...), whose two-digit-year mapping
127
+ // would turn a valid year 0050 into 1950 and report it as a non-existent date.
128
+ const [year, month, day] = value.split("-").map(Number);
129
+ const parsed = new Date(`${value}T00:00:00Z`);
130
+ if (parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1 || parsed.getUTCDate() !== day) {
131
+ throw new ValidationError(`Invalid ${optionName}: "${value}" is not a real calendar date`);
132
+ }
133
+ return value;
134
+ }
135
+ /** Commander argParser factory — `.option("--start-date <date>", desc, dateArg("--start-date"))`. */
136
+ export function dateArg(optionName) {
137
+ return (value) => parseDateOption(value, optionName);
138
+ }
139
+ /** `yyyy-MM-dd` with an optional ` HH:mm[:ss]` / `THH:mm[:ss]` tail. Anything else
140
+ * is rejected rather than handed to `new Date()`: V8's fallback parser accepts the
141
+ * same year-last shapes the server does but reads them the OTHER way round —
142
+ * `07/01/2026` is July 1 to V8 and January 7 to the server, `25/12/2026` is invalid
143
+ * to V8 and valid to the server (both probed 2026-07-20). Since `announcement list`
144
+ * converts locally while its HK/US siblings pass the string through, an open
145
+ * fallback made the same flag mean two dates six months apart across sibling
146
+ * commands, silently and with exit 0. */
147
+ const LOCAL_DATETIME = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
148
+ /** Field-level datetime validation, timezone-free — a real calendar day plus a
149
+ * valid clock time, judged by arithmetic alone (no Date construction, so no
150
+ * dependence on the client's timezone or DST). This is what the pass-through guard
151
+ * needs: a string the CLI forwards verbatim must be judged on its fields, not on
152
+ * whether the local zone happens to contain that wall-clock instant. */
153
+ function datetimeFieldsValid(value) {
154
+ const parts = LOCAL_DATETIME.exec(value);
155
+ if (!parts)
156
+ return false;
157
+ const [, y, mo, d, hh = "0", mi = "0", ss = "0"] = parts;
158
+ const year = Number(y), month = Number(mo), day = Number(d);
159
+ if (month < 1 || month > 12)
160
+ return false;
161
+ const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
162
+ const dim = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
163
+ if (day < 1 || day > dim)
164
+ return false;
165
+ return Number(hh) <= 23 && Number(mi) <= 59 && Number(ss) <= 59;
166
+ }
167
+ /** A 10-digit seconds or 13-digit millis epoch, normalized to millis. Judged by
168
+ * digit count, NOT magnitude: a `> 1e12` test sends a real 13-digit `1000000000000`
169
+ * (which equals 1e12) down the seconds branch, and lets Number() coerce scientific
170
+ * / hex / whitespace-padded / 11–12–14-digit inputs through as a "timestamp" — all
171
+ * of which then convert wrong or get rejected by the server. Exactly 10 or 13
172
+ * digits, nothing else. */
173
+ function epochMillis(value) {
174
+ if (/^\d{13}$/.test(value))
175
+ return Number(value);
176
+ if (/^\d{10}$/.test(value))
177
+ return Number(value) * 1000;
178
+ return undefined;
179
+ }
93
180
  export function toTimestamp13(value) {
94
181
  if (value === undefined)
95
182
  return undefined;
96
- const num = Number(value);
97
- if (!Number.isNaN(num) && num > 1e12)
98
- return num;
99
- if (!Number.isNaN(num) && num > 1e9)
100
- return num * 1000;
101
- // `new Date("yyyy-MM-dd")` parses as UTC midnight while `new Date("yyyy-MM-dd HH:mm:ss")`
102
- // parses as local time — for CST users the two forms would differ by 8 hours and
103
- // silently shift the query window. Anchor date-only input to local midnight so both
104
- // forms mean the same wall-clock day.
105
- const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
106
- if (dateOnly) {
107
- const d = new Date(Number(dateOnly[1]), Number(dateOnly[2]) - 1, Number(dateOnly[3]));
108
- const valid = d.getMonth() === Number(dateOnly[2]) - 1 && d.getDate() === Number(dateOnly[3]);
109
- return valid ? d.getTime() : undefined;
110
- }
111
- const ms = new Date(value).getTime();
112
- if (Number.isNaN(ms))
183
+ const ts = epochMillis(value);
184
+ if (ts !== undefined)
185
+ return ts;
186
+ if (!datetimeFieldsValid(value))
113
187
  return undefined;
114
- return ms;
188
+ // `new Date("yyyy-MM-dd")` parses as UTC midnight while `new Date("yyyy-MM-dd
189
+ // HH:mm:ss")` parses as local time — for CST users the two forms would differ by 8
190
+ // hours. Build from local components so both mean the same wall-clock day.
191
+ const parts = LOCAL_DATETIME.exec(value);
192
+ const [, y, mo, d, hh = "0", mi = "0", ss = "0"] = parts;
193
+ const year = Number(y);
194
+ const dt = new Date(year, Number(mo) - 1, Number(d), Number(hh), Number(mi), Number(ss));
195
+ // Full round-trip on every field: rejects the two inputs that cannot convert
196
+ // faithfully — the two-digit-year remap (new Date(50,…) → 1950) and a DST gap (a
197
+ // wall-clock time the local zone skips: 02:30 on a US spring-forward morning, or
198
+ // 02:15 in Lord Howe's 30-minute gap, which only a minute-level check catches).
199
+ const faithful = dt.getFullYear() === year && dt.getMonth() === Number(mo) - 1
200
+ && dt.getDate() === Number(d) && dt.getHours() === Number(hh)
201
+ && dt.getMinutes() === Number(mi) && dt.getSeconds() === Number(ss);
202
+ return faithful ? dt.getTime() : undefined;
115
203
  }
116
204
  export function parseTimestamp13(value, optionName) {
117
205
  if (value === undefined)
118
206
  return undefined;
119
207
  const parsed = toTimestamp13(value);
120
208
  if (parsed === undefined) {
121
- throw new ValidationError(`Invalid ${optionName}: expected a Unix timestamp or date string`);
209
+ throw new ValidationError(`Invalid ${optionName}: expected a Unix timestamp or "YYYY-MM-DD" optionally with " HH:mm[:ss]" (space or T separator), got "${value}" — year-last forms are refused because Node and the API read their day/month order the opposite way round`);
122
210
  }
123
211
  return parsed;
124
212
  }
213
+ /**
214
+ * Guard for datetime options forwarded to the server AS A STRING (never converted
215
+ * to a timestamp): the pass-through Insight/Vault list endpoints echo the string
216
+ * verbatim, and probing 2026-07-21 showed they misread year-last separators exactly
217
+ * like the date endpoints — `insight research list` read `07/01/2026` as 2026-01-07
218
+ * but `07-01-2026` as 2026-07-01, a half-year apart, both HTTP 200 with nothing in
219
+ * the response flagging it. Accept a finite epoch or a well-formed `YYYY-MM-DD
220
+ * [ HH:mm[:ss]]` and return the ORIGINAL string unchanged.
221
+ *
222
+ * Validated with `datetimeFieldsValid`, NOT `toTimestamp13`: the latter's local Date
223
+ * round-trip would reject a DST-gap string (e.g. `2026-03-08 02:30:00` under
224
+ * America/New_York) that the server accepts — the CLI forwards this string as-is and
225
+ * the server resolves it in its own zone, so the client's timezone must not decide
226
+ * validity. Distinct from `parseTimestamp13`, which DOES convert (A-share
227
+ * announcement / knowledge-batch want epoch millis, where an unrepresentable local
228
+ * instant genuinely cannot convert).
229
+ */
230
+ export function parseDatetimeOption(value, optionName) {
231
+ if (epochMillis(value) === undefined && !datetimeFieldsValid(value)) {
232
+ throw new ValidationError(`Invalid ${optionName}: expected a Unix timestamp or "YYYY-MM-DD" optionally with " HH:mm[:ss]" (space or T separator), got "${value}" — year-last forms are refused because the API reads their day/month order differently per separator`);
233
+ }
234
+ return value;
235
+ }
236
+ /** Commander argParser factory for pass-through datetime options — same role as
237
+ * `dateArg`, but allows an optional time part and keeps the string as-is. */
238
+ export function datetimeArg(optionName) {
239
+ return (value) => parseDatetimeOption(value, optionName);
240
+ }
125
241
  /** Machine-local calendar date as `yyyy-MM-dd`, for CLI "default: today" options.
126
242
  * `new Date().toISOString().slice(0,10)` renders the UTC day — for CST users a
127
243
  * pre-08:00 "today" resolves to yesterday. Anchoring to local components matches
@@ -10,8 +10,35 @@ function nextDelayMs(attempt) {
10
10
  const grown = POLL_INITIAL_DELAY_MS * 1.6 ** (attempt - 1);
11
11
  return Math.min(POLL_MAX_DELAY_MS, Math.round(grown));
12
12
  }
13
+ // Probed 2026-07-20 against a real viewpoint-debate job: the async endpoints are
14
+ // still entirely on the legacy codes — 410110 "正在生成中" and 410111 "生成失败",
15
+ // both string-typed, both HTTP 400, neither carrying the new `errorType` field.
16
+ // The 2026-07-17 spec renumbers them to 140001 RESULT_GENERATING (409) and
17
+ // 140002 PROCESSING_FAILED (500); those are listed ahead of the switchover
18
+ // because the failure mode is expensive and silent — a `--wait` that does not
19
+ // recognize the pending code aborts the poll on a job already billed 50 credits.
20
+ const PENDING_CODES = new Set(["410110", "140001"]);
21
+ const FAILED_CODES = new Set(["410111", "140002"]);
13
22
  function isAsyncPending(error) {
14
- return error instanceof ApiError && error.code === "410110";
23
+ return error instanceof ApiError && error.code !== undefined && PENDING_CODES.has(error.code);
24
+ }
25
+ function isAsyncFailed(error) {
26
+ return error instanceof ApiError && error.code !== undefined && FAILED_CODES.has(error.code);
27
+ }
28
+ /** Terminal failures are swallowed here rather than rethrown, so this line is the
29
+ * user's only record of them — it has to carry what the global handler in cli.ts
30
+ * would have printed (code, msg, traceId). Without the traceId the failure is
31
+ * unreportable to Gangtise support, and README promises every error line has one. */
32
+ function terminalFailureLine(error) {
33
+ const api = error instanceof ApiError ? error : undefined;
34
+ const code = api?.code ? ` ${api.code}` : "";
35
+ const trace = api?.traceId ? ` [trace ${api.traceId}]` : "";
36
+ const detail = api?.message ? `: ${api.message}` : "";
37
+ // Name the *submit* endpoint explicitly: re-running a `*-check` is a free
38
+ // lookup, it is resubmitting the generation job that re-bills 50 credits for a
39
+ // verdict that will not change (probed 2026-07-20 on sensitive content).
40
+ return `Content generation failed (terminal${code})${trace}${detail}\n`
41
+ + "This dataId is final — re-checking it will not change. Resubmitting the generation task bills again for the same result; change the parameters first.\n";
15
42
  }
16
43
  export async function pollAsyncContent(client, getContentEndpoint, dataId, format, output) {
17
44
  for (let attempt = 1; attempt <= POLL_MAX_ATTEMPTS; attempt++) {
@@ -23,8 +50,8 @@ export async function pollAsyncContent(client, getContentEndpoint, dataId, forma
23
50
  }
24
51
  }
25
52
  catch (error) {
26
- if (error instanceof ApiError && error.code === "410111") {
27
- process.stderr.write("Content generation failed (terminal). Do not retry.\n");
53
+ if (isAsyncFailed(error)) {
54
+ process.stderr.write(terminalFailureLine(error));
28
55
  return "failed";
29
56
  }
30
57
  if (!isAsyncPending(error)) {
@@ -55,8 +82,8 @@ export async function checkAsyncContent(client, getContentEndpoint, dataId, form
55
82
  }
56
83
  }
57
84
  catch (error) {
58
- if (error instanceof ApiError && error.code === "410111") {
59
- process.stderr.write("Content generation failed (terminal). Do not retry.\n");
85
+ if (isAsyncFailed(error)) {
86
+ process.stderr.write(terminalFailureLine(error));
60
87
  process.exitCode = 1;
61
88
  return;
62
89
  }
@@ -4,14 +4,20 @@ import path from "node:path";
4
4
  import { pipeline } from "node:stream/promises";
5
5
  import { request } from "undici";
6
6
  import { isTokenCacheValid, normalizeToken, readTokenCache, requireAccessCredentials, writeTokenCache } from "./auth.js";
7
- import { ApiError, ValidationError } from "./errors.js";
7
+ import { ApiError, attachEnvelopeTraceId, ValidationError } from "./errors.js";
8
8
  import { ENDPOINTS, resolveTimeoutMs } from "./endpoints.js";
9
9
  import { getLookupData } from "./lookupData/index.js";
10
10
  import { decodeResponseBody, getDispatcher, isVerbose, logTiming, markRetryable, PAGE_CONCURRENCY, parseRetryAfterMs, runWithConcurrency, withRetry } from "./transport.js";
11
- // Auth errors that warrant a forced re-login + one replay. 8000014/8000015 are
12
- // AK/SK errors; 0000001008 is a server-side token invalidation (the token still
13
- // looks valid by local expiry, so only a forced refresh recovers it).
14
- const AUTH_RETRY_CODES = new Set(["8000014", "8000015", "0000001008"]);
11
+ // Auth errors that warrant a forced re-login + one replay: the token was rejected
12
+ // server-side while still looking valid by local expiry, so only a forced refresh
13
+ // recovers it. 0000001008 is the legacy code (probed 2026-07-20: still what the
14
+ // token filter emits); 999002 TOKEN_INVALID is its 2026-07-17 replacement, listed
15
+ // ahead of the rollout so self-heal does not silently die when the filter switches.
16
+ // 8000014/8000015 are the retired AK/SK codes, kept for older server builds.
17
+ // 999011 CREDENTIAL_INVALID is not here and could not act if it were — it comes from
18
+ // auth.login, which runs useAuth=false and so never reaches this check. Its "never
19
+ // replay" guarantee lives in transport's TERMINAL_API_CODES instead.
20
+ const AUTH_RETRY_CODES = new Set(["8000014", "8000015", "0000001008", "999002"]);
15
21
  export class GangtiseClient {
16
22
  config;
17
23
  refreshPromise = null;
@@ -126,17 +132,20 @@ export class GangtiseClient {
126
132
  }
127
133
  throw new ApiError(`API request failed (HTTP ${statusCode})`, undefined, statusCode, parsed, retryAfterMs);
128
134
  }
129
- unwrapEnvelope(parsed, statusCode) {
135
+ unwrapEnvelope(parsed, statusCode, retryAfterMs) {
130
136
  if (!this.isEnvelope(parsed)) {
131
137
  return parsed;
132
138
  }
133
139
  const code = parsed.code === undefined ? undefined : String(parsed.code);
134
140
  const ok = parsed.status === true || parsed.success === true || code === "000000" || code === "0";
135
141
  if (!ok) {
136
- throw new ApiError(parsed.msg || "API request failed", code, statusCode, parsed);
142
+ throw new ApiError(parsed.msg || "API request failed", code, statusCode, parsed, retryAfterMs);
137
143
  }
138
144
  if ('data' in parsed) {
139
- return parsed.data;
145
+ // Carry the envelope's traceId onto the payload: the EDE endpoints wrap a
146
+ // second envelope inside `data` and raise their own failures from it, by
147
+ // which point this is the only traceId in reach.
148
+ return attachEnvelopeTraceId(parsed.data, parsed.traceId);
140
149
  }
141
150
  return parsed;
142
151
  }
@@ -397,7 +406,7 @@ export class GangtiseClient {
397
406
  if (response.statusCode >= 400) {
398
407
  this.throwHttpError(parsed, response.statusCode, retryAfterMs);
399
408
  }
400
- return this.unwrapEnvelope(parsed, response.statusCode);
409
+ return this.unwrapEnvelope(parsed, response.statusCode, retryAfterMs);
401
410
  }
402
411
  catch (error) {
403
412
  await this.refreshAuthIfRecoverable(error, useAuth, authState, usedAuthorization);
@@ -491,7 +500,7 @@ export class GangtiseClient {
491
500
  if (response.statusCode >= 400) {
492
501
  this.throwHttpError(parsed, response.statusCode, retryAfterMs);
493
502
  }
494
- data = this.unwrapEnvelope(parsed, response.statusCode);
503
+ data = this.unwrapEnvelope(parsed, response.statusCode, retryAfterMs);
495
504
  }
496
505
  catch (error) {
497
506
  await this.refreshAuthIfRecoverable(error, true, authState, authorization);
@@ -10,26 +10,111 @@ export class ValidationError extends CliError {
10
10
  }
11
11
  export class DownloadError extends CliError {
12
12
  }
13
+ /** Outer-envelope traceId, stashed on the unwrapped payload so it survives
14
+ * `unwrapEnvelope` discarding the envelope. Needed by the EDE endpoints alone:
15
+ * they double-wrap, and probing 2026-07-20 showed the traceId lives only on the
16
+ * OUTER envelope — the inner failure envelope (`{code, status:false, msg}`) has
17
+ * none. Without this the one correlation id Gangtise support can trace is gone
18
+ * exactly where the inner failure is raised. Non-enumerable so it never reaches
19
+ * JSON/CSV output. */
20
+ export const ENVELOPE_TRACE_ID = Symbol("gangtise.envelopeTraceId");
21
+ export function attachEnvelopeTraceId(payload, traceId) {
22
+ if (payload && typeof payload === "object" && (typeof traceId === "string" || typeof traceId === "number")) {
23
+ Object.defineProperty(payload, ENVELOPE_TRACE_ID, { value: String(traceId), enumerable: false, configurable: true });
24
+ }
25
+ return payload;
26
+ }
27
+ /** Keyed by the code as a string — `unwrapEnvelope` runs every envelope code
28
+ * through `String()` first, which matters because the 2026-07-17 error-code
29
+ * overhaul emits the new codes as JSON *numbers* while legacy codes stay strings.
30
+ *
31
+ * Both generations are listed on purpose. Probed 2026-07-20: the rollout is
32
+ * partial — the business layer already answers with the new codes (`999011`,
33
+ * `130001`, `130002`, `100003`, `999010`), but the outer token filter still
34
+ * emits `0000001007` / `0000001008` / `900002`. Dropping either set would leave
35
+ * a live code hintless. */
13
36
  const ERROR_HINTS = {
14
- "999999": "Gangtise 系统错误,请稍后重试。",
15
- "999997": "当前账号未开通该接口权限。",
16
- "999995": "当前账号积分不足。",
17
- "900002": "请求缺少 uid。",
18
- "900001": "请求参数为空或缺少必填项。",
19
- "100003": "参数值非法——服务端不会指明是哪个参数,多为枚举参数拼写或取值超范围(如 --source / --question-category / --answer-important),对照命令 --help 列出的合法值检查。",
37
+ // The hint is appended after the server's own msg, so it must carry the *action*,
38
+ // never restate the diagnosis — "资源不存在 资源不存在,确认 ID 有效" reads as a stutter.
39
+ // ── 服务统一层 999xxx ──
40
+ "999001": "检查 GANGTISE_TOKEN 或 AK/SK 是否已 export。",
41
+ "999002": "有 AK/SK 时 CLI 会自动重新登录重试一次,否则请重新登录。",
42
+ "999003": "定制接口需联系客户经理开通。",
43
+ "999004": "换一条本账号可见的记录重试。",
44
+ "999005": "联系客户经理充值,或缩小查询范围降低消耗。",
45
+ "999006": "触发限流,稍后再试或联系客户经理提额;429 所有端点都退避重试,5xx 仅普通端点重试(贵档 no-replay 端点的 5xx 不重放,但其 429 仍重试)。",
46
+ // CLI 自身发不出这三类请求(method / Content-Type 都由 endpoint 定义固定),
47
+ // 只有 raw call 打错端点或服务端行为变化才可能撞上。实测 2026-07-20 分别落到
48
+ // 900002 / 999999 / 100003,此处为服务端接上新码后的预置。
49
+ "999007": "请求方法不支持——`raw call` 时确认该 endpoint 是 GET 还是 POST。",
50
+ "999008": "Content-Type 不支持,该接口只收 application/json。",
51
+ "999009": "请求体无法解析,检查 JSON 是否合法。",
52
+ "999010": "`raw call` 传的 endpoint key 可能已下线,用 `gangtise raw list` 核对。",
53
+ "999011": "检查 GANGTISE_ACCESS_KEY / GANGTISE_SECRET_KEY 是否写反或未 export。",
54
+ "999012": "联系客户经理。",
55
+ "999013": "联系客户经理续期。",
56
+ "999014": "联系客户经理。",
57
+ "999015": "联系客户经理开通长期 token。",
58
+ "999016": "联系客户经理登记当前出口 IP。",
59
+ "999999": "请稍后重试;持续失败请带上面的 trace 报障。",
60
+ // ── 业务通用 1xxxxx ──
61
+ "100001": "对照命令 --help 检查必填项。",
62
+ "100002": "检查数值/字符串参数是否传反。",
63
+ // 实测两种形态都有:类型/范围错的 msg 带字段(「请求体字段类型不匹配: size 期望类型
64
+ // Integer」「limit 最小为 1,最大为 10000」),枚举错的 msg 只有笼统的「参数值非法」。
65
+ // 条件句让两种形态都读得通——v0.25.0 的旧文案断言"服务端不会指明",与前一种直接打架。
66
+ "100003": "msg 已指明字段名或取值范围时直接按 msg 改;msg 只说「参数值非法」时多为枚举参数拼写错误(如 --source / --question-category / --answer-important),对照命令 --help 列出的合法值检查。",
67
+ "100004": "检查 --size / --from 是否为非负数且未超单页上限。",
68
+ "100005": "对照命令 --help 列出的合法取值检查。",
69
+ "100006": "缩短日期范围或调小 --size / --limit。",
70
+ // 按参数名判断,不要按命令组:AI 下 management-discuss 的 --report-date 是 date,
71
+ // 而同属 AI 的 knowledge-batch 收时间戳或 datetime——旧文案笼统写"AI 用 datetime"会把
72
+ // --report-date 的用户越导越错。
73
+ "110001": "看参数名:`--*-date` 用 YYYY-MM-DD,`--*-time` 用 \"YYYY-MM-DD HH:mm:ss\"(`ai knowledge-batch` 的 --start-time/--end-time 收时间戳或 datetime,CLI 统一转 13 位毫秒)。",
74
+ "110002": "起始晚于结束——检查 --start-date/--end-date 或 --start-time/--end-time 的先后。",
75
+ "110003": "缩短查询窗口后重试。",
76
+ "120001": "用 `gangtise reference securities-search` 确认代码与后缀(如 600519.SH / 00700.HK)。",
77
+ "130001": "未找到数据——先核对查询条件;EDE 指标端点此码也可能是未开通该指标权限,仍失败联系客户经理。",
78
+ "130002": "确认下载 ID 有效且本账号可见;下载类还需检查 --file-type 取值是否合法(非法 file-type 也归此码)。",
79
+ "130003": "该条记录可能未附带文件。",
80
+ // 下载类命令各有各的 ID 参数(--report-id / --announcement-id / --chunk-id /
81
+ // --summary-id / --conference-id / --record-id / --file-id / --article-id /
82
+ // --independent-opinion-id);--data-id 是异步 *-check 用的,不产生此码。
83
+ "130004": "下载 ID 需为数字,检查该命令的 --*-id 参数是否传对。",
84
+ "130005": "对照命令 --help 检查 --file-type / --content-type 取值。",
85
+ "140001": "稍后用对应 *-check 命令查询。",
86
+ "140002": "异步生成失败(终态)——换参数重新提交,重试同一 dataId 不会变。",
87
+ // ── 接口专有 2xxxxx ──
88
+ "210001": "换一篇,或改用 list 取正文摘要。",
89
+ "220001": "改用 list 取正文摘要。",
90
+ "230001": "只有自己上传的文件可下载。",
91
+ "240001": "换更早的 --period(如 2025q3 → 2025interim)。",
92
+ "240002": "改述后重新提交。",
93
+ "240003": "对照命令 --help 检查取值。",
94
+ "250001": "检查 resourceType 与 sourceId 组合(两者都来自 knowledge-batch 返回)。",
95
+ // ── 旧码(2026-07-20 实测仍在线,或历史遗留) ──
96
+ "0000001007": "请求未携带 Bearer token,检查 GANGTISE_TOKEN 或 AK/SK 是否已 export。",
20
97
  "0000001008": "Token 已失效(多为他处登录挤掉本会话);有 AK/SK 时 CLI 会自动重新登录重试一次,否则请重新登录。",
21
- "8000014": "GANGTISE_ACCESS_KEY 错误。",
22
- "8000015": "GANGTISE_SECRET_KEY 错误。",
23
- "8000016": "开发账号状态异常。",
24
- "8000018": "开发账号已到期。",
25
- "903301": "今日调用次数已达上限。",
26
- "410110": "异步内容生成中,稍后用对应 *-check 命令查询。",
27
- "410111": "异步内容生成失败(终态),请更换参数后重新提交。",
28
- "410004": "数据未找到或无指标权限,请检查查询条件与指标权限。",
29
- "430004": "下载失败(官方未文档化错误码),请确认 reportId 有效或更换 --file-type 重试。",
30
- "430007": "行情查询超出限制,请缩短日期范围。",
31
- "433007": "数据源不匹配,请检查 resourceType sourceId 组合。",
32
- "10011401": "白名单未开通,请联系管理员。",
98
+ "900001": "对照命令 --help 检查必填项。",
99
+ "900002": "请求方法不正确(服务端 msg 为「请求类型有误」)——`raw call` 时检查该 endpoint 是 GET 还是 POST。",
100
+ "903301": "次日再试,或联系客户经理提额。",
101
+ // EDE 专有旧码,未被 2026-07-17 重排收编但仍是 indicator 取数的主要报错
102
+ // (references/commands/indicator.md 把这两个列为首要排查项)。
103
+ "410001": "补齐 --indicator / --security;`time-series` 不支持「多指标 × 多证券」,改用 `indicator cross-section`。",
104
+ "410106": "读 `indicator search --format json` 的 parameterList,用 --indicator-param 补上 required:true 的参数(如 periodNum / startDate / fiscalYear)。",
105
+ "410004": "换证券或日期确认该条件下本应有数据;仍失败多为未开通该指标,联系客户经理。",
106
+ "410110": "稍后用对应 *-check 命令查询。",
107
+ "410111": "终态,换参数后重新提交,重试同一请求不会变。",
108
+ "430004": "确认 reportId 有效,或更换 --file-type 重试(官方未文档化错误码)。",
109
+ "430007": "缩短日期范围或调小 --limit。",
110
+ "433007": "检查 resourceType 与 sourceId 组合(两者都来自 knowledge-batch 返回)。",
111
+ "8000014": "检查 GANGTISE_ACCESS_KEY 是否正确、是否与 SECRET_KEY 写反。",
112
+ "8000015": "检查 GANGTISE_SECRET_KEY 是否正确、是否与 ACCESS_KEY 写反。",
113
+ "8000016": "联系客户经理核查账号状态。",
114
+ "8000018": "联系客户经理续期。",
115
+ "999995": "联系客户经理充值,或缩小查询范围降低消耗。",
116
+ "999997": "联系客户经理开通。",
117
+ "10011401": "联系客户经理开通白名单。",
33
118
  };
34
119
  export class ApiError extends CliError {
35
120
  code;
@@ -51,4 +136,17 @@ export class ApiError extends CliError {
51
136
  this.retryAfterMs = retryAfterMs;
52
137
  this.hint = hintOverride ?? (code ? ERROR_HINTS[code] : undefined);
53
138
  }
139
+ /** Server-side correlation id from the 2026-07-17 envelope
140
+ * (`{code, errorType, msg, status, data, traceId}`). Read off `details` rather
141
+ * than threading a 7th positional constructor arg through every call site.
142
+ * Worth surfacing: it is the only handle Gangtise support can trace a 999999 by. */
143
+ get traceId() {
144
+ if (!this.details || typeof this.details !== "object")
145
+ return undefined;
146
+ // Fall back to the outer envelope's id for double-wrapped (EDE) responses,
147
+ // whose inner failure envelope carries no traceId of its own.
148
+ const details = this.details;
149
+ const value = details.traceId ?? details[ENVELOPE_TRACE_ID];
150
+ return typeof value === "string" || typeof value === "number" ? String(value) : undefined;
151
+ }
54
152
  }
@@ -16,7 +16,11 @@ export function unwrapIndicatorData(raw) {
16
16
  // (status/msg/data) so a non-envelope object that merely carries a `code`
17
17
  // field can't be misread as a failure.
18
18
  if (!ok && ("status" in record || "msg" in record || "data" in record)) {
19
- throw new ApiError(typeof record.msg === "string" && record.msg ? record.msg : "Indicator API request failed", code);
19
+ // Pass `record` as details: the inner envelope carries no traceId of its
20
+ // own, but unwrapEnvelope attached the outer one to this object and
21
+ // ApiError.traceId falls back to it. Without it these failures — the EDE
22
+ // 999999 / 130001 that most need reporting — reach the user trace-less.
23
+ throw new ApiError(typeof record.msg === "string" && record.msg ? record.msg : "Indicator API request failed", code, undefined, record);
20
24
  }
21
25
  if (ok && "data" in record)
22
26
  return record.data;
@@ -77,6 +77,19 @@ export const PAGE_CONCURRENCY = resolvePageConcurrency(process.env.GANGTISE_PAGE
77
77
  const RETRYABLE_HTTP_STATUS = new Set([429, 500, 502, 503, 504]);
78
78
  const RETRYABLE_NETWORK_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
79
79
  const RETRYABLE_API_CODES = new Set(["999999"]);
80
+ // Never replayed on any HTTP status:
81
+ // - 999011 CREDENTIAL_INVALID: bad AK/SK, only from auth.login (useAuth=false, so it
82
+ // never consults AUTH_RETRY_CODES, and it declares no retry policy) — a 5xx would
83
+ // otherwise be retried twice by the status rule.
84
+ // - 140002 PROCESSING_FAILED: the async *-check endpoints (get-content) declare no
85
+ // retry policy, so a 140002@500 would be retried 2× by the default policy BEFORE
86
+ // asyncContent's FAILED_CODES gets to call it terminal — that guard sits above
87
+ // client.call's withRetry and cannot see the retries. 140002 means "generation
88
+ // failed" (terminal by definition) and only those async endpoints can return it,
89
+ // so a blanket rule is safe and skips the wasted retries. Server still emits the
90
+ // legacy 410111 today (410111@400 isn't retryable anyway); this is a forward guard
91
+ // for the documented 140002@500.
92
+ const TERMINAL_API_CODES = new Set(["999011", "140002"]);
80
93
  // Connect-phase / DNS failures: the request provably never reached the server, so a
81
94
  // replay cannot double-execute (or double-bill) anything even under "no-replay".
82
95
  const NO_REPLAY_NETWORK_CODES = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT"]);
@@ -85,6 +98,8 @@ function isRetryableError(error, policy) {
85
98
  return true;
86
99
  }
87
100
  if (error instanceof ApiError) {
101
+ if (error.code && TERMINAL_API_CODES.has(error.code))
102
+ return false;
88
103
  if (error.statusCode === 429)
89
104
  return true;
90
105
  if (policy === "no-replay")
@@ -1,2 +1,2 @@
1
1
  // Auto-generated — DO NOT EDIT
2
- export const CLI_VERSION = "0.27.0";
2
+ export const CLI_VERSION = "0.28.1";