focalapi-cli 0.1.0 → 0.1.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.
package/README.md CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  # focalapi-cli
4
4
 
5
- **给你的 AI Agent 赋予 focalapi 的全部模型能力**
5
+ **给你的 AI Agent 赋予 focalapi 的创作模型能力**
6
6
 
7
7
  [![npm](https://img.shields.io/npm/v/focalapi-cli?color=brightgreen&label=npm)](https://www.npmjs.com/package/focalapi-cli)
8
8
  [![license](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)
9
9
  [![node](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org)
10
10
 
11
- focalapi 的对话、图像生成、视频生成、联网搜索、音频、向量、Rerank、用量查询能力,都可以通过 focalapi CLI + Skills 交给 Claude Code、Codex、OpenCode、Hermes 等本地 Agent 调用。
11
+ focalapi 的图像、视频、音频与视觉创作能力,可以通过 focalapi CLI + Skills 交给本地 Agent 调用;DeepSeek 仅作为提示词、分镜和轻量文本的备用能力。
12
12
 
13
13
  ```shell
14
14
  npm i -g focalapi-cli
@@ -22,11 +22,9 @@ npm i -g focalapi-cli
22
22
 
23
23
  让你的 AI Agent 具备这些 focalapi 能力,并能在复杂任务中组合调用:
24
24
 
25
- - **模型调用**:文本对话、多模态输入(图片理解)、流式输出、管道输入
26
- - **图片 / 视频生成**:文生图、文生视频,产物自动下载到本地;视频任务支持异步编排
27
- - **联网搜索**:`/v1/alpha/search`,给 Agent 接上实时信息
25
+ - **图片 / 视频生成**:文生图、图生图、文生视频,产物自动下载到本地;视频任务支持异步编排
28
26
  - **音频**:语音转写(transcribe)、文字转语音(speech)
29
- - **向量 / 重排**:embeddings 与 rerank,检索链路两件套
27
+ - **DeepSeek 文本辅助**:提示词、分镜、旁白和轻量脚本草稿
30
28
  - **Agent 接入**:`focalapi connect` 把内置 Skills 装进本机 Agent,Agent 即刻学会调用 focalapi
31
29
  - **治理闭环**:`usage` 额度用量、`doctor` 只读诊断(用免费演练模型做端到端自检,不花额度)
32
30
 
@@ -40,7 +38,7 @@ focalapi-cli 不是让你记住更多命令,而是把 focalapi 的模型能力
40
38
 
41
39
  调用链路(Agent 视角):
42
40
  models list 选模型
43
- -> chat / gen / search / embed / rerank 调用
41
+ -> gen / audio / chat(DeepSeek 备用)调用
44
42
  -> 产物落盘(图像/视频/音频)
45
43
  -> usage 看额度,doctor 做诊断
46
44
  ```
@@ -71,34 +69,57 @@ export FOCALAPI_API_KEY=sk-xxxx
71
69
 
72
70
  ## 常用任务
73
71
 
74
- ### 对话与多模态
72
+ ### DeepSeek 文本辅助
75
73
 
76
74
  ```shell
77
- focalapi chat "用一句话总结 RAG 的核心思想" -m focal-rehearsal-chat
75
+ focalapi chat "把这个产品简介写成 6 镜头分镜" -m <DeepSeek模型>
78
76
 
79
- cat report.md | focalapi chat -m <model> --system "你是严谨的技术编辑"
77
+ cat brief.md | focalapi chat -m <DeepSeek模型> --system "你是专业的广告分镜师"
80
78
 
81
- focalapi chat "描述这张图片" -m <多模态模型> --input @photo.jpg
79
+ focalapi chat "提炼这张参考图的构图和色彩" -m <DeepSeek模型> --input @photo.jpg
82
80
  ```
83
81
 
84
82
  ### 生成图片 / 视频
85
83
 
86
84
  ```shell
85
+ # 先读取模型的端点、支持参数、默认值与范围;适合 Agent 在生成前自检
86
+ focalapi models get doubao-seedream-4-5-251128
87
+ focalapi models get doubao-seedance-2-0-260128 --json
88
+
87
89
  focalapi gen image "未来城市海报" -m <图像模型> --size 1024x1024 -o ./out
90
+ focalapi gen image "产品主视觉" -m gpt-image-2 --size 1536x1024 --quality high --background opaque -o ./out
91
+ focalapi gen image "将背景改为雨夜" -m gpt-image-2 --image https://example.com/source.png \
92
+ --mask https://example.com/mask.png --response-format b64_json -o ./out
93
+ # OpenAI 图像模型可选持久任务:立即获得 task_id,稍后查询 data[].url
94
+ focalapi gen image "产品主视觉" -m gpt-image-2 --size 1024x1024 --no-wait --json
95
+ focalapi task status <task_id> --json
96
+
97
+ # Gemini 图像模型是原生 Gemini 端点,不走 OpenAI 图像端点
98
+ focalapi gen gemini-image "一只水彩风格的橘猫" -m gemini-3.1-flash-image-preview \
99
+ --aspect-ratio 16:9 --image-size 2K -o ./out
100
+ # Gemini native generationConfig fields can be passed through --config; named flags cover --image, --system, --seed, --thinking-level, --temperature, and --top-p. responseFormat.image and a single candidate are fixed.
88
101
 
89
102
  # 视频:默认等待完成并下载;异步编排用 --no-wait
90
103
  focalapi gen video "海浪拍打礁石" -m <视频模型> --seconds 5
104
+ focalapi gen video "海浪拍打礁石" -m doubao-seedance-2-0-260128 \
105
+ --seconds 5 --resolution 720p --ratio 16:9 --generate-audio true --no-wait --json
106
+ focalapi gen video "让海浪缓慢推进" -m doubao-seedance-2-0-260128 \
107
+ --image https://example.com/frame.png --generate-audio false --watermark true \
108
+ --return-last-frame true --callback-url https://example.com/callback \
109
+ --execution-expires-after 7200 --safety-identifier customer-42 --priority 4 --no-wait --json
110
+ # Ark-compatible content (text, image_url, video_url, audio_url with roles) is available unchanged through metadata.content:
111
+ focalapi gen video "ignored when content is supplied" -m doubao-seedance-2-0-260128 \
112
+ --content '[{"type":"text","text":"A cinematic ocean wave."}]' --no-wait --json
91
113
  focalapi gen video "海浪拍打礁石" -m <视频模型> --no-wait --json # 拿 task_id
92
114
  focalapi task status <task_id> --json
93
115
  focalapi task download <task_id> -o ./out
94
116
  ```
95
117
 
96
- ### 搜索 / 向量 / 重排 / 音频
118
+ `models get` 是生成前的权威检查入口:返回 `supported_endpoint_types`、`supported_params`,并在可用时提供官方文档链接。CLI 会在本地拒绝已知的不支持参数,例如 Seedream 4.5 的低于 3.69 MP 的尺寸、Seedance Fast/Mini 的 1080p、或 Seedance 的非 4–15 秒时长;不会把这些确定会失败的请求交给上游。同步图像生成时,等待进度输出到 stderr,因此 `--json` 的 stdout 始终保持为可解析 JSON。
119
+
120
+ ### 音频创作
97
121
 
98
122
  ```shell
99
- focalapi search "过去7天 AI 行业新闻" -m <搜索模型> --json
100
- focalapi embed "待编码文本" -m <向量模型> --json
101
- focalapi rerank -m <rerank模型> --query "问题" --docs @docs.json
102
123
  focalapi audio transcribe meeting.mp3 -m <转写模型>
103
124
  focalapi audio speech "大家好" -m <TTS模型> -o hello.mp3
104
125
  ```
@@ -114,9 +135,9 @@ focalapi doctor # 全链路只读诊断,任何调用失败先跑它
114
135
  ## Agent 接入(connect)
115
136
 
116
137
  ```shell
117
- focalapi connect list # 探测本机 Agent(Claude Code / Codex / OpenCode / Hermes)
138
+ focalapi connect list # 探测本机已支持的 Agent
118
139
  focalapi connect install # 向全部检测到的 Agent 安装 skills
119
- focalapi connect install claude-code # 只装指定 Agent
140
+ focalapi connect install <agent-id> # 只装指定 Agent
120
141
  focalapi connect uninstall # 按 manifest 精确卸载,不碰其他文件
121
142
  ```
122
143
 
@@ -134,12 +155,10 @@ focalapi connect uninstall # 按 manifest 精确卸载,不碰其他
134
155
  | 任务 | 命令 |
135
156
  |---|---|
136
157
  | 登录 / 状态 / 登出 | `focalapi auth login / status / logout` |
137
- | 对话和多模态推理 | `focalapi chat` |
158
+ | DeepSeek 文本辅助 | `focalapi chat` |
138
159
  | 图片 / 视频生成 | `focalapi gen image / gen video` |
139
- | 任务查询与产物下载 | `focalapi task status / download` |
140
- | 联网搜索 | `focalapi search` |
160
+ | 任务查询与产物下载 | `focalapi task status / download`(图像任务使用 status 读取 `data[].url`) |
141
161
  | 语音转写 / 合成 | `focalapi audio transcribe / speech` |
142
- | 向量化 / 重排序 | `focalapi embed / rerank` |
143
162
  | 模型查询 | `focalapi models list / get` |
144
163
  | 额度用量 | `focalapi usage` |
145
164
  | 诊断排障 | `focalapi doctor` |
package/dist/cli.js CHANGED
@@ -8,6 +8,10 @@ var ApiError = class extends Error {
8
8
  code;
9
9
  status;
10
10
  hint;
11
+ /** 上游返回的稳定错误代码或类型;不包含密钥等敏感请求信息。 */
12
+ upstreamCode;
13
+ /** 可用于向服务方关联日志的请求 ID。 */
14
+ requestId;
11
15
  /** 上游原始响应体(已截断),仅调试用途,打印前需脱敏。 */
12
16
  body;
13
17
  constructor(code, message, opts) {
@@ -17,6 +21,8 @@ var ApiError = class extends Error {
17
21
  this.status = opts?.status;
18
22
  this.hint = opts?.hint;
19
23
  this.body = opts?.body;
24
+ this.upstreamCode = opts?.upstreamCode;
25
+ this.requestId = opts?.requestId;
20
26
  }
21
27
  toJSON() {
22
28
  return {
@@ -24,12 +30,14 @@ var ApiError = class extends Error {
24
30
  code: this.code,
25
31
  message: this.message,
26
32
  ...this.hint ? { hint: this.hint } : {},
27
- ...this.status !== void 0 ? { status: this.status } : {}
33
+ ...this.status !== void 0 ? { status: this.status } : {},
34
+ ...this.upstreamCode ? { upstream_code: this.upstreamCode } : {},
35
+ ...this.requestId ? { request_id: this.requestId } : {}
28
36
  }
29
37
  };
30
38
  }
31
39
  };
32
- function refineErrorCode(status, message) {
40
+ function refineErrorCode(status, message, opts) {
33
41
  const m = message.toLowerCase();
34
42
  if (m.includes("quota") || m.includes("\u989D\u5EA6") || m.includes("insufficient")) {
35
43
  return "insufficient_quota";
@@ -38,14 +46,17 @@ function refineErrorCode(status, message) {
38
46
  return "model_not_found";
39
47
  }
40
48
  if (m.includes("key") || m.includes("token") || m.includes("auth")) {
41
- return status === 401 || status === 403 ? "invalid_api_key" : "invalid_request";
49
+ if (status === 401 || status === 403) {
50
+ return opts?.authFailureIsInvalidApiKey ? "invalid_api_key" : "upstream_auth_failed";
51
+ }
52
+ return "invalid_request";
42
53
  }
43
54
  switch (status) {
44
55
  case 400:
45
56
  return "invalid_request";
46
57
  case 401:
47
58
  case 403:
48
- return "invalid_api_key";
59
+ return opts?.authFailureIsInvalidApiKey ? "invalid_api_key" : "authentication_failed";
49
60
  case 404:
50
61
  return "model_not_found";
51
62
  case 429:
@@ -103,6 +114,14 @@ function printError(err, opts) {
103
114
  const hint = err.hint;
104
115
  if (hint) {
105
116
  process.stderr.write(`\u63D0\u793A\uFF1A${hint}
117
+ `);
118
+ }
119
+ if (err.upstreamCode) {
120
+ process.stderr.write(`\u4E0A\u6E38\u4EE3\u7801\uFF1A${err.upstreamCode}
121
+ `);
122
+ }
123
+ if (err.requestId) {
124
+ process.stderr.write(`\u8BF7\u6C42 ID\uFF1A${err.requestId}
106
125
  `);
107
126
  }
108
127
  }
@@ -136,7 +155,7 @@ function displayWidth(s) {
136
155
  }
137
156
 
138
157
  // src/lib/version.ts
139
- var VERSION = true ? "0.1.0" : "0.0.0-dev";
158
+ var VERSION = true ? "0.1.1" : "0.0.0-dev";
140
159
 
141
160
  // src/commands/auth.ts
142
161
  import { createInterface } from "readline/promises";
@@ -248,7 +267,8 @@ function extractErrorMessage(raw) {
248
267
  const obj = parsed;
249
268
  const errObj = obj?.error;
250
269
  const message = typeof errObj?.message === "string" && errObj.message || typeof obj?.message === "string" && obj.message || JSON.stringify(parsed).slice(0, 500);
251
- return { message, body: parsed };
270
+ const upstreamCode = [errObj?.code, errObj?.type, obj?.code, obj?.type].find((value) => typeof value === "string");
271
+ return { message, body: parsed, upstreamCode: typeof upstreamCode === "string" ? upstreamCode : void 0 };
252
272
  }
253
273
  async function request(opts) {
254
274
  const res = await rawRequest(opts);
@@ -298,9 +318,10 @@ async function rawRequest(opts) {
298
318
  }
299
319
  if (!res.ok) {
300
320
  const text = await res.text().catch(() => "");
301
- const { message, body } = extractErrorMessage(text);
302
- const code = refineErrorCode(res.status, message);
303
- throw new ApiError(code, message, { status: res.status, body });
321
+ const { message, body, upstreamCode } = extractErrorMessage(text);
322
+ const code = refineErrorCode(res.status, message, { authFailureIsInvalidApiKey: opts.authFailureIsInvalidApiKey });
323
+ const requestId = res.headers.get("x-request-id") ?? res.headers.get("request-id") ?? res.headers.get("x-requestid") ?? void 0;
324
+ throw new ApiError(code, message, { status: res.status, body, upstreamCode, requestId });
304
325
  }
305
326
  return res;
306
327
  }
@@ -342,7 +363,8 @@ async function fetchTokenUsage(baseUrl, apiKey) {
342
363
  baseUrl,
343
364
  path: "/api/usage/token/",
344
365
  apiKey,
345
- timeoutMs: 15e3
366
+ timeoutMs: 15e3,
367
+ authFailureIsInvalidApiKey: true
346
368
  });
347
369
  if (!res.data) {
348
370
  throw new ApiError("bad_response", "\u7528\u91CF\u63A5\u53E3\u54CD\u5E94\u7F3A\u5C11 data \u5B57\u6BB5");
@@ -444,6 +466,39 @@ function registerAuth(program) {
444
466
  }
445
467
 
446
468
  // src/commands/models.ts
469
+ function textValue(value) {
470
+ if (value === null || value === void 0 || value === "") return "-";
471
+ if (Array.isArray(value)) return value.length === 0 ? "-" : value.map(textValue).join(", ");
472
+ if (typeof value === "object") return JSON.stringify(value);
473
+ return String(value);
474
+ }
475
+ function parameterConstraint(parameter) {
476
+ const constraints = [];
477
+ if (parameter.required) constraints.push("\u5FC5\u586B");
478
+ if (parameter.default !== void 0) constraints.push(`\u9ED8\u8BA4 ${String(parameter.default)}`);
479
+ if (parameter.values?.length) constraints.push(`\u53EF\u9009 ${parameter.values.join(" / ")}`);
480
+ if (parameter.minimum !== void 0 || parameter.maximum !== void 0) {
481
+ constraints.push(`${parameter.minimum ?? "-\u221E"}\u2013${parameter.maximum ?? "\u221E"}`);
482
+ }
483
+ return [parameter.type, ...constraints, parameter.description].join("\uFF1B");
484
+ }
485
+ function printModelDetails(model) {
486
+ const { supported_params: supportedParams, ...summary } = model;
487
+ printTable(
488
+ ["\u5B57\u6BB5", "\u503C"],
489
+ Object.entries(summary).map(([key, value]) => [key, textValue(value)])
490
+ );
491
+ if (Array.isArray(supportedParams) && supportedParams.length > 0) {
492
+ process.stdout.write("\n\u652F\u6301\u53C2\u6570\n");
493
+ printTable(
494
+ ["\u53C2\u6570", "\u7EA6\u675F"],
495
+ supportedParams.map((parameter) => {
496
+ const item = parameter;
497
+ return [item.name, parameterConstraint(item)];
498
+ })
499
+ );
500
+ }
501
+ }
447
502
  function registerModels(program) {
448
503
  const models = program.command("models").description("\u53EF\u7528\u6A21\u578B\u67E5\u8BE2");
449
504
  models.command("list").description("\u5217\u51FA\u5F53\u524D Key \u53EF\u7528\u7684\u5168\u90E8\u6A21\u578B").option("--filter <keyword>", "\u6309 id \u5173\u952E\u5B57\u8FC7\u6EE4\uFF08\u4E0D\u533A\u5206\u5927\u5C0F\u5199\uFF09").action(async (opts, cmd) => {
@@ -459,8 +514,8 @@ function registerModels(program) {
459
514
  printJson({ data: list });
460
515
  } else {
461
516
  printTable(
462
- ["\u6A21\u578B ID", "\u63D0\u4F9B\u65B9"],
463
- list.map((m) => [m.id, m.owned_by ?? "-"])
517
+ ["\u6A21\u578B ID", "\u63D0\u4F9B\u65B9", "\u652F\u6301\u7AEF\u70B9"],
518
+ list.map((m) => [m.id, m.owned_by ?? "-", textValue(m.supported_endpoint_types)])
464
519
  );
465
520
  }
466
521
  });
@@ -475,10 +530,7 @@ function registerModels(program) {
475
530
  if (g.json) {
476
531
  printJson(res);
477
532
  } else {
478
- printTable(
479
- ["\u5B57\u6BB5", "\u503C"],
480
- Object.entries(res).map(([k, v]) => [k, typeof v === "object" ? JSON.stringify(v) : String(v)])
481
- );
533
+ printModelDetails(res);
482
534
  }
483
535
  });
484
536
  }
@@ -551,7 +603,7 @@ function extractText(content) {
551
603
  return "";
552
604
  }
553
605
  function registerChat(program) {
554
- program.command("chat").description("\u5BF9\u8BDD\u4E0E\u591A\u6A21\u6001\u63A8\u7406\uFF08/v1/chat/completions\uFF09").argument("[prompt...]", "\u63D0\u793A\u8BCD\uFF1B\u7701\u7565\u4E14 stdin \u4E3A\u7BA1\u9053\u65F6\u4ECE stdin \u8BFB\u53D6").option("-m, --model <model>", "\u6A21\u578B ID\uFF08\u6216\u8BBE FOCALAPI_MODEL\uFF09").option("--system <text>", "system \u63D0\u793A\u8BCD").option("--input <file...>", "\u8F93\u5165\u6587\u4EF6\uFF08\u56FE\u7247\u8F6C data URL\uFF0C\u5982 --input @photo.jpg\uFF1B@ \u524D\u7F00\u53EF\u9009\uFF09").option("--max-tokens <n>", "max_tokens", (v) => Number.parseInt(v, 10)).option("--stream", "\u5F3A\u5236\u6D41\u5F0F\u8F93\u51FA").option("--no-stream", "\u5F3A\u5236\u975E\u6D41\u5F0F").action(
606
+ program.command("chat").description("DeepSeek \u5907\u7528\u5BF9\u8BDD\u4E0E\u63D0\u793A\u8BCD\u8F85\u52A9\uFF08/v1/chat/completions\uFF09").argument("[prompt...]", "\u63D0\u793A\u8BCD\uFF1B\u7701\u7565\u4E14 stdin \u4E3A\u7BA1\u9053\u65F6\u4ECE stdin \u8BFB\u53D6").option("-m, --model <model>", "\u6A21\u578B ID\uFF08\u6216\u8BBE FOCALAPI_MODEL\uFF09").option("--system <text>", "system \u63D0\u793A\u8BCD").option("--input <file...>", "\u8F93\u5165\u6587\u4EF6\uFF08\u56FE\u7247\u8F6C data URL\uFF0C\u5982 --input @photo.jpg\uFF1B@ \u524D\u7F00\u53EF\u9009\uFF09").option("--max-tokens <n>", "max_tokens", (v) => Number.parseInt(v, 10)).option("--stream", "\u5F3A\u5236\u6D41\u5F0F\u8F93\u51FA").option("--no-stream", "\u5F3A\u5236\u975E\u6D41\u5F0F").action(
555
607
  async (promptParts, opts, cmd) => {
556
608
  const g = cmd.optsWithGlobals();
557
609
  const auth = resolveAuth(g);
@@ -724,6 +776,187 @@ import { join as join3, resolve as resolve2 } from "path";
724
776
  import { pipeline as pipeline2 } from "stream/promises";
725
777
  import { Readable as Readable2 } from "stream";
726
778
 
779
+ // src/lib/model-capabilities.ts
780
+ var IMAGE_CONSTRAINTS = {
781
+ "gpt-image-2": {
782
+ defaultSize: "1024x1024",
783
+ maxN: 8,
784
+ maxReferenceImages: 16,
785
+ minMegapixels: 0.65536,
786
+ maxMegapixels: 8.2944,
787
+ minEdge: 1024,
788
+ maxEdge: 3840,
789
+ edgeMultiple: 16,
790
+ maxAspectRatio: 3,
791
+ qualities: ["low", "medium", "high"],
792
+ backgrounds: ["auto", "opaque"]
793
+ },
794
+ "doubao-seedream-4-5-251128": {
795
+ defaultSize: "2048x2048",
796
+ maxN: 10,
797
+ maxReferenceImages: 10,
798
+ maxTotalImages: 15,
799
+ minMegapixels: 3.6864,
800
+ maxMegapixels: 16.777216
801
+ },
802
+ "doubao-seedream-5-0-pro-260628": {
803
+ defaultSize: "1024x1024",
804
+ maxN: 1,
805
+ maxReferenceImages: 10,
806
+ minMegapixels: 0.92,
807
+ maxMegapixels: 4.194304
808
+ },
809
+ "doubao-seedream-5-0-lite-260128": {
810
+ defaultSize: "2048x2048",
811
+ maxN: 14,
812
+ maxReferenceImages: 14,
813
+ maxTotalImages: 15,
814
+ minMegapixels: 3.6864,
815
+ maxMegapixels: 16.777216
816
+ },
817
+ "grok-imagine-image-quality": { defaultSize: "1024x1024", maxN: 10, maxReferenceImages: 3 },
818
+ "grok-imagine-image": { defaultSize: "1024x1024", maxN: 10, maxReferenceImages: 3 },
819
+ "grok-imagine-image-pro": { defaultSize: "1024x1024", maxN: 10, maxReferenceImages: 1 }
820
+ };
821
+ var SEEDANCE_RATIOS = ["adaptive", "16:9", "4:3", "1:1", "3:4", "9:16", "21:9"];
822
+ var VIDEO_CONSTRAINTS = {
823
+ "doubao-seedance-2-0-260128": {
824
+ resolutions: ["480p", "720p", "1080p", "4k"],
825
+ ratios: SEEDANCE_RATIOS,
826
+ minSeconds: 4,
827
+ maxSeconds: 15
828
+ },
829
+ "doubao-seedance-2-0-fast-260128": {
830
+ resolutions: ["480p", "720p"],
831
+ ratios: SEEDANCE_RATIOS,
832
+ minSeconds: 4,
833
+ maxSeconds: 15
834
+ },
835
+ "doubao-seedance-2-0-mini-260615": {
836
+ resolutions: ["480p", "720p"],
837
+ ratios: SEEDANCE_RATIOS,
838
+ minSeconds: 4,
839
+ maxSeconds: 15
840
+ }
841
+ };
842
+ var COMMON_GEMINI_RATIOS = ["auto", "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"];
843
+ var GEMINI_IMAGE_CONSTRAINTS = {
844
+ "gemini-3-pro-image-preview": { aspectRatios: COMMON_GEMINI_RATIOS, imageSizes: ["1K", "2K", "4K"], supportsSampling: false },
845
+ "gemini-3.1-flash-image-preview": { aspectRatios: COMMON_GEMINI_RATIOS, imageSizes: ["1K", "2K", "4K"], supportsSampling: false },
846
+ "gemini-3.1-flash-lite-image-preview": {
847
+ aspectRatios: [...COMMON_GEMINI_RATIOS, "1:4", "4:1", "1:8", "8:1"],
848
+ imageSizes: ["1K"],
849
+ supportsSampling: true
850
+ }
851
+ };
852
+ function parseSize(size, model) {
853
+ const match = /^(\d+)x(\d+)$/i.exec(size.trim());
854
+ if (!match) {
855
+ throw new ApiError("invalid_request", `${model} size must be WIDTHxHEIGHT (received: ${size})`);
856
+ }
857
+ return { width: Number(match[1]), height: Number(match[2]) };
858
+ }
859
+ function megapixels(width, height) {
860
+ return width * height / 1e6;
861
+ }
862
+ function formatMegapixels(value) {
863
+ return value.toFixed(2).replace(/\.00$/, "");
864
+ }
865
+ function validateImageGeneration(model, input) {
866
+ if (input.responseFormat && input.responseFormat !== "url" && input.responseFormat !== "b64_json") {
867
+ throw new ApiError("invalid_request", `response_format must be url or b64_json (received: ${input.responseFormat})`);
868
+ }
869
+ const constraint = IMAGE_CONSTRAINTS[model.trim()];
870
+ if (!constraint) return;
871
+ if (input.n < 1 || input.n > constraint.maxN) {
872
+ throw new ApiError("invalid_request", `${model} n must be 1-${constraint.maxN} (received: ${input.n})`);
873
+ }
874
+ if (input.imageCount !== void 0 && constraint.maxReferenceImages !== void 0 && input.imageCount > constraint.maxReferenceImages) {
875
+ throw new ApiError("invalid_request", `${model} supports at most ${constraint.maxReferenceImages} reference images`);
876
+ }
877
+ if (input.imageCount !== void 0 && constraint.maxTotalImages !== void 0 && input.imageCount + input.n > constraint.maxTotalImages) {
878
+ throw new ApiError("invalid_request", `${model} supports at most ${constraint.maxTotalImages} input plus generated images`);
879
+ }
880
+ if (input.hasMask && model === "gpt-image-2" && input.imageCount !== 1) {
881
+ throw new ApiError("invalid_request", "gpt-image-2 mask requires exactly one reference image");
882
+ }
883
+ if (input.quality && constraint.qualities && !constraint.qualities.includes(input.quality.toLowerCase())) {
884
+ throw new ApiError("invalid_request", `${model} quality must be one of ${constraint.qualities.join(", ")} (received: ${input.quality})`);
885
+ }
886
+ if (input.background && constraint.backgrounds && !constraint.backgrounds.includes(input.background.toLowerCase())) {
887
+ throw new ApiError("invalid_request", `${model} background must be one of ${constraint.backgrounds.join(", ")} (received: ${input.background})`);
888
+ }
889
+ const { width, height } = parseSize(input.size ?? constraint.defaultSize, model);
890
+ const pixels = megapixels(width, height);
891
+ if (constraint.minEdge && (width < constraint.minEdge || height < constraint.minEdge) || constraint.maxEdge && (width > constraint.maxEdge || height > constraint.maxEdge) || constraint.edgeMultiple && (width % constraint.edgeMultiple !== 0 || height % constraint.edgeMultiple !== 0) || constraint.minMegapixels && pixels < constraint.minMegapixels || constraint.maxMegapixels && pixels > constraint.maxMegapixels) {
892
+ const edge = constraint.minEdge && constraint.maxEdge ? `${constraint.minEdge}-${constraint.maxEdge}px per edge` : "";
893
+ const step = constraint.edgeMultiple ? `, edge multiples of ${constraint.edgeMultiple}` : "";
894
+ const mp = constraint.minMegapixels && constraint.maxMegapixels ? `, ${formatMegapixels(constraint.minMegapixels)}-${formatMegapixels(constraint.maxMegapixels)} MP` : "";
895
+ throw new ApiError("invalid_request", `${model} does not support size=${width}x${height}; supported: ${edge}${step}${mp}`);
896
+ }
897
+ if (constraint.maxAspectRatio) {
898
+ const ratio = Math.max(width, height) / Math.min(width, height);
899
+ if (ratio > constraint.maxAspectRatio) {
900
+ throw new ApiError("invalid_request", `${model} longest-to-shortest edge ratio must not exceed ${constraint.maxAspectRatio}:1 (received: ${width}x${height})`);
901
+ }
902
+ }
903
+ }
904
+ function validateGeminiImageGeneration(model, input) {
905
+ const constraint = GEMINI_IMAGE_CONSTRAINTS[model.trim()];
906
+ if (!constraint) {
907
+ throw new ApiError("invalid_request", `${model} is not a supported Gemini image model; run focalapi models get ${model} first`);
908
+ }
909
+ if (input.aspectRatio && !constraint.aspectRatios.includes(input.aspectRatio)) {
910
+ throw new ApiError("invalid_request", `${model} aspectRatio must be one of ${constraint.aspectRatios.join(", ")} (received: ${input.aspectRatio})`);
911
+ }
912
+ if (input.imageSize && !constraint.imageSizes.includes(input.imageSize.toUpperCase())) {
913
+ throw new ApiError("invalid_request", `${model} imageSize must be one of ${constraint.imageSizes.join(", ")} (received: ${input.imageSize})`);
914
+ }
915
+ if (input.seed !== void 0 && (!Number.isInteger(input.seed) || input.seed < 0)) {
916
+ throw new ApiError("invalid_request", "seed must be a non-negative integer");
917
+ }
918
+ if (!constraint.supportsSampling && (input.thinkingLevel || input.temperature !== void 0 || input.topP !== void 0)) {
919
+ throw new ApiError("invalid_request", `${model} supports thinkingLevel, temperature, and topP only on gemini-3.1-flash-lite-image-preview`);
920
+ }
921
+ if (input.thinkingLevel && !["MINIMAL", "HIGH"].includes(input.thinkingLevel.toUpperCase())) {
922
+ throw new ApiError("invalid_request", "thinkingLevel must be MINIMAL or HIGH");
923
+ }
924
+ if (input.temperature !== void 0 && (!Number.isFinite(input.temperature) || input.temperature < 0 || input.temperature > 2)) {
925
+ throw new ApiError("invalid_request", "temperature must be between 0 and 2");
926
+ }
927
+ if (input.topP !== void 0 && (!Number.isFinite(input.topP) || input.topP < 0 || input.topP > 1)) {
928
+ throw new ApiError("invalid_request", "topP must be between 0 and 1");
929
+ }
930
+ }
931
+ function validateVideoGeneration(model, input) {
932
+ const constraint = VIDEO_CONSTRAINTS[model.trim()];
933
+ if (!constraint) return;
934
+ if (input.seconds !== void 0 && (input.seconds < constraint.minSeconds || input.seconds > constraint.maxSeconds)) {
935
+ throw new ApiError("invalid_request", `${model} seconds must be ${constraint.minSeconds}-${constraint.maxSeconds} (received: ${input.seconds})`);
936
+ }
937
+ if (input.resolution && !constraint.resolutions.includes(input.resolution.toLowerCase())) {
938
+ throw new ApiError("invalid_request", `${model} resolution must be one of ${constraint.resolutions.join(", ")} (received: ${input.resolution})`);
939
+ }
940
+ if (input.ratio && !constraint.ratios.includes(input.ratio)) {
941
+ throw new ApiError("invalid_request", `${model} ratio must be one of ${constraint.ratios.join(", ")} (received: ${input.ratio})`);
942
+ }
943
+ if (input.priority !== void 0 && model !== "doubao-seedance-2-0-260128") {
944
+ throw new ApiError("invalid_request", `${model} does not support priority; only doubao-seedance-2-0-260128 does`);
945
+ }
946
+ if (input.priority !== void 0 && (input.priority < 0 || input.priority > 9)) {
947
+ throw new ApiError("invalid_request", `${model} priority must be 0-9 (received: ${input.priority})`);
948
+ }
949
+ if (input.serviceTier && input.serviceTier !== "default") {
950
+ throw new ApiError("invalid_request", `${model} service_tier must be default (received: ${input.serviceTier})`);
951
+ }
952
+ if (input.executionExpiresAfter !== void 0 && (input.executionExpiresAfter < 3600 || input.executionExpiresAfter > 259200)) {
953
+ throw new ApiError("invalid_request", `${model} execution_expires_after must be 3600-259200 (received: ${input.executionExpiresAfter})`);
954
+ }
955
+ if (input.safetyIdentifier !== void 0 && !/^[\x21-\x7e]{1,64}$/.test(input.safetyIdentifier)) {
956
+ throw new ApiError("invalid_request", `${model} safety_identifier must contain 1-64 printable ASCII characters`);
957
+ }
958
+ }
959
+
727
960
  // src/lib/tasks.ts
728
961
  import { createWriteStream } from "fs";
729
962
  import { mkdir } from "fs/promises";
@@ -767,11 +1000,21 @@ function extractProgress(body) {
767
1000
  return void 0;
768
1001
  }
769
1002
  async function fetchTask(baseUrl, apiKey, taskId) {
770
- const raw = await request({
771
- baseUrl,
772
- path: `/v1/video/generations/${encodeURIComponent(taskId)}`,
773
- apiKey
774
- });
1003
+ let raw;
1004
+ try {
1005
+ raw = await request({
1006
+ baseUrl,
1007
+ path: `/v1/video/generations/${encodeURIComponent(taskId)}`,
1008
+ apiKey
1009
+ });
1010
+ } catch (err) {
1011
+ if (!(err instanceof ApiError) || err.status !== 404) throw err;
1012
+ raw = await request({
1013
+ baseUrl,
1014
+ path: `/v1/images/generations/${encodeURIComponent(taskId)}`,
1015
+ apiKey
1016
+ });
1017
+ }
775
1018
  const obj = raw;
776
1019
  const rawStatus = String(obj?.status ?? obj?.data?.status ?? "");
777
1020
  return {
@@ -843,9 +1086,34 @@ function clampInt(value, min, max, name) {
843
1086
  }
844
1087
  return value;
845
1088
  }
1089
+ function parseBooleanOption(value, name) {
1090
+ switch (value.trim().toLowerCase()) {
1091
+ case "true":
1092
+ return true;
1093
+ case "false":
1094
+ return false;
1095
+ default:
1096
+ throw new ApiError("invalid_request", `--${name} must be true or false (received: ${value})`);
1097
+ }
1098
+ }
1099
+ async function withProgress(label, operation) {
1100
+ const startedAt = Date.now();
1101
+ info(`${label}\u2026`);
1102
+ const timer = setInterval(() => {
1103
+ const elapsedSeconds = Math.max(1, Math.floor((Date.now() - startedAt) / 1e3));
1104
+ info(`${label}\uFF0C\u5DF2\u7B49\u5F85 ${elapsedSeconds} \u79D2\u2026`);
1105
+ }, 1e4);
1106
+ timer.unref();
1107
+ try {
1108
+ return await operation();
1109
+ } finally {
1110
+ clearInterval(timer);
1111
+ }
1112
+ }
846
1113
  async function saveImageItem(item, dir, base, apiKey) {
847
1114
  if (item.b64_json) {
848
- const filePath = join3(dir, `${base}.png`);
1115
+ const ext = item.mime_type?.includes("jpeg") ? ".jpg" : item.mime_type?.includes("webp") ? ".webp" : ".png";
1116
+ const filePath = join3(dir, `${base}${ext}`);
849
1117
  await writeFile(filePath, Buffer.from(item.b64_json, "base64"));
850
1118
  return filePath;
851
1119
  }
@@ -864,21 +1132,88 @@ async function saveImageItem(item, dir, base, apiKey) {
864
1132
  }
865
1133
  throw new ApiError("bad_response", "\u56FE\u50CF\u7ED3\u679C\u65E2\u6CA1\u6709 url \u4E5F\u6CA1\u6709 b64_json");
866
1134
  }
1135
+ function parseGenerationConfig(raw) {
1136
+ if (!raw) return {};
1137
+ try {
1138
+ const parsed = JSON.parse(raw);
1139
+ if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") {
1140
+ throw new Error("not an object");
1141
+ }
1142
+ return parsed;
1143
+ } catch {
1144
+ throw new ApiError("invalid_request", "--config \u5FC5\u987B\u662F\u5408\u6CD5\u7684 generationConfig JSON \u5BF9\u8C61\u3002");
1145
+ }
1146
+ }
1147
+ function parseJsonArray(raw, option) {
1148
+ try {
1149
+ const parsed = JSON.parse(raw);
1150
+ if (!Array.isArray(parsed)) throw new Error("not an array");
1151
+ return parsed;
1152
+ } catch {
1153
+ throw new ApiError("invalid_request", `--${option} must be a JSON array`);
1154
+ }
1155
+ }
1156
+ function geminiImagePart(source) {
1157
+ const dataUri = /^data:([^;,]+);base64,([a-z0-9+/=\r\n]+)$/i.exec(source.trim());
1158
+ if (dataUri) {
1159
+ const [, mimeType = "", data = ""] = dataUri;
1160
+ return { inlineData: { mimeType, data: data.replace(/[\r\n]/g, "") } };
1161
+ }
1162
+ return { fileData: { fileUri: source } };
1163
+ }
1164
+ function extractGeminiImageItems(response) {
1165
+ return (response.candidates ?? []).flatMap(
1166
+ (candidate) => (candidate.content?.parts ?? []).flatMap(
1167
+ (part) => part.inlineData?.data ? [{ b64_json: part.inlineData.data, mime_type: part.inlineData.mimeType }] : []
1168
+ )
1169
+ );
1170
+ }
867
1171
  function registerGen(program) {
868
1172
  const gen = program.command("gen").description("\u56FE\u50CF / \u89C6\u9891\u751F\u6210");
869
- gen.command("image").description("\u751F\u6210\u56FE\u50CF\uFF08\u540C\u6B65\u8FD4\u56DE\uFF0C\u4EA7\u7269\u81EA\u52A8\u4E0B\u8F7D\u5230\u672C\u5730\uFF09").argument("<prompt...>", "\u63D0\u793A\u8BCD").requiredOption("-m, --model <model>", "\u56FE\u50CF\u6A21\u578B ID\uFF08focalapi models list \u67E5\u770B\uFF09").option("--size <size>", "\u5C3A\u5BF8\uFF0C\u5982 1024x1024").option("--n <count>", "\u5F20\u6570\uFF081\u2013128\uFF09", (v) => Number.parseInt(v, 10), 1).option("-o, --out <dir>", "\u8F93\u51FA\u76EE\u5F55", DEFAULT_OUT_DIR).action(async (promptParts, opts, cmd) => {
1173
+ gen.command("image").description("\u751F\u6210\u56FE\u50CF\uFF08\u9ED8\u8BA4\u540C\u6B65\u8FD4\u56DE\u5E76\u4E0B\u8F7D\uFF1B--no-wait \u8FD4\u56DE\u53EF\u8F6E\u8BE2\u7684\u4EFB\u52A1 ID\uFF09").argument("<prompt...>", "\u63D0\u793A\u8BCD").requiredOption("-m, --model <model>", "\u56FE\u50CF\u6A21\u578B ID\uFF08focalapi models list \u67E5\u770B\uFF09").option("--size <size>", "\u5C3A\u5BF8\uFF0C\u5982 1024x1024").option("--quality <quality>", "\u56FE\u50CF\u8D28\u91CF\u6863\u4F4D\uFF08\u4EC5\u652F\u6301\u8BE5\u53C2\u6570\u7684\u6A21\u578B\u751F\u6548\uFF09").option("--background <background>", "\u80CC\u666F\u6A21\u5F0F\uFF08\u4EC5 gpt-image-2 \u652F\u6301 auto/opaque\uFF09").option("--image <url...>", "\u53C2\u8003\u56FE\u6216\u7F16\u8F91\u56FE URL\uFF0C\u53EF\u591A\u4E2A").option("--mask <url>", "\u7F16\u8F91 mask URL\uFF08gpt-image-2 \u9700\u8981\u5355\u5F20\u53C2\u8003\u56FE\uFF09").option("--response-format <format>", "\u56FE\u50CF\u54CD\u5E94\u683C\u5F0F\uFF1Aurl \u6216 b64_json").option("--n <count>", "\u5F20\u6570\uFF081\u2013128\uFF09", (v) => Number.parseInt(v, 10), 1).option("--no-wait", "\u63D0\u4EA4\u540E\u7ACB\u5373\u8FD4\u56DE task_id\uFF0C\u4E0D\u7B49\u5F85\u56FE\u50CF\u751F\u6210\u5B8C\u6210").option("-o, --out <dir>", "\u8F93\u51FA\u76EE\u5F55", DEFAULT_OUT_DIR).action(async (promptParts, opts, cmd) => {
870
1174
  const g = cmd.optsWithGlobals();
871
1175
  const auth = resolveAuth(g);
872
1176
  const n = clampInt(opts.n, 1, MAX_IMAGE_N, "n");
1177
+ validateImageGeneration(opts.model, {
1178
+ n,
1179
+ size: opts.size,
1180
+ quality: opts.quality,
1181
+ background: opts.background,
1182
+ responseFormat: opts.responseFormat,
1183
+ imageCount: opts.image?.length,
1184
+ hasMask: Boolean(opts.mask)
1185
+ });
1186
+ if (opts.wait === false && opts.responseFormat === "b64_json") {
1187
+ throw new ApiError("invalid_request", "--response-format b64_json cannot be used with --no-wait; use url");
1188
+ }
873
1189
  const body = { model: opts.model, prompt: promptParts.join(" "), n };
874
1190
  if (opts.size) body.size = opts.size;
875
- const res = await request({
1191
+ if (opts.quality) body.quality = opts.quality;
1192
+ if (opts.background) body.background = opts.background;
1193
+ if (opts.image) body.image = opts.image;
1194
+ if (opts.mask) body.mask = opts.mask;
1195
+ if (opts.responseFormat) body.response_format = opts.responseFormat;
1196
+ const res = await withProgress(opts.wait === false ? "\u6B63\u5728\u63D0\u4EA4\u56FE\u50CF\u4EFB\u52A1" : "\u6B63\u5728\u751F\u6210\u56FE\u50CF", () => request({
876
1197
  baseUrl: auth.baseUrl,
877
1198
  path: "/v1/images/generations",
878
1199
  apiKey: auth.apiKey,
879
1200
  body,
1201
+ headers: opts.wait === false ? { Prefer: "respond-async" } : void 0,
880
1202
  timeoutMs: 6e5
881
- });
1203
+ }));
1204
+ if (opts.wait === false) {
1205
+ const taskId = extractTaskId(res);
1206
+ if (!taskId) {
1207
+ throw new ApiError("bad_response", "\u5F02\u6B65\u56FE\u50CF\u4EFB\u52A1\u54CD\u5E94\u4E2D\u672A\u627E\u5230 task_id", { body: res });
1208
+ }
1209
+ if (g.json) {
1210
+ printJson({ task_id: taskId, status: res.status ?? "queued", submitted: true });
1211
+ } else {
1212
+ process.stdout.write(taskId + "\n");
1213
+ info(`\u4EFB\u52A1\u5DF2\u63D0\u4EA4\u3002\u67E5\u8BE2\uFF1Afocalapi task status ${taskId}`);
1214
+ }
1215
+ return;
1216
+ }
882
1217
  const items = res.data ?? [];
883
1218
  if (items.length === 0) {
884
1219
  throw new ApiError("bad_response", "\u56FE\u50CF\u751F\u6210\u54CD\u5E94\u4E3A\u7A7A", { body: res });
@@ -896,22 +1231,102 @@ function registerGen(program) {
896
1231
  for (const f of files) info(`\u2713 ${f}`);
897
1232
  }
898
1233
  });
899
- gen.command("video").description("\u751F\u6210\u89C6\u9891\uFF08\u4EFB\u52A1\u5236\uFF1A\u9ED8\u8BA4\u8F6E\u8BE2\u81F3\u5B8C\u6210\u5E76\u4E0B\u8F7D\uFF1B--no-wait \u53EA\u53D6 task_id\uFF09").argument("<prompt...>", "\u63D0\u793A\u8BCD").requiredOption("-m, --model <model>", "\u89C6\u9891\u6A21\u578B ID\uFF08focalapi models list \u67E5\u770B\uFF09").option("--seconds <n>", "\u65F6\u957F\u79D2\u6570\uFF081\u20133600\uFF09", (v) => Number.parseInt(v, 10)).option("--size <size>", "\u5206\u8FA8\u7387\uFF0C\u5982 1280x720").option("--no-wait", "\u63D0\u4EA4\u540E\u7ACB\u5373\u8FD4\u56DE task_id\uFF0C\u4E0D\u7B49\u5F85\u5B8C\u6210").option("--poll-interval <ms>", "\u8F6E\u8BE2\u95F4\u9694\u6BEB\u79D2", (v) => Number.parseInt(v, 10), 5e3).option("--timeout <minutes>", "\u6700\u957F\u7B49\u5F85\u5206\u949F", (v) => Number.parseInt(v, 10), 30).option("-o, --out <dir>", "\u8F93\u51FA\u76EE\u5F55", DEFAULT_OUT_DIR).action(
1234
+ gen.command("gemini-image").description("\u4F7F\u7528 Gemini \u539F\u751F generateContent \u63A5\u53E3\u751F\u6210\u56FE\u50CF").argument("<prompt...>", "\u63D0\u793A\u8BCD").requiredOption("-m, --model <model>", "Gemini \u56FE\u50CF\u6A21\u578B ID\uFF1B\u5148\u7528 focalapi models get \u786E\u8BA4").option("--aspect-ratio <ratio>", "\u753B\u9762\u6BD4\u4F8B\uFF0C\u4F8B\u5982 1:1\u300116:9\u3001auto").option("--image-size <size>", "\u8F93\u51FA\u5C3A\u5BF8\uFF0C\u4F8B\u5982 1K\u30012K\u30014K").option("--config <json>", "\u9644\u52A0 Gemini generationConfig JSON\uFF1B\u547D\u4EE4\u56FA\u5B9A responseFormat.image \u548C\u5355\u5019\u9009").option("-o, --out <dir>", "\u8F93\u51FA\u76EE\u5F55", DEFAULT_OUT_DIR).option("--image <url...>", "Gemini reference image URL or data URI; repeatable").option("--system <text>", "Gemini systemInstruction text").option("--seed <n>", "Non-negative Gemini generation seed", (v) => Number.parseInt(v, 10)).option("--thinking-level <level>", "Nano Banana 2 Lite: MINIMAL or HIGH").option("--temperature <n>", "Nano Banana 2 Lite: 0 through 2", (v) => Number.parseFloat(v)).option("--top-p <n>", "Nano Banana 2 Lite: 0 through 1", (v) => Number.parseFloat(v)).action(async (promptParts, opts, cmd) => {
1235
+ const g = cmd.optsWithGlobals();
1236
+ const auth = resolveAuth(g);
1237
+ validateGeminiImageGeneration(opts.model, {
1238
+ aspectRatio: opts.aspectRatio,
1239
+ imageSize: opts.imageSize,
1240
+ seed: opts.seed,
1241
+ thinkingLevel: opts.thinkingLevel,
1242
+ temperature: opts.temperature,
1243
+ topP: opts.topP
1244
+ });
1245
+ const suppliedConfig = parseGenerationConfig(opts.config);
1246
+ const suppliedResponseFormat = suppliedConfig.responseFormat;
1247
+ const suppliedImageConfig = suppliedResponseFormat !== null && typeof suppliedResponseFormat === "object" && !Array.isArray(suppliedResponseFormat) ? suppliedResponseFormat.image : void 0;
1248
+ const imageConfig = {
1249
+ ...suppliedImageConfig !== null && typeof suppliedImageConfig === "object" && !Array.isArray(suppliedImageConfig) ? suppliedImageConfig : {},
1250
+ ...opts.aspectRatio ? { aspectRatio: opts.aspectRatio } : {},
1251
+ ...opts.imageSize ? { imageSize: opts.imageSize.toUpperCase() } : {}
1252
+ };
1253
+ const generationConfig = {
1254
+ ...suppliedConfig,
1255
+ candidateCount: 1,
1256
+ responseFormat: { image: imageConfig },
1257
+ ...opts.seed !== void 0 ? { seed: opts.seed } : {},
1258
+ ...opts.thinkingLevel ? { thinkingConfig: { ...suppliedConfig.thinkingConfig ?? {}, thinkingLevel: opts.thinkingLevel.toUpperCase() } } : {},
1259
+ ...opts.temperature !== void 0 ? { temperature: opts.temperature } : {},
1260
+ ...opts.topP !== void 0 ? { topP: opts.topP } : {}
1261
+ };
1262
+ const res = await withProgress("\u6B63\u5728\u751F\u6210 Gemini \u56FE\u50CF", () => request({
1263
+ baseUrl: auth.baseUrl,
1264
+ path: `/v1beta/models/${encodeURIComponent(opts.model)}:generateContent`,
1265
+ apiKey: auth.apiKey,
1266
+ body: {
1267
+ contents: [{ role: "user", parts: [{ text: promptParts.join(" ") }, ...(opts.image ?? []).map(geminiImagePart)] }],
1268
+ ...opts.system ? { systemInstruction: { parts: [{ text: opts.system }] } } : {},
1269
+ generationConfig
1270
+ },
1271
+ timeoutMs: 6e5
1272
+ }));
1273
+ const items = extractGeminiImageItems(res);
1274
+ if (items.length === 0) {
1275
+ throw new ApiError("bad_response", "Gemini \u56FE\u50CF\u54CD\u5E94\u4E2D\u672A\u627E\u5230 inlineData \u56FE\u50CF\u3002", { body: res });
1276
+ }
1277
+ const dir = resolve2(opts.out);
1278
+ await mkdir2(dir, { recursive: true });
1279
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
1280
+ const files = [];
1281
+ for (const [i, item] of items.entries()) {
1282
+ files.push(await saveImageItem(item, dir, `gemini-image-${ts}-${i + 1}`, auth.apiKey));
1283
+ }
1284
+ if (g.json) {
1285
+ printJson({ files, count: files.length });
1286
+ } else {
1287
+ for (const file of files) info(`\u2713 ${file}`);
1288
+ }
1289
+ });
1290
+ gen.command("video").description("\u751F\u6210\u89C6\u9891\uFF08\u4EFB\u52A1\u5236\uFF1A\u9ED8\u8BA4\u8F6E\u8BE2\u81F3\u5B8C\u6210\u5E76\u4E0B\u8F7D\uFF1B--no-wait \u53EA\u53D6 task_id\uFF09").argument("<prompt...>", "\u63D0\u793A\u8BCD").requiredOption("-m, --model <model>", "\u89C6\u9891\u6A21\u578B ID\uFF08focalapi models list \u67E5\u770B\uFF09").option("--seconds <n>", "\u65F6\u957F\u79D2\u6570\uFF081\u20133600\uFF09", (v) => Number.parseInt(v, 10)).option("--size <size>", "\u5206\u8FA8\u7387\uFF0C\u5982 1280x720").option("--resolution <resolution>", "\u539F\u751F\u8F93\u51FA\u5206\u8FA8\u7387\uFF0C\u5982 480p\u3001720p\u30011080p\u30014k").option("--ratio <ratio>", "\u539F\u751F\u5BBD\u9AD8\u6BD4\uFF0C\u5982 16:9\u30019:16\u3001adaptive").option("--image <url...>", "\u56FE\u751F\u89C6\u9891\u7684\u6E90\u56FE\u50CF URL\uFF0C\u53EF\u591A\u4E2A").option("--generate-audio <boolean>", "\u662F\u5426\u751F\u6210\u97F3\u9891\uFF08\u53EA\u63A5\u53D7 true \u6216 false\uFF09", (v) => parseBooleanOption(v, "generate-audio")).option("--watermark <boolean>", "\u662F\u5426\u6DFB\u52A0\u6C34\u5370\uFF08\u53EA\u63A5\u53D7 true \u6216 false\uFF09", (v) => parseBooleanOption(v, "watermark")).option("--service-tier <tier>", "\u670D\u52A1\u5C42\u7EA7\uFF08Seedance 2.0 \u9ED8\u8BA4 default\uFF09").option("--priority <n>", "\u4EFB\u52A1\u4F18\u5148\u7EA7\uFF08\u4EC5 Seedance 2.0 \u6807\u51C6\u7248\uFF09", (v) => Number.parseInt(v, 10)).option("--callback-url <url>", "\u4EFB\u52A1\u5B8C\u6210\u56DE\u8C03 URL").option("--return-last-frame <boolean>", "\u662F\u5426\u8FD4\u56DE\u6700\u540E\u4E00\u5E27\uFF08\u53EA\u63A5\u53D7 true \u6216 false\uFF09", (v) => parseBooleanOption(v, "return-last-frame")).option("--execution-expires-after <seconds>", "\u4EFB\u52A1\u8FC7\u671F\u79D2\u6570\uFF083600\u2013259200\uFF09", (v) => Number.parseInt(v, 10)).option("--safety-identifier <identifier>", "Seedance \u5B89\u5168\u6807\u8BC6\u7B26\uFF081\u201364 \u4E2A\u53EF\u6253\u5370 ASCII \u5B57\u7B26\uFF09").option("--no-wait", "\u63D0\u4EA4\u540E\u7ACB\u5373\u8FD4\u56DE task_id\uFF0C\u4E0D\u7B49\u5F85\u5B8C\u6210").option("--poll-interval <ms>", "\u8F6E\u8BE2\u95F4\u9694\u6BEB\u79D2", (v) => Number.parseInt(v, 10), 5e3).option("--timeout <minutes>", "\u6700\u957F\u7B49\u5F85\u5206\u949F", (v) => Number.parseInt(v, 10), 30).option("-o, --out <dir>", "\u8F93\u51FA\u76EE\u5F55", DEFAULT_OUT_DIR).option("--content <json>", "Ark-compatible content JSON array; overrides prompt/image facade fields").action(
900
1291
  async (promptParts, opts, cmd) => {
901
1292
  const g = cmd.optsWithGlobals();
902
1293
  const auth = resolveAuth(g);
903
1294
  const body = { model: opts.model, prompt: promptParts.join(" ") };
1295
+ const metadata = {};
904
1296
  if (opts.seconds !== void 0) {
905
- body.seconds = String(clampInt(opts.seconds, 1, MAX_TASK_DURATION_SECONDS, "seconds"));
1297
+ const seconds = clampInt(opts.seconds, 1, MAX_TASK_DURATION_SECONDS, "seconds");
1298
+ body.duration = seconds;
906
1299
  }
907
1300
  if (opts.size) body.size = opts.size;
908
- const created = await request({
1301
+ if (opts.image) body.images = opts.image;
1302
+ if (opts.resolution) metadata.resolution = opts.resolution.toLowerCase();
1303
+ if (opts.ratio) metadata.ratio = opts.ratio;
1304
+ if (opts.generateAudio !== void 0) metadata.generate_audio = opts.generateAudio;
1305
+ if (opts.watermark !== void 0) metadata.watermark = opts.watermark;
1306
+ if (opts.serviceTier) metadata.service_tier = opts.serviceTier;
1307
+ if (opts.priority !== void 0) metadata.priority = opts.priority;
1308
+ if (opts.callbackUrl) metadata.callback_url = opts.callbackUrl;
1309
+ if (opts.returnLastFrame !== void 0) metadata.return_last_frame = opts.returnLastFrame;
1310
+ if (opts.executionExpiresAfter !== void 0) metadata.execution_expires_after = opts.executionExpiresAfter;
1311
+ if (opts.safetyIdentifier) metadata.safety_identifier = opts.safetyIdentifier;
1312
+ if (opts.content) metadata.content = parseJsonArray(opts.content, "content");
1313
+ validateVideoGeneration(opts.model, {
1314
+ seconds: opts.seconds,
1315
+ resolution: opts.resolution,
1316
+ ratio: opts.ratio,
1317
+ serviceTier: opts.serviceTier,
1318
+ priority: opts.priority,
1319
+ executionExpiresAfter: opts.executionExpiresAfter,
1320
+ safetyIdentifier: opts.safetyIdentifier
1321
+ });
1322
+ if (Object.keys(metadata).length > 0) body.metadata = metadata;
1323
+ const created = await withProgress("\u6B63\u5728\u63D0\u4EA4\u89C6\u9891\u4EFB\u52A1", () => request({
909
1324
  baseUrl: auth.baseUrl,
910
1325
  path: "/v1/video/generations",
911
1326
  apiKey: auth.apiKey,
912
1327
  body,
913
1328
  timeoutMs: 12e4
914
- });
1329
+ }));
915
1330
  const taskId = extractTaskId(created);
916
1331
  if (!taskId) {
917
1332
  throw new ApiError("bad_response", "\u89C6\u9891\u4EFB\u52A1\u54CD\u5E94\u4E2D\u672A\u627E\u5230 task_id", { body: created });
@@ -980,63 +1395,6 @@ function registerTask(program) {
980
1395
  });
981
1396
  }
982
1397
 
983
- // src/commands/search.ts
984
- import { readFileSync as readFileSync3 } from "fs";
985
- function extractResults(raw) {
986
- if (!raw || typeof raw !== "object") return [];
987
- const obj = raw;
988
- const candidates = [obj.results, obj.data, obj.items, obj.output];
989
- for (const c of candidates) {
990
- if (!Array.isArray(c)) continue;
991
- const out = [];
992
- for (const item of c) {
993
- if (!item || typeof item !== "object") continue;
994
- const it = item;
995
- const url = typeof it.url === "string" ? it.url : typeof it.link === "string" ? it.link : "";
996
- const title = typeof it.title === "string" ? it.title : url;
997
- const snippet = typeof it.snippet === "string" ? it.snippet : typeof it.content === "string" ? it.content.slice(0, 200) : void 0;
998
- if (url || title) out.push({ title, url, ...snippet ? { snippet } : {} });
999
- }
1000
- if (out.length > 0) return out;
1001
- }
1002
- return [];
1003
- }
1004
- function registerSearch(program) {
1005
- program.command("search").description("\u8054\u7F51\u641C\u7D22\uFF08/v1/alpha/search\uFF0Calpha \u7EA7\u63A5\u53E3\uFF09").argument("<query...>", "\u641C\u7D22\u5185\u5BB9").requiredOption("-m, --model <model>", "\u641C\u7D22\u6A21\u578B ID\uFF08focalapi models list --filter search \u67E5\u770B\uFF09").option("--raw <json|@file>", "\u5B8C\u6574\u81EA\u5B9A\u4E49\u8BF7\u6C42\u4F53\uFF08JSON \u5B57\u7B26\u4E32\u6216 @\u6587\u4EF6\uFF09\uFF0C\u4E0E query \u5408\u5E76").action(async (queryParts, opts, cmd) => {
1006
- const g = cmd.optsWithGlobals();
1007
- const auth = resolveAuth(g);
1008
- let body = { model: opts.model, query: queryParts.join(" ") };
1009
- if (opts.raw) {
1010
- const text = opts.raw.startsWith("@") ? readFileSync3(opts.raw.slice(1), "utf-8") : opts.raw;
1011
- try {
1012
- body = { ...JSON.parse(text), model: opts.model };
1013
- } catch {
1014
- throw new ApiError("invalid_request", "--raw \u4E0D\u662F\u5408\u6CD5 JSON");
1015
- }
1016
- }
1017
- const res = await request({
1018
- baseUrl: auth.baseUrl,
1019
- path: "/v1/alpha/search",
1020
- apiKey: auth.apiKey,
1021
- body,
1022
- timeoutMs: 12e4
1023
- });
1024
- if (g.json) {
1025
- printJson(res);
1026
- return;
1027
- }
1028
- const results = extractResults(res);
1029
- if (results.length === 0) {
1030
- printJson(res);
1031
- return;
1032
- }
1033
- printTable(
1034
- ["#", "\u6807\u9898", "\u94FE\u63A5"],
1035
- results.map((r, i) => [String(i + 1), r.title.slice(0, 60), r.url])
1036
- );
1037
- });
1038
- }
1039
-
1040
1398
  // src/commands/audio.ts
1041
1399
  import { writeFile as writeFile2 } from "fs/promises";
1042
1400
  import { resolve as resolve3 } from "path";
@@ -1087,77 +1445,15 @@ function registerAudio(program) {
1087
1445
  });
1088
1446
  }
1089
1447
 
1090
- // src/commands/embed.ts
1091
- function registerEmbed(program) {
1092
- program.command("embed").description("\u6587\u672C\u5411\u91CF\u5316\uFF08/v1/embeddings\uFF09").argument("[text...]", "\u6587\u672C\uFF1B\u7701\u7565\u4E14 stdin \u4E3A\u7BA1\u9053\u65F6\u4ECE stdin \u8BFB\u53D6").requiredOption("-m, --model <model>", "\u5411\u91CF\u6A21\u578B ID").option("--input <file>", "\u4ECE\u6587\u4EF6\u8BFB\u53D6\u6587\u672C\uFF08@ \u524D\u7F00\u53EF\u9009\uFF09").action(async (textParts, opts, cmd) => {
1093
- const g = cmd.optsWithGlobals();
1094
- const auth = resolveAuth(g);
1095
- let text = textParts.join(" ").trim();
1096
- if (opts.input) {
1097
- text = readInputFile(opts.input.replace(/^@/, "")).data.toString("utf-8");
1098
- } else if (!text && !process.stdin.isTTY) {
1099
- text = await readStdin();
1100
- }
1101
- if (!text) {
1102
- throw new ApiError("invalid_request", "\u7F3A\u5C11\u5F85\u5411\u91CF\u5316\u6587\u672C", {
1103
- hint: 'focalapi embed "\u6587\u672C" -m <model>\uFF0C\u6216 focalapi embed -m <model> --input @file.txt\u3002'
1104
- });
1105
- }
1106
- const res = await request({
1107
- baseUrl: auth.baseUrl,
1108
- path: "/v1/embeddings",
1109
- apiKey: auth.apiKey,
1110
- body: { model: opts.model, input: text },
1111
- timeoutMs: 12e4
1112
- });
1113
- if (g.json) {
1114
- printJson(res);
1115
- } else {
1116
- const vec = res.data?.[0]?.embedding ?? [];
1117
- info(`\u7EF4\u5EA6\uFF1A${vec.length}\uFF1Btokens\uFF1A${res.usage?.total_tokens ?? "-"}`);
1118
- info("\u5B8C\u6574\u5411\u91CF\u8BF7\u7528 --json \u8F93\u51FA\u3002");
1119
- }
1120
- });
1121
- }
1122
-
1123
- // src/commands/rerank.ts
1124
- import { readFileSync as readFileSync4 } from "fs";
1125
- function registerRerank(program) {
1126
- program.command("rerank").description("\u6309\u67E5\u8BE2\u5BF9\u6587\u6863\u91CD\u6392\u5E8F\uFF08/v1/rerank\uFF09").requiredOption("-m, --model <model>", "rerank \u6A21\u578B ID").requiredOption("--query <text>", "\u67E5\u8BE2").requiredOption("--docs <json|@file>", "\u6587\u6863\u6570\u7EC4\uFF08JSON \u5B57\u7B26\u4E32\u6216 @file.json\uFF09").option("--top-n <n>", "\u53EA\u8FD4\u56DE\u524D N \u6761", (v) => Number.parseInt(v, 10)).action(async (opts, cmd) => {
1127
- const g = cmd.optsWithGlobals();
1128
- const auth = resolveAuth(g);
1129
- const text = opts.docs.startsWith("@") ? readFileSync4(opts.docs.slice(1), "utf-8") : opts.docs;
1130
- let documents;
1131
- try {
1132
- documents = JSON.parse(text);
1133
- } catch {
1134
- throw new ApiError("invalid_request", "--docs \u4E0D\u662F\u5408\u6CD5 JSON \u6570\u7EC4");
1135
- }
1136
- if (!Array.isArray(documents) || documents.length === 0) {
1137
- throw new ApiError("invalid_request", "--docs \u5FC5\u987B\u662F\u975E\u7A7A JSON \u6570\u7EC4");
1138
- }
1139
- const body = { model: opts.model, query: opts.query, documents };
1140
- if (opts.topN !== void 0) body.top_n = opts.topN;
1141
- const res = await request({
1142
- baseUrl: auth.baseUrl,
1143
- path: "/v1/rerank",
1144
- apiKey: auth.apiKey,
1145
- body,
1146
- timeoutMs: 12e4
1147
- });
1148
- if (g.json) {
1149
- printJson(res);
1150
- return;
1151
- }
1152
- const rows = (res.results ?? []).map((r) => {
1153
- const doc = typeof r.document === "string" ? r.document : r.document?.text ?? "";
1154
- return [String(r.index ?? "-"), String(r.relevance_score ?? "-"), doc.slice(0, 60)];
1155
- });
1156
- printTable(["\u539F\u6587\u6863\u5E8F\u53F7", "\u76F8\u5173\u5EA6", "\u6587\u6863\u9884\u89C8"], rows);
1157
- });
1158
- }
1159
-
1160
1448
  // src/commands/usage.ts
1449
+ function formatBillingUsage(billing) {
1450
+ const value = billing.total_usage;
1451
+ if (typeof value === "number" && Number.isFinite(value)) {
1452
+ return value.toLocaleString("zh-CN", { maximumFractionDigits: 4 });
1453
+ }
1454
+ if (typeof value === "string" && value.trim() !== "") return value;
1455
+ return "-";
1456
+ }
1161
1457
  function defaultStartDate() {
1162
1458
  const now = /* @__PURE__ */ new Date();
1163
1459
  return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
@@ -1191,14 +1487,15 @@ function registerUsage(program) {
1191
1487
  ["\u5DF2\u7528", String(token.total_used)],
1192
1488
  ["\u5269\u4F59", token.unlimited_quota ? "\u65E0\u9650" : String(token.total_available)],
1193
1489
  ["\u8FC7\u671F\u65F6\u95F4", token.expires_at > 0 ? new Date(token.expires_at * 1e3).toLocaleString() : "\u6C38\u4E0D\u8FC7\u671F"],
1194
- [`\u5468\u671F\u7528\u91CF\uFF08${start} ~ ${end}\uFF09`, JSON.stringify(billing)]
1490
+ [`\u5468\u671F\u7528\u91CF\uFF08${start} ~ ${end}\uFF09`, formatBillingUsage(billing)],
1491
+ ["\u8D26\u5355\u5BF9\u8C61", typeof billing.object === "string" ? billing.object : "-"]
1195
1492
  ]
1196
1493
  );
1197
1494
  });
1198
1495
  }
1199
1496
 
1200
1497
  // src/commands/connect.ts
1201
- import { cpSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync5, readdirSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
1498
+ import { cpSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, readdirSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
1202
1499
  import { homedir as homedir2, platform } from "os";
1203
1500
  import { dirname, join as join4 } from "path";
1204
1501
  import { fileURLToPath } from "url";
@@ -1290,7 +1587,7 @@ function readManifest(skillsDir) {
1290
1587
  const path = manifestPath(skillsDir);
1291
1588
  if (!existsSync2(path)) return void 0;
1292
1589
  try {
1293
- return JSON.parse(readFileSync5(path, "utf-8"));
1590
+ return JSON.parse(readFileSync3(path, "utf-8"));
1294
1591
  } catch {
1295
1592
  return void 0;
1296
1593
  }
@@ -1513,16 +1810,13 @@ function registerRequest(program) {
1513
1810
  // src/cli.ts
1514
1811
  function buildProgram() {
1515
1812
  const program = new Command();
1516
- program.name("focalapi").description("\u8FDE\u63A5 focalapi \u4E0E AI Agent \u7684\u547D\u4EE4\u884C\u5DE5\u5177\uFF1A\u5BF9\u8BDD\u3001\u56FE\u50CF\u3001\u89C6\u9891\u3001\u641C\u7D22\u3001\u97F3\u9891\u3001\u7528\u91CF\uFF0C\u4E00\u6761\u547D\u4EE4\u76F4\u8FBE").version(VERSION, "-v, --version", "\u663E\u793A\u7248\u672C\u53F7").option("--json", "\u4EE5 JSON \u8F93\u51FA\uFF08\u9762\u5411 Agent \u4E0E\u811A\u672C\uFF0Cstdout \u7EAF\u51C0\uFF09").option("--base-url <url>", "\u8986\u76D6 API \u5730\u5740\uFF08\u9ED8\u8BA4 https://api.focalapi.com\uFF0C\u53EF\u7528 FOCALAPI_BASE_URL\uFF09").option("--key <key>", "\u8986\u76D6 API Key\uFF08\u53EF\u7528 FOCALAPI_API_KEY\uFF09").option("--profile <name>", "\u4F7F\u7528\u6307\u5B9A\u914D\u7F6E\u6863\u6848");
1813
+ program.name("focalapi").description("\u9762\u5411\u521B\u4F5C\u5DE5\u4F5C\u6D41\u7684 focalapi CLI\uFF1A\u56FE\u50CF\u3001\u89C6\u9891\u3001\u97F3\u9891\u4E0E DeepSeek \u5907\u7528\u6587\u672C\u80FD\u529B\uFF0C\u4E00\u6761\u547D\u4EE4\u76F4\u8FBE").version(VERSION, "-v, --version", "\u663E\u793A\u7248\u672C\u53F7").option("--json", "\u4EE5 JSON \u8F93\u51FA\uFF08\u9762\u5411 Agent \u4E0E\u811A\u672C\uFF0Cstdout \u7EAF\u51C0\uFF09").option("--base-url <url>", "\u8986\u76D6 API \u5730\u5740\uFF08\u9ED8\u8BA4 https://api.focalapi.com\uFF0C\u53EF\u7528 FOCALAPI_BASE_URL\uFF09").option("--key <key>", "\u8986\u76D6 API Key\uFF08\u53EF\u7528 FOCALAPI_API_KEY\uFF09").option("--profile <name>", "\u4F7F\u7528\u6307\u5B9A\u914D\u7F6E\u6863\u6848");
1517
1814
  registerAuth(program);
1518
1815
  registerModels(program);
1519
1816
  registerChat(program);
1520
1817
  registerGen(program);
1521
1818
  registerTask(program);
1522
- registerSearch(program);
1523
1819
  registerAudio(program);
1524
- registerEmbed(program);
1525
- registerRerank(program);
1526
1820
  registerUsage(program);
1527
1821
  registerDoctor(program);
1528
1822
  registerConnect(program);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "focalapi-cli",
3
- "version": "0.1.0",
4
- "description": "连接 focalapi AI Agent 的命令行工具——让 Agent 直接调用 focalapi 的对话、图像、视频、搜索、音频等模型能力",
3
+ "version": "0.1.1",
4
+ "description": "连接 focalapi 创作模型与 AI Agent 的命令行工具——图像、视频、音频与 DeepSeek 备用文本能力",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "focalapi": "dist/cli.js"
@@ -1,43 +1,43 @@
1
1
  ---
2
2
  name: focalapi
3
- description: focalapi 能力总入口。Use when 用户要求使用 focalapi / focalapi-cli,或需要调用 focalapi 的对话、图像、视频、搜索、音频、向量、重排、用量等任意模型能力。先读本技能确定该用哪个子技能/命令。
3
+ description: focalapi 创作能力总入口。Use when 用户需要生成或处理图像、视频、音频、3D 或其他视觉创作内容;DeepSeek 仅用于提示词、分镜和轻量文本辅助。先读本技能选择对应的子技能或命令。
4
4
  ---
5
5
 
6
- # focalapi 能力总览
6
+ # focalapi 创作能力总览
7
7
 
8
- focalapi 是统一的 AI 模型 API 网关(OpenAI 兼容),`focalapi` CLI 让 Agent 一条命令调用全部能力。
8
+ focalapi 是面向创作工作流的模型中转服务。`focalapi` CLI 让 Agent 用命令完成图像、视频、音频与其他视觉创作任务;通用文本只将 DeepSeek 作为备用能力。
9
9
 
10
10
  ## 前置检查
11
11
 
12
12
  ```bash
13
- focalapi --version # 确认 CLI 已安装
14
- focalapi auth status # 确认 Key 有效、看额度
15
- focalapi doctor # 全链路自检(用免费演练模型,不花额度)
13
+ focalapi --version
14
+ focalapi auth status
15
+ focalapi models list --json
16
+ # 选定创作模型后,读取支持参数、端点与默认值
17
+ focalapi models get <模型ID> --json
16
18
  ```
17
19
 
18
- 任何命令失败,先跑 `focalapi doctor` 按提示修复(Key 缺失 `focalapi auth login --key <sk-...>`)。
20
+ 模型、参数、并发和价格始终以 `focalapi models list --json` 与控制台模型广场的实际结果为准。命令失败时,先执行 `focalapi doctor` 排查 Key、网络与额度。
19
21
 
20
22
  ## 能力 → 命令速查
21
23
 
22
24
  | 需求 | 命令 | 子技能 |
23
25
  |---|---|---|
24
- | 对话/总结/翻译/看图 | `focalapi chat` | focalapi-chat |
25
- | 生成图片 | `focalapi gen image` | focalapi-gen |
26
+ | 生成或编辑图像 | `focalapi gen image`(可 `--no-wait` 提交持久任务) | focalapi-gen |
27
+ | 生成 Gemini 原生图像 | `focalapi gen gemini-image` | focalapi-gen |
26
28
  | 生成视频 | `focalapi gen video`(任务制,可 `--no-wait`) | focalapi-gen |
27
- | 联网搜索 | `focalapi search` | focalapi-search |
29
+ | 查询任务或下载视频产物 | `focalapi task status` / `focalapi task download` | focalapi-gen |
28
30
  | 语音转文字 | `focalapi audio transcribe` | focalapi-chat |
29
31
  | 文字转语音 | `focalapi audio speech` | focalapi-chat |
30
- | 文本向量化 | `focalapi embed` | focalapi-chat |
31
- | 文档重排序 | `focalapi rerank` | focalapi-chat |
32
- | 额度/用量 | `focalapi usage` / `focalapi auth status` | focalapi-usage |
33
- | 诊断排障 | `focalapi doctor` | focalapi-usage |
34
- | 读取未封装端点 | `focalapi request get /v1/... --json` | focalapi |
35
-
36
- ## Agent 使用约定
37
-
38
- 1. **机器可读输出**:所有命令加 `--json`,stdout 是纯净 JSON,可直接 `jq` 解析。
39
- 2. **模型选择**:先 `focalapi models list --json` 拿当前 Key 可用模型,不要猜模型名。
40
- 3. **演练模型**:`focal-rehearsal-chat` 免费,适合做链路验证和演示。
41
- 4. **产物路径**:生成类命令默认写入 `./focalapi-out/`,把绝对路径告诉用户。
42
- 5. **非交互**:本 CLI 在非 TTY 环境全自动(无提示无动画),认证用环境变量 `FOCALAPI_API_KEY` 或已保存的配置。
43
- 6. **原始请求**:优先用语义化命令;只有读取尚未封装的端点时才用 `request get` / `request head`。它只允许站内路径和 GET/HEAD,绝不用于写入操作。
32
+ | 提示词、分镜或脚本辅助 | `focalapi chat -m <DeepSeek 模型>` | focalapi-chat |
33
+ | 额度与诊断 | `focalapi usage` / `focalapi doctor` | focalapi-usage |
34
+ | 查询可用模型 | `focalapi models list --json` | focalapi |
35
+
36
+ ## 使用约定
37
+
38
+ 1. **创作优先**:图像、视频、音频和视觉模型是默认选择。3D 或其他视觉模型是否可用,以模型列表为准。
39
+ 2. **文本边界**:仅在提示词、分镜、旁白草稿或轻量文本任务中使用 DeepSeek;不要假设其他文本、编码或聊天模型可用。
40
+ 3. **机器可读输出**:命令加 `--json` 时,stdout 只输出 JSON
41
+ 4. **先查模型再调用**:不能猜测模型 ID;先运行 `focalapi models list --json`,选定模型后运行 `focalapi models get <模型ID> --json`,以 `supported_endpoint_types` 和 `supported_params` 为准。
42
+ 5. **产物路径**:生成类命令默认写入 `./focalapi-out/`,完成后向用户报告绝对路径。
43
+ 6. **原始只读请求**:优先使用语义化命令;只有读取尚未封装的端点时才使用 `request get` / `request head`。
@@ -1,51 +1,29 @@
1
1
  ---
2
2
  name: focalapi-chat
3
- description: 用 focalapi 做对话、多模态理解(看图)、embedding rerank。Use when 需要文本生成/总结/翻译/问答、理解图片内容、文本向量化或文档重排序。
3
+ description: 用 DeepSeek 作为创作流程的文本备用能力,并通过 focalapi 处理音频转写和语音合成。Use when 用户需要提示词、分镜、脚本草稿、看图辅助、音频转写或文字转语音。
4
4
  ---
5
5
 
6
- # focalapi 对话与理解
6
+ # focalapi 文本辅助与音频
7
7
 
8
- ## 对话
8
+ ## DeepSeek 文本辅助
9
9
 
10
- ```bash
11
- # 基础(模型名先从 focalapi models list 获取;演练用免费 focal-rehearsal-chat)
12
- focalapi chat "用一句话总结 RAG" -m <model>
13
-
14
- # 管道输入(处理长文本/文件内容)
15
- cat report.md | focalapi chat -m <model> --system "你是严谨的技术编辑"
16
-
17
- # 看图(多模态)
18
- focalapi chat "描述这张图的 UI 布局" -m <多模态模型> --input @screenshot.png
19
-
20
- # 机器可读输出(Agent 串联)
21
- focalapi chat "提取关键日期" -m <model> --json
22
- ```
23
-
24
- - `--stream` / `--no-stream` 控制流式(TTY 默认流式,`--json` 默认非流式)。
25
- - `--input` 支持多张图片;txt/md/json 文件会作为文本拼入。
26
- - 默认模型可用 `FOCALAPI_MODEL` 环境变量固定。
27
-
28
- ## 向量化
10
+ DeepSeek 是 focalapi 唯一对外展示的通用文本备用能力。先查询可用模型,再用于提示词、分镜、旁白与轻量脚本任务:
29
11
 
30
12
  ```bash
31
- focalapi embed "待编码文本" -m <embedding模型> --json
32
- focalapi embed -m <model> --input @doc.txt --json
13
+ focalapi models list --json
14
+ focalapi chat "把这个产品简介写成 6 镜头分镜" -m <DeepSeek模型>
33
15
  ```
34
16
 
35
- ## 重排序
36
-
37
- ```bash
38
- focalapi rerank -m <rerank模型> --query "用户问题" --docs @docs.json --json
39
- # docs.json 是字符串数组
40
- ```
17
+ - 不要猜测模型 ID,也不要假设其他文本或编码模型可用。
18
+ - 需要结构化输出时使用 `--json`;长结果可加 `--stream`。
19
+ - `--input @image.png` 可把图片作为辅助输入;图像生成请改用 `focalapi gen image`。
41
20
 
42
21
  ## 音频
43
22
 
44
23
  ```bash
45
- focalapi audio transcribe meeting.mp3 -m <转写模型> # 语音→文字
46
- focalapi audio speech "大家好" -m <TTS模型> --voice alloy -o out.mp3
24
+ focalapi audio transcribe interview.mp3 -m <转写模型>
25
+ focalapi audio speech "这里是一段旁白" -m <语音模型> -o narration.mp3
47
26
  ```
48
27
 
49
- ## 排错
50
-
51
- 失败先 `focalapi doctor`;报 model_not_found 时 `focalapi models list --filter <关键字>` 确认模型名。
28
+ - 音频模型、可用音色和格式以 `focalapi models list --json` 为准。
29
+ - 批量任务前先用短样本确认质量和单价。
@@ -7,9 +7,29 @@ description: 用 focalapi 生成图片和视频(产物自动下载到本地)
7
7
 
8
8
  ## 图片(同步)
9
9
 
10
+ 调用创作模型前,先读取它的机器可读契约;不要猜测尺寸、时长或原生端点:
11
+
12
+ ```bash
13
+ focalapi models get <模型ID> --json
14
+ ```
15
+
16
+ 响应中的 `supported_endpoint_types` 指明应使用的 API 协议,`supported_params` 列出参数、默认值、枚举和边界。CLI 会提前拒绝已知无效参数;应修正参数,不要将同一参数重试给上游。
17
+
10
18
  ```bash
11
19
  focalapi gen image "未来城市夜景海报,赛博朋克风" -m <图像模型> --size 1024x1024 -o ./out
12
20
  # 多张:--n 4(上限 128)
21
+ # 参考图/编辑:image 可重复给 URL;response-format 只接受 url 或 b64_json
22
+ focalapi gen image "将背景改为雨夜" -m gpt-image-2 --image https://example.com/source.png \
23
+ --mask https://example.com/mask.png --response-format b64_json -o ./out
24
+ # 长任务编排:--no-wait 要求服务端持久化任务,返回 task_id;图像结果从 task status 的 raw.data 读取 URL
25
+ focalapi gen image "未来城市夜景海报,赛博朋克风" -m gpt-image-2 --size 1024x1024 --no-wait --json
26
+ focalapi task status <task_id> --json
27
+
28
+ # Gemini 图像模型必须走原生 Gemini generateContent 端点,不能使用 gen image
29
+ focalapi gen gemini-image "未来城市夜景海报,赛博朋克风" -m gemini-3.1-flash-image-preview \
30
+ --aspect-ratio 16:9 --image-size 2K -o ./out
31
+ # Gemini named flags cover --image, --system, --seed, --thinking-level, --temperature, and --top-p.
32
+ # For another documented generationConfig field, use --config; the command keeps responseFormat.image and candidateCount=1 authoritative.
13
33
  ```
14
34
 
15
35
  产物保存到 `-o` 目录(默认 `./focalapi-out/`),stdout/stderr 会打印绝对路径,把路径直接交给用户或后续步骤。
@@ -20,6 +40,20 @@ focalapi gen image "未来城市夜景海报,赛博朋克风" -m <图像模型
20
40
  # 方式一:前台等待完成并自动下载(默认)
21
41
  focalapi gen video "海浪拍打礁石,电影感" -m <视频模型> --seconds 5 -o ./out
22
42
 
43
+ # Seedance 2.0:使用模型原生参数。Fast/Mini 只支持 480p 和 720p;所有 Seedance 2.0 时长为 4–15 秒。
44
+ focalapi gen video "海浪拍打礁石,电影感" -m doubao-seedance-2-0-260128 \
45
+ --seconds 5 --resolution 720p --ratio 16:9 --generate-audio true --no-wait --json
46
+
47
+ # 图生视频与 Seedance 的任务控制参数会映射到请求 metadata;布尔参数必须显式为 true 或 false
48
+ focalapi gen video "让海浪缓慢推进" -m doubao-seedance-2-0-260128 \
49
+ --image https://example.com/frame.png --generate-audio false --watermark true \
50
+ --return-last-frame true --callback-url https://example.com/callback \
51
+ --execution-expires-after 7200 --safety-identifier customer-42 --priority 4 --no-wait --json
52
+
53
+ # Ark-compatible metadata.content keeps native text/image_url/video_url/audio_url items and roles unchanged:
54
+ focalapi gen video "ignored when content is supplied" -m doubao-seedance-2-0-260128 \
55
+ --content '[{"type":"text","text":"A cinematic ocean wave."}]' --no-wait --json
56
+
23
57
  # 方式二:异步——Agent 做长任务编排时推荐
24
58
  focalapi gen video "..." -m <视频模型> --no-wait --json # 立即拿 task_id
25
59
  focalapi task status <task_id> --json # 轮询状态
@@ -33,6 +67,8 @@ focalapi task download <task_id> -o ./out # 完成后取 mp4
33
67
  ## JSON 输出约定(供 Agent 解析)
34
68
 
35
69
  - `gen image --json` → `{"files": ["..."], "count": N}`
70
+ - `gen image --no-wait --json` → `{"task_id": "...", "status": "queued", "submitted": true}`
71
+ - `gen gemini-image --json` → `{"files": ["..."], "count": N}`
36
72
  - `gen video --no-wait --json` → `{"task_id": "...", "submitted": true}`
37
73
  - `gen video --json`(等待模式)→ `{"task_id": "...", "status": "success", "file": "..."}`
38
74
  - `task status --json` → `{"task_id","status":"pending|running|success|failed|unknown","progress","raw"}`
@@ -1,31 +0,0 @@
1
- ---
2
- name: focalapi-search
3
- description: 用 focalapi 联网搜索。Use when 需要实时信息、新闻、资料检索、事实核查,或用户明确要求"搜一下/查一下"。
4
- ---
5
-
6
- # focalapi 联网搜索
7
-
8
- ```bash
9
- focalapi search "过去7天 AI 行业重要新闻" -m <搜索模型> --json
10
- ```
11
-
12
- - 端点为 `/v1/alpha/search`(alpha 级,响应结构由上游定义);`--json` 原样透传,pretty 模式尽力解析为结果表格。
13
- - 搜索模型名先确认:`focalapi models list --filter search`。
14
-
15
- ## 自定义请求体(逃生门)
16
-
17
- 上游格式变更或需要高级参数时,用 `--raw` 直接控制请求体:
18
-
19
- ```bash
20
- focalapi search "查询" -m <model> --raw '{"query":"...","search_depth":"advanced"}' --json
21
- focalapi search x -m <model> --raw @body.json
22
- ```
23
-
24
- ## 典型编排
25
-
26
- 搜索 → 把 `--json` 结果交给 `focalapi chat` 做摘要/分析:
27
-
28
- ```bash
29
- focalapi search "主题" -m <搜索模型> --json > results.json
30
- cat results.json | focalapi chat -m <对话模型> --system "把输入的搜索结果整理成 5 条要点"
31
- ```