tripo-cli 0.7.2 → 0.8.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +16 -14
  3. package/dist/ai/experts.js +26 -20
  4. package/dist/ai/plan-card.js +73 -34
  5. package/dist/ai/wizard.js +19 -19
  6. package/dist/commands/account.js +2 -6
  7. package/dist/commands/ai.js +18 -16
  8. package/dist/commands/batch.js +58 -18
  9. package/dist/commands/completion.js +1 -1
  10. package/dist/commands/config-cmd.js +1 -1
  11. package/dist/commands/doctor.js +1 -3
  12. package/dist/commands/generate.js +50 -8
  13. package/dist/commands/home.js +12 -13
  14. package/dist/commands/login.js +125 -182
  15. package/dist/commands/make.js +50 -25
  16. package/dist/commands/mcp.js +8 -3
  17. package/dist/commands/process-groups.js +29 -18
  18. package/dist/commands/shared.js +22 -13
  19. package/dist/commands/task.js +12 -11
  20. package/dist/commands/topup.js +6 -10
  21. package/dist/commands/update.js +23 -11
  22. package/dist/commands/view.js +3 -4
  23. package/dist/config/config.js +1 -1
  24. package/dist/core/analytics.js +39 -16
  25. package/dist/core/client.js +53 -21
  26. package/dist/core/device-auth.js +49 -1
  27. package/dist/core/download.js +2 -1
  28. package/dist/core/errors.js +24 -14
  29. package/dist/core/output.js +0 -4
  30. package/dist/core/requests.js +94 -31
  31. package/dist/core/task-service.js +21 -20
  32. package/dist/core/upload.js +23 -5
  33. package/dist/core/watch.js +3 -0
  34. package/dist/knowledge/chains.js +16 -17
  35. package/dist/knowledge/error-catalog.js +39 -37
  36. package/dist/knowledge/models.js +147 -27
  37. package/dist/knowledge/params.js +143 -32
  38. package/dist/knowledge/scenarios.js +32 -55
  39. package/dist/pipeline/make.js +38 -30
  40. package/dist/pipeline/runner.js +27 -8
  41. package/dist/pipeline/stdin.js +40 -3
  42. package/dist/program.js +3 -10
  43. package/dist/util/input-detect.js +10 -1
  44. package/package.json +3 -2
  45. package/skill/SKILL.md +52 -15
  46. package/skill/commands/account.md +8 -3
  47. package/skill/commands/batch.md +13 -3
  48. package/skill/commands/generate.md +57 -23
  49. package/skill/commands/make.md +43 -16
  50. package/skill/commands/process.md +29 -24
  51. package/skill/commands/task.md +4 -4
  52. package/skill/commands/view.md +1 -1
  53. package/skill/common-errors.md +14 -10
  54. package/skill/examples/animation.md +4 -2
  55. package/skill/examples/ar-web.md +6 -4
  56. package/skill/examples/game-asset.md +18 -7
  57. package/skill/examples/pipes.md +2 -2
  58. package/skill/examples/print.md +7 -3
  59. package/dist/i18n.js +0 -19
@@ -10,7 +10,6 @@ import { downloadArtifacts, defaultOutputDir } from '../core/download.js';
10
10
  import { UsageError } from '../core/errors.js';
11
11
  import { emitResult, log, shouldOpen } from '../core/output.js';
12
12
  import { startViewer } from '../core/viewer.js';
13
- import { t } from '../i18n.js';
14
13
  import { runTask } from '../core/task-service.js';
15
14
  import { openExternal } from '../util/open.js';
16
15
  import { printTaskResult, newClient } from './shared.js';
@@ -26,11 +25,11 @@ export function registerViewCommands(program) {
26
25
  const handle = await startViewer(modelFile, target);
27
26
  if (shouldOpen()) {
28
27
  openExternal(handle.url);
29
- log(t({ zh: `预览已打开: ${handle.url}`, en: `preview opened: ${handle.url}` }));
28
+ log(`preview opened: ${handle.url}`);
30
29
  }
31
30
  emitResult({ url: handle.url, model_file: modelFile }, `${handle.url}\n${modelFile}`);
32
31
  if (opts.keep || shouldOpen()) {
33
- log(t({ zh: 'Ctrl+C 关闭预览服务', en: 'Ctrl+C to stop the preview server' }));
32
+ log('Ctrl+C to stop the preview server');
34
33
  await new Promise(() => { }); // hold until interrupted
35
34
  }
36
35
  else {
@@ -84,7 +83,7 @@ async function resolveModelFile(target) {
84
83
  const task = await client.getTask(taskId);
85
84
  if (task.status !== 'success')
86
85
  throw new UsageError(`task ${taskId} is ${task.status}, nothing to view yet`);
87
- log(t({ zh: '正在下载模型用于预览…', en: 'downloading the model for preview…' }));
86
+ log('downloading the model for preview…');
88
87
  const dir = entry?.output_dir ?? defaultOutputDir(process.cwd(), entry?.name ?? 'view', taskId);
89
88
  const artifacts = await downloadArtifacts(task, dir);
90
89
  if (!artifacts.modelFile || !VIEWABLE.has(extname(artifacts.modelFile).toLowerCase())) {
@@ -4,7 +4,7 @@
4
4
  * Multi-account model: credentials live in named *profiles* (a dict plus an
5
5
  * `active_profile` pointer — same shape as AWS CLI profiles / kubectl
6
6
  * contexts). Everything account-bound (key, region, URL overrides) is
7
- * per-profile; user preferences (language, llm, output dir) stay top-level.
7
+ * per-profile; user preferences (llm, output dir) stay top-level.
8
8
  *
9
9
  * v0.1.0 stored a single key at the top level. That format is normalized
10
10
  * into `profiles.default` on *every* load, so old files keep working
@@ -10,10 +10,12 @@ export const ANALYTICS_APP_ID = "2908364badd34996a1cd44a5f7a71223";
10
10
  const operations = new AsyncLocalStorage();
11
11
  let activeOperation;
12
12
  let installationId;
13
+ /** Account lookups held only in this process's memory: at most one per key per CLI command or MCP session. */
14
+ const identities = new Map();
13
15
  const currentOperation = () => operations.getStore() ?? activeOperation;
14
16
  export function beginOperation(command, disabled = false, info, regionHint) {
15
17
  activeOperation = { command, started: Date.now(), disabled, finished: false, collectors: new Map(), regionHint, context: {
16
- ...attribution(process.env.TRIPO_CLIENT_APP, process.env.TRIPO_DISTRIBUTION_CHANNEL, info),
18
+ ...attribution(process.env.TRIPO_CLIENT_APP, process.env.TRIPO_DISTRIBUTION_CHANNEL, info, undefined, hostClientApp()),
17
19
  distinctId: randomUUID(), operation_id: randomUUID(), integration_mode: info ? "local_mcp" : "cli",
18
20
  } };
19
21
  operations.enterWith(activeOperation);
@@ -27,8 +29,15 @@ export function identifyUser(apiKey, region, apiUrl) {
27
29
  return;
28
30
  const consoleApi = process.env.TRIPO_CONSOLE_API_BASE_URL ?? REGION_DEFAULTS[region].consoleApi;
29
31
  const key = createHash('sha256').update(JSON.stringify([region, apiUrl, consoleApi, apiKey])).digest('hex');
30
- if (operation.identity?.key !== key)
31
- operation.identity = { key, value: analytics.identify(apiKey, consoleApi) };
32
+ if (operation.identity?.key === key)
33
+ return;
34
+ let value = identities.get(key);
35
+ if (!value) {
36
+ // A CLI command ends its lookup when it finishes; an MCP session lets it outlive the tool call, for at most 10 s.
37
+ const signal = operation.context.integration_mode === 'local_mcp' ? AbortSignal.timeout(10_000) : (operation.lookups ??= new AbortController()).signal;
38
+ identities.set(key, value = analytics.identify(apiKey, consoleApi, signal));
39
+ }
40
+ operation.identity = { key, value };
32
41
  }
33
42
  function collector(region = currentOperation()?.regionHint ?? resolveRegion(), apiUrl = resolveApiBaseUrl()) {
34
43
  const operation = currentOperation();
@@ -95,6 +104,7 @@ export async function finishOperation(result, error) {
95
104
  ...(operation.context.integration_mode === 'local_mcp' ? { tool_name: operation.command } : {}),
96
105
  result, duration_ms: Date.now() - operation.started, ...(error ? errorProperties(error) : {}) });
97
106
  await flushAnalytics();
107
+ operation.lookups?.abort();
98
108
  }
99
109
  export function taskOrigin(model) {
100
110
  const context = analyticsContext();
@@ -136,23 +146,35 @@ export function clientApp(value) {
136
146
  if (typeof value !== "string")
137
147
  return "unknown";
138
148
  const name = value.trim().toLowerCase();
139
- for (const app of ["codex", "workbuddy", "zcode", "kimi", "deepseek"]) {
140
- if (new RegExp(`(^|[\\s_./-])${app}($|[\\s_./-])`).test(name))
141
- return app;
142
- }
143
- return "unknown";
149
+ if (name === "dsh")
150
+ return "deepseek";
151
+ const matches = ["codex", "workbuddy", "zcode", "kimi", "deepseek", "claude", "cursor", "chatgpt"]
152
+ .filter((app) => new RegExp(`(^|[\\s_./():-])${app}($|[\\s_./():-])`).test(name));
153
+ return matches.length === 1 ? matches[0] : "unknown";
154
+ }
155
+ function hostClientApp() {
156
+ // Session markers identify the caller; install paths and terminal names do not.
157
+ const hosts = [];
158
+ if (process.env.CODEX_THREAD_ID?.trim())
159
+ hosts.push("codex");
160
+ if (process.env.ZCODE_SESSION_ID?.trim())
161
+ hosts.push("zcode");
162
+ if (process.env.CODEBUDDY_HOST === "workbuddy-desktop")
163
+ hosts.push("workbuddy");
164
+ return hosts.length === 1 ? hosts[0] : "unknown";
144
165
  }
145
- export function attribution(explicit, distribution, info, oauthName) {
166
+ export function attribution(explicit, distribution, info, oauthName, hostName) {
146
167
  const tagged = clientApp(explicit);
147
168
  const initialized = clientApp(info?.name);
148
169
  const oauth = clientApp(oauthName);
149
- const chosen = tagged !== "unknown" ? tagged : initialized !== "unknown" ? initialized : oauth;
170
+ const host = clientApp(hostName);
171
+ const chosen = tagged !== "unknown" ? tagged : initialized !== "unknown" ? initialized : oauth !== "unknown" ? oauth : host;
150
172
  const channels = ["codex_plugin", "workbuddy_plugin", "zcode_plugin", "kimi_plugin", "deepseek_plugin"];
151
173
  return {
152
174
  client_app: chosen,
153
175
  distribution_channel: typeof distribution === "string" && channels.includes(distribution) ? distribution : "unknown",
154
- attribution_method: tagged !== "unknown" ? "explicit_tag" : initialized !== "unknown" ? "mcp_client_info" : oauth !== "unknown" ? "oauth_client_name" : "unknown",
155
- attribution_conflict: [tagged, initialized, oauth].some((app) => app !== "unknown" && chosen !== app),
176
+ attribution_method: tagged !== "unknown" ? "explicit_tag" : initialized !== "unknown" ? "mcp_client_info" : oauth !== "unknown" ? "oauth_client_name" : host !== "unknown" ? "host_environment" : "unknown",
177
+ attribution_conflict: [tagged, initialized, oauth, host].some((app) => app !== "unknown" && chosen !== app),
156
178
  ...(typeof info?.version === "string" && /^[a-zA-Z0-9._+-]{1,64}$/.test(info.version) && !/tsk_|bearer|token/i.test(info.version) ? { client_version: info.version } : {}),
157
179
  };
158
180
  }
@@ -202,7 +224,7 @@ export class Analytics {
202
224
  && (testing ? env.TRIPO_ANALYTICS_ENABLED === "1" && testReceiver : officialApi || testReceiver);
203
225
  this.identityEnabled = this.enabled && (testing ? localApi : officialApi);
204
226
  }
205
- async identify(apiKey, consoleApi) {
227
+ async identify(apiKey, consoleApi, signal = AbortSignal.timeout(1000)) {
206
228
  if (!this.identityEnabled)
207
229
  return undefined;
208
230
  try {
@@ -214,7 +236,7 @@ export class Analytics {
214
236
  const response = await fetch(url, {
215
237
  method: "POST", headers: { "Content-Type": "application/json" },
216
238
  body: JSON.stringify({ apikey: apiKey, with_detail: true }),
217
- redirect: "error", signal: AbortSignal.timeout(1000),
239
+ redirect: "error", signal,
218
240
  });
219
241
  if (!response.ok)
220
242
  return undefined;
@@ -268,9 +290,10 @@ export class Analytics {
268
290
  const deadline = Date.now() + 2000;
269
291
  while (this.queue.length && Date.now() < deadline) {
270
292
  const batch = this.queue.splice(0, 100);
271
- // Each lookup began before its event and gives up after 1 s, so this wait leaves the send at least 1 s of the budget.
293
+ // A lookup may outlive its events, so wait at most 1 s for it; that leaves the send at least 1 s of the budget.
294
+ const wait = new Promise((resolve) => setTimeout(() => resolve(undefined), 1000).unref());
272
295
  await Promise.all(batch.map(async ({ event, identity }) => {
273
- const userId = await identity;
296
+ const userId = await Promise.race([identity, wait]);
274
297
  if (userId)
275
298
  event.data.properties.platform_user_id = userId;
276
299
  }));
@@ -2,10 +2,10 @@
2
2
  * Lightweight V3 API client on top of global fetch (Node 20+).
3
3
  * Handles the response envelope, error translation, timeouts, and
4
4
  * exponential-backoff retries for transient failures (429/5xx/network;
5
- * task creation is only resent on 429).
5
+ * task creation is only resent on 429). A 429 waits at least as long as the
6
+ * server's Retry-After / X-RateLimit-Reset hint, capped at 60s per wait.
6
7
  */
7
8
  import { activeProfileName, DEFAULT_PROFILE, listProfiles, loadConfig, resolveApiBaseUrl, resolveApiKey } from '../config/config.js';
8
- import { t } from '../i18n.js';
9
9
  import { EXIT } from '../knowledge/error-catalog.js';
10
10
  import { ApiError, AuthError, NetworkError } from './errors.js';
11
11
  import { identifyUser, observeTask, trackEvent, taskOrigin } from './analytics.js';
@@ -27,15 +27,9 @@ export class TripoClient {
27
27
  const wanted = activeProfileName(config);
28
28
  const known = listProfiles(config).map((p) => p.name);
29
29
  if (wanted !== DEFAULT_PROFILE && !known.includes(wanted)) {
30
- throw new AuthError(t({
31
- zh: `没有叫「${wanted}」的 profile${known.length ? `(现有: ${known.join(', ')})` : ''}`,
32
- en: `profile "${wanted}" not found${known.length ? ` (have: ${known.join(', ')})` : ''}`,
33
- }), t({
34
- zh: `用 tripo login --profile ${wanted} 登录,或 tripo use 切换`,
35
- en: `run "tripo login --profile ${wanted}" to create it, or "tripo use" to switch`,
36
- }));
30
+ throw new AuthError(`profile "${wanted}" not found${known.length ? ` (have: ${known.join(', ')})` : ''}`, `run "tripo login --profile ${wanted}" to create it, or "tripo use" to switch`);
37
31
  }
38
- throw new AuthError(t({ zh: '未配置 API Key', en: 'no API key configured' }));
32
+ throw new AuthError('no API key configured');
39
33
  }
40
34
  this.apiKey = key;
41
35
  this.timeoutMs = options.timeoutMs ?? 60_000;
@@ -54,12 +48,35 @@ export class TripoClient {
54
48
  form.append('file', data, fileName);
55
49
  return this.send('POST', '/v3/files', { body: form });
56
50
  }
51
+ /**
52
+ * Large-file upload: presign, then PUT straight to storage. The PUT carries
53
+ * no Authorization header and no request timeout (files up to 150MB).
54
+ */
55
+ async uploadFileViaPresign(format, data) {
56
+ const { presigned_url, file_token } = await this.request('POST', '/v3/files/presign', { format });
57
+ let response;
58
+ try {
59
+ response = await fetch(presigned_url, {
60
+ method: 'PUT',
61
+ headers: { 'Content-Type': 'application/octet-stream' },
62
+ body: data,
63
+ });
64
+ }
65
+ catch (err) {
66
+ throw new NetworkError(`presigned upload failed: ${err instanceof Error ? err.message : String(err)}`);
67
+ }
68
+ if (!response.ok)
69
+ throw new NetworkError(`presigned upload failed: HTTP ${response.status}`);
70
+ return { file_token };
71
+ }
57
72
  async send(method, path, init) {
58
73
  identifyUser(this.apiKey, this.region, this.baseUrl);
59
74
  let lastError;
60
75
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
61
76
  if (attempt > 0) {
62
- const delay = Math.min(1000 * 2 ** (attempt - 1), 8000);
77
+ const backoff = Math.min(1000 * 2 ** (attempt - 1), 8000);
78
+ const hint = lastError instanceof ApiError ? (lastError.retryAfterMs ?? 0) : 0;
79
+ const delay = Math.min(Math.max(backoff, hint), 60_000);
63
80
  this.onRetry?.(attempt, delay, lastError?.message ?? 'retry');
64
81
  await sleep(delay);
65
82
  }
@@ -99,13 +116,14 @@ export class TripoClient {
99
116
  finally {
100
117
  clearTimeout(timer);
101
118
  }
119
+ const retryAfterMs = response.status === 429 ? retryHintMs(response.headers) : undefined;
102
120
  let envelope;
103
121
  try {
104
122
  envelope = (await response.json());
105
123
  }
106
124
  catch {
107
125
  if (!response.ok) {
108
- throw new ApiError({ httpStatus: response.status, message: `HTTP ${response.status} with non-JSON body` });
126
+ throw new ApiError({ httpStatus: response.status, message: `HTTP ${response.status} with non-JSON body`, retryAfterMs });
109
127
  }
110
128
  throw new NetworkError(`invalid JSON response from ${path}`);
111
129
  }
@@ -116,6 +134,7 @@ export class TripoClient {
116
134
  message: envelope.message,
117
135
  suggestion: envelope.suggestion,
118
136
  requestId: envelope.request_id,
137
+ retryAfterMs,
119
138
  });
120
139
  }
121
140
  return envelope.data;
@@ -128,18 +147,23 @@ export class TripoClient {
128
147
  return task;
129
148
  }
130
149
  /**
131
- * Batch task query. The server returns `{tasks: {id: detail}, missed: [id]}`
132
- * (NOT an array — the public docs are wrong here; verified against production).
150
+ * Batch task query, at most 100 ids per request. The server returns
151
+ * `{tasks: {id: detail}, missed: [id]}`.
133
152
  */
134
153
  async listTasks(taskIds) {
135
- const data = await this.request('POST', '/v3/tasks/list', { task_ids: taskIds });
136
- const byId = data.tasks ?? {};
154
+ const byId = {};
155
+ const missed = [];
156
+ for (let i = 0; i < taskIds.length; i += 100) {
157
+ const data = await this.request('POST', '/v3/tasks/list', { task_ids: taskIds.slice(i, i + 100) });
158
+ Object.assign(byId, data.tasks ?? {});
159
+ missed.push(...(data.missed ?? []));
160
+ }
137
161
  for (const [id, task] of Object.entries(byId))
138
162
  observeTask({ ...task, task_id: task.task_id ?? id }, this.region, this.baseUrl);
139
163
  const tasks = taskIds
140
164
  .filter((id) => byId[id])
141
165
  .map((id) => ({ ...byId[id], task_id: byId[id].task_id ?? id }));
142
- return { tasks, missed: data.missed ?? [] };
166
+ return { tasks, missed };
143
167
  }
144
168
  getBalance() {
145
169
  return this.request('GET', '/v3/account/balance');
@@ -157,10 +181,8 @@ export class TripoClient {
157
181
  }
158
182
  catch (err) {
159
183
  if (err instanceof NetworkError || (err instanceof ApiError && err.exitCode === EXIT.NETWORK)) {
160
- err.suggestion = t({
161
- zh: '任务可能已在服务端创建并冻结积分;重试前先用 tripo balance 查看冻结额或到控制台确认,避免重复扣费',
162
- en: 'the task may already exist server-side (credits frozen); check "tripo balance" or the console before retrying, to avoid a duplicate charge',
163
- });
184
+ err.suggestion = 'the task may already exist server-side (credits frozen); check "tripo balance" or the console before retrying, to avoid a duplicate charge';
185
+ err.submissionUncertain = true;
164
186
  }
165
187
  throw err;
166
188
  }
@@ -169,3 +191,13 @@ export class TripoClient {
169
191
  export function sleep(ms) {
170
192
  return new Promise((resolve) => setTimeout(resolve, ms));
171
193
  }
194
+ /** Retry-After is in seconds; X-RateLimit-Reset is a unix timestamp in seconds. */
195
+ function retryHintMs(headers) {
196
+ const after = Number(headers.get('retry-after') ?? NaN);
197
+ if (Number.isFinite(after))
198
+ return after * 1000;
199
+ const reset = Number(headers.get('x-ratelimit-reset') ?? NaN);
200
+ if (Number.isFinite(reset))
201
+ return Math.max(reset * 1000 - Date.now(), 0);
202
+ return undefined;
203
+ }
@@ -8,7 +8,7 @@
8
8
  * /cli/device endpoints 404 and the caller falls back to paste login.
9
9
  */
10
10
  import { randomBytes } from 'node:crypto';
11
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import { chmodSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
12
12
  import { hostname } from 'node:os';
13
13
  import { join } from 'node:path';
14
14
  import { REGION_DEFAULTS, tripoHome } from '../config/config.js';
@@ -148,6 +148,54 @@ export async function waitForDeviceApproval(region, grant, onTick) {
148
148
  trackEvent('agent_auth_result', { result: 'expired', auth_method: 'device' }, region, REGION_DEFAULTS[region].api);
149
149
  return { status: 'expired' };
150
150
  }
151
+ /** A single poll without waiting; slow_down only means "asked too soon", so it reads as pending. */
152
+ export async function checkDeviceApproval(region, grant) {
153
+ const poll = await pollDeviceToken(region, grant.device_code);
154
+ if (poll.status === 'pending' || poll.status === 'slow_down')
155
+ return { status: 'pending' };
156
+ trackEvent('agent_auth_result', { result: poll.status === 'approved' ? 'success' : poll.status, auth_method: 'device' }, region, REGION_DEFAULTS[region].api);
157
+ return poll;
158
+ }
159
+ /**
160
+ * A headless login must outlive its process: a chat agent can only relay the
161
+ * code by ending its turn, and its shell may be killed or suspended before the
162
+ * human approves. The grant is parked here (0600, removed once redeemed,
163
+ * denied, or expired) so the next `tripo login` resumes the code the human
164
+ * already has. device_code redeems the key — it never goes to stdout or logs.
165
+ */
166
+ function pendingGrantFile() {
167
+ return join(tripoHome(), 'pending-login.json');
168
+ }
169
+ export function savePendingGrant(region, profile, grant) {
170
+ mkdirSync(tripoHome(), { recursive: true, mode: 0o700 });
171
+ const file = pendingGrantFile();
172
+ writeFileSync(file, JSON.stringify({ region, profile, expires_at: Date.now() + grant.expires_in * 1000, grant }) + '\n', { mode: 0o600 });
173
+ chmodSync(file, 0o600);
174
+ }
175
+ /** The parked grant for this region + profile, with its remaining lifetime as expires_in. */
176
+ export function loadPendingGrant(region, profile) {
177
+ let saved;
178
+ try {
179
+ saved = JSON.parse(readFileSync(pendingGrantFile(), 'utf8'));
180
+ }
181
+ catch {
182
+ return undefined;
183
+ }
184
+ const left = Math.floor(((saved.expires_at ?? 0) - Date.now()) / 1000);
185
+ if (left <= 0 || typeof saved.grant?.device_code !== 'string') {
186
+ clearPendingGrant();
187
+ return undefined;
188
+ }
189
+ if (saved.region !== region || saved.profile !== profile)
190
+ return undefined;
191
+ return { ...saved.grant, expires_in: left };
192
+ }
193
+ export function clearPendingGrant() {
194
+ try {
195
+ unlinkSync(pendingGrantFile());
196
+ }
197
+ catch { }
198
+ }
151
199
  /**
152
200
  * POST JSON and return the response envelope regardless of HTTP status —
153
201
  * device-auth errors (4290/4291/4292) arrive as HTTP 400/429 with a JSON
@@ -11,7 +11,7 @@ import { Readable } from 'node:stream';
11
11
  import { pipeline } from 'node:stream/promises';
12
12
  import { NetworkError } from './errors.js';
13
13
  import { trackEvent, errorProperties } from './analytics.js';
14
- const MODEL_EXTS = new Set(['.glb', '.gltf', '.fbx', '.obj', '.stl', '.3mf', '.usdz', '.zip', '.ply']);
14
+ const MODEL_EXTS = new Set(['.glb', '.gltf', '.fbx', '.obj', '.stl', '.3mf', '.usdz', '.zip', '.ply', '.splat']);
15
15
  const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
16
16
  /** Collect every downloadable URL in the output map (string or {url} object). */
17
17
  export function collectOutputUrls(output) {
@@ -97,6 +97,7 @@ export async function downloadArtifacts(task, outputDir, historyEntry, options =
97
97
  status: task.status,
98
98
  input: task.input ?? historyEntry?.payload ?? {},
99
99
  output_fields: urls.map((u) => u.field),
100
+ normalize_scale: task.output?.normalize_scale,
100
101
  credits_consumed: task.credits_consumed,
101
102
  created_at: task.created_at,
102
103
  completed_at: task.completed_at,
@@ -1,9 +1,10 @@
1
1
  /** CLI error types carrying stable exit codes and human translations. */
2
- import { t } from '../i18n.js';
3
2
  import { EXIT, exitCodeFor, isRetryable, lookupError } from '../knowledge/error-catalog.js';
4
3
  export class CliError extends Error {
5
4
  exitCode;
6
5
  suggestion;
6
+ /** A create request failed without a response: the paid task may exist server-side. */
7
+ submissionUncertain;
7
8
  constructor(message, exitCode = EXIT.FATAL, suggestion) {
8
9
  super(message);
9
10
  this.exitCode = exitCode;
@@ -17,7 +18,7 @@ export class UsageError extends CliError {
17
18
  }
18
19
  export class AuthError extends CliError {
19
20
  constructor(message, suggestion) {
20
- super(message, EXIT.AUTH, suggestion ?? t({ zh: '运行 tripo login 配置 API Key', en: 'run "tripo login" to configure an API key' }));
21
+ super(message, EXIT.AUTH, suggestion ?? 'run "tripo login" to configure an API key');
21
22
  }
22
23
  }
23
24
  export class ApiError extends CliError {
@@ -25,9 +26,13 @@ export class ApiError extends CliError {
25
26
  httpStatus;
26
27
  requestId;
27
28
  retryable;
29
+ /** Server wait hint on 429 (Retry-After / X-RateLimit-Reset). */
30
+ retryAfterMs;
28
31
  constructor(opts) {
29
- const info = opts.apiCode !== undefined ? lookupError(opts.apiCode) : undefined;
30
- const translated = info ? t(info) : undefined;
32
+ // HTTP 401 reads as an auth failure whatever code the body carries (see error-catalog).
33
+ const code = opts.httpStatus === 401 ? 1002 : opts.apiCode;
34
+ const info = code !== undefined ? lookupError(code) : undefined;
35
+ const translated = info?.message;
31
36
  const parts = [translated ?? opts.message ?? `API error (HTTP ${opts.httpStatus})`];
32
37
  if (translated && opts.message && opts.message !== translated)
33
38
  parts.push(`(${opts.message})`);
@@ -36,6 +41,7 @@ export class ApiError extends CliError {
36
41
  this.httpStatus = opts.httpStatus;
37
42
  this.requestId = opts.requestId;
38
43
  this.retryable = isRetryable(opts.apiCode, opts.httpStatus);
44
+ this.retryAfterMs = opts.retryAfterMs;
39
45
  }
40
46
  }
41
47
  export class NetworkError extends CliError {
@@ -43,17 +49,24 @@ export class NetworkError extends CliError {
43
49
  super(message, EXIT.NETWORK, suggestion);
44
50
  }
45
51
  }
52
+ /** The task succeeded (and was billed); only fetching its artifacts failed. */
53
+ export class ArtifactDownloadError extends NetworkError {
54
+ taskId;
55
+ result;
56
+ constructor(taskId, result, reason) {
57
+ super(`task ${taskId} succeeded but downloading its artifacts failed: ${reason}`, `the task is finished and billed; do not regenerate. Download it with "tripo task get ${taskId} --download"`);
58
+ this.taskId = taskId;
59
+ this.result = result;
60
+ }
61
+ }
46
62
  export class TaskFailedError extends CliError {
47
63
  taskId;
48
64
  status;
49
65
  constructor(taskId, status, detail) {
50
- const refundNote = t({
51
- zh: '预扣积分已自动退还',
52
- en: 'frozen credits have been refunded automatically',
53
- });
66
+ const refundNote = 'frozen credits have been refunded automatically';
54
67
  super(
55
- // A timed-out task is still running server-side: nothing has been refunded.
56
- `task ${taskId} ${status}${detail ? `: ${detail}` : ''}${status === 'timeout' ? '' : ` (${refundNote})`}`, EXIT.TASK_FAILED);
68
+ // Only failed/cancelled tasks are refunded; a timed-out task is still running server-side.
69
+ `task ${taskId} ${status}${detail ? `: ${detail}` : ''}${status === 'failed' || status === 'cancelled' ? ` (${refundNote})` : ''}`, EXIT.TASK_FAILED);
57
70
  this.taskId = taskId;
58
71
  this.status = status;
59
72
  }
@@ -61,8 +74,5 @@ export class TaskFailedError extends CliError {
61
74
  /** Decorate insufficient-balance errors with the topup path and the gap. */
62
75
  export function balanceSuggestion(needed, balance) {
63
76
  const gap = needed !== undefined && balance !== undefined ? Math.max(0, needed - balance) : undefined;
64
- return t({
65
- zh: `运行 tripo topup 充值${gap !== undefined ? `(还差约 ${gap} 积分)` : ''}`,
66
- en: `run "tripo topup" to add credits${gap !== undefined ? ` (about ${gap} more needed)` : ''}`,
67
- });
77
+ return `run "tripo topup" to add credits${gap !== undefined ? ` (about ${gap} more needed)` : ''}`;
68
78
  }
@@ -66,7 +66,3 @@ export function emitResult(data, human) {
66
66
  process.stdout.write((human ?? JSON.stringify(data, null, 2)) + '\n');
67
67
  }
68
68
  }
69
- /** Stream one NDJSON event to stdout (used by watch-style streaming output). */
70
- export function emitNdjson(event) {
71
- process.stdout.write(JSON.stringify(event) + '\n');
72
- }