@lingjingai/scriptctl 0.8.2 → 0.9.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.
@@ -1,8 +1,7 @@
1
- import { createGoogleGenerativeAI } from "@ai-sdk/google";
2
- import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
3
- import { APICallError, NoObjectGeneratedError, RetryError, extractJsonMiddleware, generateObject, generateText, wrapLanguageModel, } from "ai";
4
- import { z } from "zod";
5
- import { CliError, DEFAULT_BATCH_MAX_TOKENS, DEFAULT_MAX_TOKENS, EXIT_RUNTIME, EXIT_USAGE, MARKDOWN_BATCH_PROMPT_SPEC, ROLE_TYPE_VALUES, WORLDVIEW_VALUES, } from "../common.js";
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import { createDeepSeek } from "@ai-sdk/deepseek";
3
+ import { APICallError, NoObjectGeneratedError, generateObject, generateText, jsonSchema, } from "ai";
4
+ import { ASSET_CURATION_SCHEMA, CliError, DEFAULT_BATCH_MAX_TOKENS, DEFAULT_MAX_TOKENS, DEFAULT_THINKING_BUDGET_TOKENS, EPISODE_TITLE_EXTRACTION_SCHEMA, EXIT_RUNTIME, EXIT_USAGE, MARKDOWN_BATCH_PROMPT_SPEC, METADATA_EXTRACTION_SCHEMA, NONSTREAMING_MAX_TOKENS, WORLDVIEW_VALUES, } from "../common.js";
6
5
  import { _md_push_asset, _normalize_speaker_list, buildAssetCurationContext, buildEpisodeTitleContext, buildMetadataContext, deterministicEpisodeShortTitle, deterministicExtractEpisode, deterministicExtractMetadata, episodesNeedingGeneratedTitles, formatBatchSource, parseMarkdownBatch, } from "../domain/direct-core.js";
7
6
  function strOf(v) {
8
7
  if (v === null || v === undefined)
@@ -16,43 +15,6 @@ function asList(v) {
16
15
  return Array.isArray(v) ? v : [];
17
16
  }
18
17
  // ---------------------------------------------------------------------------
19
- // Zod schemas for structured extraction (LLM JSON-mode output)
20
- // ---------------------------------------------------------------------------
21
- const ROLE_TYPE_TUPLE = ROLE_TYPE_VALUES;
22
- const WORLDVIEW_TUPLE = WORLDVIEW_VALUES;
23
- const METADATA_SCHEMA = z.object({
24
- confidence: z.enum(["high", "medium", "low"]),
25
- worldview: z.enum(WORLDVIEW_TUPLE),
26
- worldview_raw: z.string(),
27
- actors: z.array(z.object({
28
- actor_id: z.string(),
29
- role_type: z.enum(ROLE_TYPE_TUPLE),
30
- description: z.string(),
31
- })),
32
- locations: z.array(z.object({
33
- location_id: z.string(),
34
- description: z.string(),
35
- })),
36
- props: z.array(z.object({
37
- prop_id: z.string(),
38
- description: z.string(),
39
- })),
40
- });
41
- const ASSET_CURATION_SCHEMA = z.object({
42
- locations: z.array(z.object({
43
- location_id: z.string(),
44
- decision: z.enum(["keep", "merge"]),
45
- target_location_id: z.string().nullable(),
46
- reason: z.string(),
47
- })),
48
- });
49
- const EPISODE_TITLE_SCHEMA = z.object({
50
- episode_titles: z.array(z.object({
51
- episode: z.number().int(),
52
- title: z.string(),
53
- })),
54
- });
55
- // ---------------------------------------------------------------------------
56
18
  // Mock provider
57
19
  // ---------------------------------------------------------------------------
58
20
  export class MockProvider {
@@ -138,23 +100,84 @@ export class MockProvider {
138
100
  }
139
101
  }
140
102
  // ---------------------------------------------------------------------------
141
- // LiteLLM provider — talks to PROJECT_LITELLM_GATEWAY via OpenAI-compatible
142
- // /v1/chat/completions. Backs every Claude / DeepSeek / etc. model fronted by
143
- // the gateway. The protocol switch from /v1/messages (Anthropic) to
144
- // /v1/chat/completions is intentional: it lets gateway-routed models that lack
145
- // Anthropic tool_use support (e.g. deepseek-v4-pro-packy) reuse the same
146
- // structured-output path via JSON-mode schemas.
103
+ // Shared prompt builders
104
+ // ---------------------------------------------------------------------------
147
105
  //
148
- // `name = "anthropic"` is preserved (not renamed to "litellm") so previously
149
- // written checkpoint metadata (`{"provider": "anthropic"}`) keeps validating
150
- // after upgrade no forced re-extraction.
106
+ // Same prompt text is used by every real provider (Anthropic, DeepSeek). Keep
107
+ // these as the single source of truth; if a provider needs a tweak, fork the
108
+ // helper rather than inlining a copy that will drift.
109
+ export function buildBatchExtractPrompt(sourceText, batchPlan) {
110
+ const context = isList(batchPlan["context"]) || (typeof batchPlan["context"] === "object" && batchPlan["context"] !== null)
111
+ ? batchPlan["context"]
112
+ : {};
113
+ const numberedSource = formatBatchSource(sourceText, batchPlan);
114
+ return ("You convert one batch from an existing script into the documented markdown template.\n" +
115
+ "\n" +
116
+ `${MARKDOWN_BATCH_PROMPT_SPEC}\n` +
117
+ "\n" +
118
+ "Batch plan:\n" +
119
+ `${JSON.stringify(batchPlan)}\n` +
120
+ "\n" +
121
+ "Read-only context:\n" +
122
+ `${JSON.stringify(context)}\n` +
123
+ "\n" +
124
+ "Batch Source:\n" +
125
+ `${numberedSource}\n`);
126
+ }
127
+ export function buildEpisodeTitlesPrompt(context) {
128
+ return ("You generate missing episode titles for a direct script conversion.\n" +
129
+ "Use only each episode excerpt below. Do not invent plot events outside it.\n" +
130
+ "Return JSON matching the configured schema.\n" +
131
+ "\n" +
132
+ "Requirements:\n" +
133
+ "- Return one item for every input episode.\n" +
134
+ "- The title field must be a short Chinese episode title, 2-8 Chinese characters when possible.\n" +
135
+ "- Do not include episode numbers, `ep_###`, `Episode ###`, punctuation wrappers, or quotes.\n" +
136
+ "- Prefer concrete plot hooks, conflicts, reversals, or memorable images from the episode.\n" +
137
+ "\n" +
138
+ "Episodes needing titles:\n" +
139
+ `${JSON.stringify(context)}\n`);
140
+ }
141
+ export function buildAssetCurationPrompt(context) {
142
+ return ("You decide which extracted locations should be merged into stable parent locations.\n" +
143
+ "Use only the structured script context below. Do not decide from names alone.\n" +
144
+ "Return JSON matching the configured schema.\n" +
145
+ "\n" +
146
+ "Requirements:\n" +
147
+ "- This task only handles location merge decisions. Actors and props are pruned by deterministic scene-count rules outside this provider step.\n" +
148
+ "- For locations, choose merge only when the location is clearly the same stable space as another existing location_id.\n" +
149
+ "- Keep a location whenever its parent location is uncertain, it may need distinct visual continuity, or no stable parent exists.\n" +
150
+ "- target_location_id must be an existing location_id when decision is merge; use null for keep.\n" +
151
+ "- Reasons must cite evidence from the provided examples/usage, not a keyword rule.\n" +
152
+ "- Do not rewrite actions or metadata; this task only decides asset curation.\n" +
153
+ "\n" +
154
+ "Script asset curation context:\n" +
155
+ `${JSON.stringify(context)}\n`);
156
+ }
157
+ export function buildMetadataPrompt(context) {
158
+ return ("You produce global metadata for a direct script conversion.\n" +
159
+ "Use only the structured script context below. Do not invent plot events outside it.\n" +
160
+ "Return JSON matching the configured schema.\n" +
161
+ "\n" +
162
+ "Requirements:\n" +
163
+ `- worldview must be exactly one of: ${WORLDVIEW_VALUES.join(", ")}\n` +
164
+ "- confidence is high/medium/low; use low only when the script context is insufficient.\n" +
165
+ "- Every actor_id listed in the input must appear once with role_type 主角 or 配角 and a non-empty description.\n" +
166
+ "- Every location_id and prop_id listed in the input must appear once with a non-empty description.\n" +
167
+ "- Descriptions should be concise, useful for downstream asset generation, and grounded in names/states/examples.\n" +
168
+ "- Do not rewrite action/dialogue source content; this task only writes metadata fields.\n" +
169
+ "\n" +
170
+ "Script metadata context:\n" +
171
+ `${JSON.stringify(context)}\n`);
172
+ }
173
+ // ---------------------------------------------------------------------------
174
+ // Anthropic provider
151
175
  // ---------------------------------------------------------------------------
152
- export class LiteLLMProvider {
176
+ export class AnthropicProvider {
153
177
  name = "anthropic";
178
+ client;
154
179
  model;
155
- modelId;
156
- configuredMaxTokens;
157
- constructor(modelId) {
180
+ constructor(model) {
158
181
  const apiKey = (process.env.PROJECT_LITELLM_GATEWAY_API_KEY ?? "").trim();
159
182
  if (!apiKey) {
160
183
  throw new CliError("INIT FAILED: provider not configured", "Provider credentials are not configured for this environment.", {
@@ -165,78 +188,83 @@ export class LiteLLMProvider {
165
188
  errorCode: "PROVIDER_AUTH_MISSING",
166
189
  });
167
190
  }
168
- const baseURL = (process.env.PROJECT_LITELLM_GATEWAY_BASE_URL ?? "").trim();
169
- if (!baseURL) {
170
- throw new CliError("INIT FAILED: provider not configured", "Provider gateway URL is not configured for this environment.", {
171
- exitCode: EXIT_RUNTIME,
172
- required: ["PROJECT_LITELLM_GATEWAY_BASE_URL"],
173
- received: ["no gateway base URL environment variable"],
174
- nextSteps: ["Run `scriptctl doctor` to identify missing configuration."],
175
- errorCode: "PROVIDER_AUTH_MISSING",
176
- });
177
- }
178
- // supportsStructuredOutputs: true makes ai-sdk send the full JSON schema
179
- // through `response_format: { type: "json_schema", json_schema: {...} }`
180
- // instead of dropping the schema and downgrading to `{type:"json_object"}`.
181
- // Without this flag, LiteLLM downstream has no schema to forward to Claude
182
- // and the model has to guess the top-level shape from the prompt — that's
183
- // why it returned a bare array instead of { episode_titles: [...] }.
184
- //
185
- // LiteLLM must accept OpenAI-style json_schema response_format and
186
- // translate it for the underlying provider. If a particular gateway model
187
- // can't translate (e.g. some packy models), requests will 400; in that
188
- // case fall back to overriding model id rather than disabling this flag.
189
- const provider = createOpenAICompatible({
190
- name: "litellm",
191
- apiKey,
192
- baseURL,
193
- supportsStructuredOutputs: true,
194
- });
195
- this.modelId = modelId;
196
- // Even with structured outputs forwarded, some Claude-family models
197
- // continue to wrap output in ```json ... ``` fences. extractJsonMiddleware
198
- // strips the fence before ai-sdk's JSON.parse runs.
199
- this.model = wrapLanguageModel({
200
- model: provider(modelId),
201
- middleware: extractJsonMiddleware(),
202
- });
203
- const raw = (process.env.SCRIPTCTL_MAX_TOKENS ?? "").trim();
204
- let mt = DEFAULT_MAX_TOKENS;
205
- if (raw) {
206
- const parsed = parseInt(raw, 10);
191
+ const baseUrl = (process.env.PROJECT_LITELLM_GATEWAY_BASE_URL ?? "").trim();
192
+ const opts = { apiKey };
193
+ if (baseUrl)
194
+ opts.baseURL = baseUrl;
195
+ this.client = new Anthropic(opts);
196
+ this.model = model;
197
+ }
198
+ messageRequest(prompt, maxTokens) {
199
+ const rawMaxTokens = (process.env.SCRIPTCTL_MAX_TOKENS ?? "").trim();
200
+ let configuredMaxTokens = DEFAULT_MAX_TOKENS;
201
+ if (rawMaxTokens) {
202
+ const parsed = parseInt(rawMaxTokens, 10);
207
203
  if (!Number.isNaN(parsed))
208
- mt = parsed;
204
+ configuredMaxTokens = parsed;
209
205
  }
210
- this.configuredMaxTokens = Math.max(1024, Math.min(mt, DEFAULT_MAX_TOKENS));
211
- }
212
- capTokens(maxTokens, fallback) {
213
- const requested = maxTokens !== undefined ? maxTokens : fallback;
214
- return Math.max(1024, Math.min(requested, this.configuredMaxTokens, DEFAULT_MAX_TOKENS));
206
+ let tokenBudget = maxTokens !== undefined ? maxTokens : configuredMaxTokens;
207
+ tokenBudget = Math.max(1024, Math.min(tokenBudget, configuredMaxTokens, DEFAULT_MAX_TOKENS));
208
+ const request = {
209
+ model: this.model,
210
+ max_tokens: tokenBudget,
211
+ messages: [{ role: "user", content: prompt }],
212
+ };
213
+ if (this.model.endsWith("-think")) {
214
+ const rawBudget = (process.env.SCRIPTCTL_THINKING_BUDGET_TOKENS ?? "").trim();
215
+ let budget = DEFAULT_THINKING_BUDGET_TOKENS;
216
+ if (rawBudget) {
217
+ const parsed = parseInt(rawBudget, 10);
218
+ if (!Number.isNaN(parsed))
219
+ budget = parsed;
220
+ }
221
+ request.thinking = { type: "enabled", budget_tokens: Math.max(1024, Math.min(budget, tokenBudget - 1000)) };
222
+ }
223
+ return request;
215
224
  }
216
- async complete(prompt, maxTokens) {
217
- const max = this.capTokens(maxTokens, DEFAULT_BATCH_MAX_TOKENS);
218
- let raw;
219
- let finishReason;
220
- try {
221
- const result = await generateText({
222
- model: this.model,
223
- prompt,
224
- maxOutputTokens: max,
225
- maxRetries: 0,
226
- });
227
- raw = result.text.trim();
228
- finishReason = strOf(result.finishReason);
225
+ async collectResponseText(request) {
226
+ if (request.max_tokens > NONSTREAMING_MAX_TOKENS) {
227
+ const stream = await this.client.messages.stream(request);
228
+ const text = await stream.finalText();
229
+ const message = await stream.finalMessage();
230
+ return [text.trim(), strOf(message.stop_reason)];
229
231
  }
230
- catch (exc) {
231
- throw translateLiteLLMError(exc, "DRAFT FAILED");
232
+ const response = await this.client.messages.create(request);
233
+ return [textFromResponse(response), strOf(response.stop_reason)];
234
+ }
235
+ /**
236
+ * Run a request that forces a specific tool call via `tool_choice`, then return that
237
+ * tool's `input` dict (already parsed by the SDK — no JSON.parse needed). Returns
238
+ * `null` when the provider didn't emit the expected tool_use block (e.g. refusal,
239
+ * truncated before tool call, gateway dropped tools field), letting the caller
240
+ * raise a method-specific CliError.
241
+ */
242
+ async collectToolUseInput(request, toolName) {
243
+ const maxTokens = Number(request["max_tokens"] ?? 0);
244
+ if (maxTokens > NONSTREAMING_MAX_TOKENS) {
245
+ const stream = await this.client.messages.stream(request);
246
+ const message = (await stream.finalMessage());
247
+ return [findToolUseInput(message.content ?? [], toolName), strOf(message.stop_reason)];
232
248
  }
233
- if (finishReason === "length") {
249
+ const response = (await this.client.messages.create(request));
250
+ return [findToolUseInput(response.content ?? [], toolName), strOf(response.stop_reason)];
251
+ }
252
+ /**
253
+ * Generic completion entry point. Used by episode subcommand's gemini-writer to draft
254
+ * one episode's spec markdown from the assembled prompt. Throws CliError when the
255
+ * provider truncates output (so the caller can surface a deterministic error rather
256
+ * than committing a half-baked episode).
257
+ */
258
+ async complete(prompt, maxTokens) {
259
+ const request = this.messageRequest(prompt, maxTokens ?? DEFAULT_BATCH_MAX_TOKENS);
260
+ const [raw, stopReason] = await this.collectResponseText(request);
261
+ if (stopReason === "max_tokens") {
234
262
  throw new CliError("DRAFT FAILED: Provider output truncated", "Provider output truncated.", {
235
263
  exitCode: EXIT_RUNTIME,
236
264
  required: ["complete markdown within provider max_tokens"],
237
265
  received: [
238
- `finishReason: ${finishReason}`,
239
- `max_tokens: ${max}`,
266
+ `stop_reason: ${stopReason}`,
267
+ `max_tokens: ${request.max_tokens}`,
240
268
  `raw chars: ${raw.length}`,
241
269
  `tail: ${raw.slice(-160) || "<empty response>"}`,
242
270
  ],
@@ -252,45 +280,16 @@ export class LiteLLMProvider {
252
280
  return this.extractBatch(sourceText, episodePlan);
253
281
  }
254
282
  async extractBatch(sourceText, batchPlan) {
255
- const context = isList(batchPlan["context"]) || (typeof batchPlan["context"] === "object" && batchPlan["context"] !== null)
256
- ? batchPlan["context"]
257
- : {};
258
- const numberedSource = formatBatchSource(sourceText, batchPlan);
259
- const prompt = "You convert one batch from an existing script into the documented markdown template.\n" +
260
- "\n" +
261
- `${MARKDOWN_BATCH_PROMPT_SPEC}\n` +
262
- "\n" +
263
- "Batch plan:\n" +
264
- `${JSON.stringify(batchPlan)}\n` +
265
- "\n" +
266
- "Read-only context:\n" +
267
- `${JSON.stringify(context)}\n` +
268
- "\n" +
269
- "Batch Source:\n" +
270
- `${numberedSource}\n`;
271
- const max = this.capTokens(DEFAULT_BATCH_MAX_TOKENS, DEFAULT_BATCH_MAX_TOKENS);
272
- let raw;
273
- let finishReason;
274
- try {
275
- const result = await generateText({
276
- model: this.model,
277
- prompt,
278
- maxOutputTokens: max,
279
- maxRetries: 0,
280
- });
281
- raw = result.text.trim();
282
- finishReason = strOf(result.finishReason);
283
- }
284
- catch (exc) {
285
- throw translateLiteLLMError(exc, "INIT FAILED");
286
- }
287
- if (finishReason === "length") {
283
+ const prompt = buildBatchExtractPrompt(sourceText, batchPlan);
284
+ const request = this.messageRequest(prompt, DEFAULT_BATCH_MAX_TOKENS);
285
+ const [raw, stopReason] = await this.collectResponseText(request);
286
+ if (stopReason === "max_tokens") {
288
287
  throw new CliError("INIT FAILED: Provider output truncated", "Provider output truncated.", {
289
288
  exitCode: EXIT_RUNTIME,
290
289
  required: ["complete markdown within provider max_tokens"],
291
290
  received: [
292
- `finishReason: ${finishReason}`,
293
- `max_tokens: ${max}`,
291
+ `stop_reason: ${stopReason}`,
292
+ `max_tokens: ${request.max_tokens}`,
294
293
  `raw chars: ${raw.length}`,
295
294
  `tail: ${raw.slice(-160) || "<empty response>"}`,
296
295
  ],
@@ -306,140 +305,117 @@ export class LiteLLMProvider {
306
305
  const context = buildEpisodeTitleContext(sourceText, episodePlan);
307
306
  if (context.length === 0)
308
307
  return { episode_titles: [] };
309
- const prompt = "You generate missing episode titles for a direct script conversion.\n" +
310
- "Use only each episode excerpt below. Do not invent plot events outside it.\n" +
311
- "Return JSON matching the configured schema.\n" +
312
- "\n" +
313
- "Requirements:\n" +
314
- "- Return one item for every input episode.\n" +
315
- "- The title field must be a short Chinese episode title, 2-8 Chinese characters when possible.\n" +
316
- "- Do not include episode numbers, `ep_###`, `Episode ###`, punctuation wrappers, or quotes.\n" +
317
- "- Prefer concrete plot hooks, conflicts, reversals, or memorable images from the episode.\n" +
318
- "\n" +
319
- "Episodes needing titles:\n" +
320
- `${JSON.stringify(context)}\n`;
321
- return this.extractStructured({
322
- prompt,
323
- maxTokens: 4096,
324
- schema: EPISODE_TITLE_SCHEMA,
325
- title: "INIT FAILED: Episode title output truncated",
326
- truncationMessage: "Episode title output truncated.",
327
- required: ["complete episode title JSON within provider max_tokens"],
328
- invalidTitle: "INIT FAILED: Provider returned invalid episode title JSON",
329
- invalidMessage: "Provider returned invalid episode title JSON.",
330
- invalidRequired: ["valid JSON matching the configured schema"],
331
- truncationNext: ["Rerun init; if this repeats, split the source or provide explicit episode titles."],
332
- invalidNext: ["Rerun init; title generation will retry before batch extraction."],
333
- });
308
+ const prompt = buildEpisodeTitlesPrompt(context);
309
+ const toolName = "submit_episode_titles";
310
+ const request = { ...this.messageRequest(prompt, 4096) };
311
+ request["tools"] = [{
312
+ name: toolName,
313
+ description: "Return generated short Chinese titles for every input episode.",
314
+ input_schema: EPISODE_TITLE_EXTRACTION_SCHEMA,
315
+ }];
316
+ request["tool_choice"] = { type: "tool", name: toolName };
317
+ const [input, stopReason] = await this.collectToolUseInput(request, toolName);
318
+ if (stopReason === "max_tokens") {
319
+ throw new CliError("INIT FAILED: Episode title output truncated", "Episode title output truncated.", {
320
+ exitCode: EXIT_RUNTIME,
321
+ required: ["complete episode title JSON within provider max_tokens"],
322
+ received: [`stop_reason: ${stopReason}`, `max_tokens: ${request["max_tokens"]}`],
323
+ nextSteps: ["Rerun init; if this repeats, split the source or provide explicit episode titles."],
324
+ });
325
+ }
326
+ if (!input) {
327
+ throw new CliError("INIT FAILED: Provider returned invalid episode title JSON", "Provider returned invalid episode title JSON.", {
328
+ exitCode: EXIT_RUNTIME,
329
+ required: [`tool_use block with name "${toolName}"`],
330
+ received: [`stop_reason: ${stopReason || "unknown"}`, `missing tool_use block: ${toolName}`],
331
+ nextSteps: ["Rerun init; title generation will retry before batch extraction."],
332
+ });
333
+ }
334
+ return input;
334
335
  }
335
336
  async extractAssetCuration(_sourceText, script) {
336
337
  const context = buildAssetCurationContext(script);
337
- const prompt = "You decide which extracted locations should be merged into stable parent locations.\n" +
338
- "Use only the structured script context below. Do not decide from names alone.\n" +
339
- "Return JSON matching the configured schema.\n" +
340
- "\n" +
341
- "Requirements:\n" +
342
- "- This task only handles location merge decisions. Actors and props are pruned by deterministic scene-count rules outside this provider step.\n" +
343
- "- For locations, choose merge only when the location is clearly the same stable space as another existing location_id.\n" +
344
- "- Keep a location whenever its parent location is uncertain, it may need distinct visual continuity, or no stable parent exists.\n" +
345
- "- target_location_id must be an existing location_id when decision is merge; use null for keep.\n" +
346
- "- Reasons must cite evidence from the provided examples/usage, not a keyword rule.\n" +
347
- "- Do not rewrite actions or metadata; this task only decides asset curation.\n" +
348
- "\n" +
349
- "Script asset curation context:\n" +
350
- `${JSON.stringify(context)}\n`;
351
- return this.extractStructured({
352
- prompt,
353
- maxTokens: DEFAULT_MAX_TOKENS,
354
- schema: ASSET_CURATION_SCHEMA,
355
- title: "INIT FAILED: Asset curation output truncated",
356
- truncationMessage: "Asset curation output truncated.",
357
- required: ["complete asset curation JSON within provider max_tokens"],
358
- invalidTitle: "INIT FAILED: Provider returned invalid asset curation JSON",
359
- invalidMessage: "Provider returned invalid asset curation JSON.",
360
- invalidRequired: ["valid JSON matching the configured schema"],
361
- truncationNext: ["Rerun init; extraction checkpoints will be reused and asset curation will retry."],
362
- invalidNext: ["Rerun init; extraction checkpoints will be reused and asset curation will retry."],
363
- });
338
+ const prompt = buildAssetCurationPrompt(context);
339
+ const toolName = "submit_asset_curation";
340
+ const request = { ...this.messageRequest(prompt, DEFAULT_MAX_TOKENS) };
341
+ request["tools"] = [{
342
+ name: toolName,
343
+ description: "Return location merge decisions for the provided script.",
344
+ input_schema: ASSET_CURATION_SCHEMA,
345
+ }];
346
+ request["tool_choice"] = { type: "tool", name: toolName };
347
+ const [input, stopReason] = await this.collectToolUseInput(request, toolName);
348
+ if (stopReason === "max_tokens") {
349
+ throw new CliError("INIT FAILED: Asset curation output truncated", "Asset curation output truncated.", {
350
+ exitCode: EXIT_RUNTIME,
351
+ required: ["complete asset curation JSON within provider max_tokens"],
352
+ received: [`stop_reason: ${stopReason}`, `max_tokens: ${request["max_tokens"]}`],
353
+ nextSteps: ["Rerun init; extraction checkpoints will be reused and asset curation will retry."],
354
+ });
355
+ }
356
+ if (!input) {
357
+ throw new CliError("INIT FAILED: Provider returned invalid asset curation JSON", "Provider returned invalid asset curation JSON.", {
358
+ exitCode: EXIT_RUNTIME,
359
+ required: [`tool_use block with name "${toolName}"`],
360
+ received: [`stop_reason: ${stopReason || "unknown"}`, `missing tool_use block: ${toolName}`],
361
+ nextSteps: ["Rerun init; extraction checkpoints will be reused and asset curation will retry."],
362
+ });
363
+ }
364
+ return input;
364
365
  }
365
366
  async extractMetadata(_sourceText, script) {
366
367
  const context = buildMetadataContext(script);
367
- const prompt = "You produce global metadata for a direct script conversion.\n" +
368
- "Use only the structured script context below. Do not invent plot events outside it.\n" +
369
- "Return JSON matching the configured schema.\n" +
370
- "\n" +
371
- "Requirements:\n" +
372
- `- worldview must be exactly one of: ${WORLDVIEW_VALUES.join(", ")}\n` +
373
- "- confidence is high/medium/low; use low only when the script context is insufficient.\n" +
374
- "- Every actor_id listed in the input must appear once with role_type 主角 or 配角 and a non-empty description.\n" +
375
- "- Every location_id and prop_id listed in the input must appear once with a non-empty description.\n" +
376
- "- Descriptions should be concise, useful for downstream asset generation, and grounded in names/states/examples.\n" +
377
- "- Do not rewrite action/dialogue source content; this task only writes metadata fields.\n" +
378
- "\n" +
379
- "Script metadata context:\n" +
380
- `${JSON.stringify(context)}\n`;
381
- return this.extractStructured({
382
- prompt,
383
- maxTokens: DEFAULT_MAX_TOKENS,
384
- schema: METADATA_SCHEMA,
385
- title: "INIT FAILED: Metadata output truncated",
386
- truncationMessage: "Metadata output truncated.",
387
- required: ["complete metadata JSON within provider max_tokens"],
388
- invalidTitle: "INIT FAILED: Provider returned invalid metadata JSON",
389
- invalidMessage: "Provider returned invalid metadata JSON.",
390
- invalidRequired: ["valid JSON matching the configured schema"],
391
- truncationNext: ["Rerun init; extraction checkpoints will be reused and metadata will retry."],
392
- invalidNext: ["Rerun init; extraction checkpoints will be reused and metadata will retry."],
393
- });
394
- }
395
- async extractStructured(args) {
396
- const max = this.capTokens(args.maxTokens, args.maxTokens);
397
- try {
398
- const result = await generateObject({
399
- model: this.model,
400
- prompt: args.prompt,
401
- schema: args.schema,
402
- maxOutputTokens: max,
403
- maxRetries: 0,
368
+ const prompt = buildMetadataPrompt(context);
369
+ const toolName = "submit_script_metadata";
370
+ const request = { ...this.messageRequest(prompt, DEFAULT_MAX_TOKENS) };
371
+ request["tools"] = [{
372
+ name: toolName,
373
+ description: "Return global script metadata (worldview, actors, locations, props).",
374
+ input_schema: METADATA_EXTRACTION_SCHEMA,
375
+ }];
376
+ request["tool_choice"] = { type: "tool", name: toolName };
377
+ const [input, stopReason] = await this.collectToolUseInput(request, toolName);
378
+ if (stopReason === "max_tokens") {
379
+ throw new CliError("INIT FAILED: Metadata output truncated", "Metadata output truncated.", {
380
+ exitCode: EXIT_RUNTIME,
381
+ required: ["complete metadata JSON within provider max_tokens"],
382
+ received: [`stop_reason: ${stopReason}`, `max_tokens: ${request["max_tokens"]}`],
383
+ nextSteps: ["Rerun init; extraction checkpoints will be reused and metadata will retry."],
404
384
  });
405
- const finishReason = strOf(result.finishReason);
406
- if (finishReason === "length") {
407
- throw new CliError(args.title, args.truncationMessage, {
408
- exitCode: EXIT_RUNTIME,
409
- required: args.required,
410
- received: [`finishReason: ${finishReason}`, `max_tokens: ${max}`],
411
- nextSteps: args.truncationNext,
412
- });
413
- }
414
- return result.object;
415
385
  }
416
- catch (exc) {
417
- if (exc instanceof CliError)
418
- throw exc;
419
- if (NoObjectGeneratedError.isInstance(exc)) {
420
- const cause = exc.cause;
421
- throw new CliError(args.invalidTitle, args.invalidMessage, {
422
- exitCode: EXIT_RUNTIME,
423
- required: args.invalidRequired,
424
- received: [
425
- `error: ${exc.message?.slice(0, 160) || "no object generated"}`,
426
- ...(cause?.message ? [`cause: ${cause.message.slice(0, 160)}`] : []),
427
- ],
428
- nextSteps: args.invalidNext,
429
- });
430
- }
431
- throw translateLiteLLMError(exc, "INIT FAILED");
386
+ if (!input) {
387
+ throw new CliError("INIT FAILED: Provider returned invalid metadata JSON", "Provider returned invalid metadata JSON.", {
388
+ exitCode: EXIT_RUNTIME,
389
+ required: [`tool_use block with name "${toolName}"`],
390
+ received: [`stop_reason: ${stopReason || "unknown"}`, `missing tool_use block: ${toolName}`],
391
+ nextSteps: ["Rerun init; extraction checkpoints will be reused and metadata will retry."],
392
+ });
432
393
  }
394
+ return input;
433
395
  }
434
396
  }
435
397
  // ---------------------------------------------------------------------------
436
- // Gemini provider @ai-sdk/google direct to Google AI Studio.
437
- // Used by `scriptctl episode draft` when --provider gemini.
398
+ // Gemini provider (used by `scriptctl episode draft` by default)
438
399
  // ---------------------------------------------------------------------------
400
+ /**
401
+ * Google Gemini provider via REST API. No SDK dependency (uses fetch).
402
+ *
403
+ * Used as the default writer for `scriptctl episode draft`. The Anthropic provider
404
+ * stays available for other internal extraction tasks (metadata / title generation /
405
+ * direct-init batch extraction) where Claude tends to outperform Gemini on JSON
406
+ * schema adherence.
407
+ *
408
+ * Env:
409
+ * - `SCRIPTCTL_GEMINI_API_KEY` (or fallback `GEMINI_API_KEY`) required
410
+ * - `SCRIPTCTL_GEMINI_BASE_URL` optional override
411
+ * - `SCRIPTCTL_GEMINI_MODEL` optional default-model override
412
+ */
439
413
  export class GeminiProvider {
440
414
  name = "gemini";
415
+ apiKey;
416
+ baseUrl;
441
417
  model;
442
- constructor(modelId) {
418
+ constructor(model) {
443
419
  const apiKey = (process.env.SCRIPTCTL_GEMINI_API_KEY ?? process.env.GEMINI_API_KEY ?? "").trim();
444
420
  if (!apiKey) {
445
421
  throw new CliError("DRAFT FAILED: provider not configured", "Provider credentials are not configured for this environment.", {
@@ -450,128 +426,315 @@ export class GeminiProvider {
450
426
  errorCode: "PROVIDER_AUTH_MISSING",
451
427
  });
452
428
  }
453
- const baseURL = (process.env.SCRIPTCTL_GEMINI_BASE_URL ?? "").trim();
454
- const opts = { apiKey };
455
- if (baseURL)
456
- opts.baseURL = baseURL.replace(/\/+$/, "");
457
- const provider = createGoogleGenerativeAI(opts);
458
- const id = (modelId || process.env.SCRIPTCTL_GEMINI_MODEL || "gemini-2.5-pro").trim();
459
- this.model = provider(id);
429
+ this.apiKey = apiKey;
430
+ const baseUrl = (process.env.SCRIPTCTL_GEMINI_BASE_URL ?? "https://generativelanguage.googleapis.com").trim();
431
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
432
+ this.model = (model || process.env.SCRIPTCTL_GEMINI_MODEL || "gemini-2.5-pro").trim();
460
433
  }
461
434
  /**
462
435
  * Plain-text completion. Used by `episode draft` to write a spec-md episode body.
463
436
  */
464
437
  async complete(prompt, maxTokens) {
465
438
  const tokens = Math.max(1024, Math.min(maxTokens ?? DEFAULT_BATCH_MAX_TOKENS, DEFAULT_MAX_TOKENS));
466
- let raw;
467
- let finishReason;
468
- try {
469
- const result = await generateText({
470
- model: this.model,
471
- prompt,
439
+ const url = `${this.baseUrl}/v1beta/models/${encodeURIComponent(this.model)}:generateContent?key=${encodeURIComponent(this.apiKey)}`;
440
+ const body = {
441
+ contents: [{ role: "user", parts: [{ text: prompt }] }],
442
+ generationConfig: {
472
443
  maxOutputTokens: tokens,
473
444
  temperature: 0,
474
- maxRetries: 0,
445
+ },
446
+ };
447
+ let response;
448
+ try {
449
+ response = await fetch(url, {
450
+ method: "POST",
451
+ headers: { "Content-Type": "application/json" },
452
+ body: JSON.stringify(body),
475
453
  });
476
- raw = result.text.trim();
477
- finishReason = strOf(result.finishReason);
478
454
  }
479
455
  catch (exc) {
480
- throw translateGeminiError(exc, tokens);
456
+ throw new CliError("DRAFT FAILED: provider unreachable", "Provider endpoint is unreachable.", {
457
+ exitCode: EXIT_RUNTIME,
458
+ required: ["reachable Gemini endpoint"],
459
+ received: [String(exc.message ?? exc)],
460
+ nextSteps: ["Check network connectivity and retry. Run `scriptctl doctor` if the issue persists."],
461
+ errorCode: "PROVIDER_NETWORK",
462
+ });
481
463
  }
482
- if (finishReason === "length") {
464
+ const text = await response.text();
465
+ if (!response.ok) {
466
+ const isAuth = response.status === 401 || response.status === 403;
467
+ const isRateLimit = response.status === 429;
468
+ const publicMessage = isAuth
469
+ ? "Provider rejected the request: authentication failed."
470
+ : isRateLimit
471
+ ? "Provider is rate-limited."
472
+ : "Provider returned an HTTP error.";
473
+ throw new CliError("DRAFT FAILED: provider returned error", publicMessage, {
474
+ exitCode: EXIT_RUNTIME,
475
+ required: ["HTTP 2xx from Gemini"],
476
+ received: [`status: ${response.status}`, `body: ${text.slice(0, 320) || "<empty>"}`],
477
+ nextSteps: isRateLimit
478
+ ? ["Back off and retry after a short wait."]
479
+ : isAuth
480
+ ? ["Run `scriptctl doctor` to verify provider configuration."]
481
+ : ["Retry once; if the issue persists, run `scriptctl doctor`."],
482
+ errorCode: isAuth
483
+ ? "PROVIDER_AUTH_REJECTED"
484
+ : isRateLimit
485
+ ? "PROVIDER_RATE_LIMITED"
486
+ : "PROVIDER_HTTP_ERROR",
487
+ });
488
+ }
489
+ let payload;
490
+ try {
491
+ payload = JSON.parse(text);
492
+ }
493
+ catch (exc) {
494
+ throw new CliError("DRAFT FAILED: provider returned invalid response", "Provider returned a response that could not be parsed.", {
495
+ exitCode: EXIT_RUNTIME,
496
+ required: ["valid JSON response"],
497
+ received: [`body head: ${text.slice(0, 160)}`, `parse error: ${exc.message}`],
498
+ nextSteps: ["Retry once; if the issue persists, run `scriptctl doctor`."],
499
+ errorCode: "PROVIDER_INVALID_RESPONSE",
500
+ });
501
+ }
502
+ const candidate = payload.candidates?.[0];
503
+ const finishReason = strOf(candidate?.finishReason ?? "");
504
+ if (finishReason === "MAX_TOKENS") {
483
505
  throw new CliError("DRAFT FAILED: Provider output truncated", "Provider output truncated.", {
484
506
  exitCode: EXIT_RUNTIME,
485
507
  required: ["complete markdown within provider max_tokens"],
486
- received: [`finishReason: ${finishReason}`, `maxOutputTokens: ${tokens}`],
508
+ received: [`finishReason: MAX_TOKENS`, `maxOutputTokens: ${tokens}`],
487
509
  nextSteps: [
488
510
  "Re-run with --regen, or split the episode outline into smaller scopes.",
489
511
  ],
490
512
  errorCode: "PROVIDER_OUTPUT_TRUNCATED",
491
513
  });
492
514
  }
493
- if (finishReason && finishReason !== "stop" && finishReason !== "length") {
494
- // content-filter / error / other — surface to agent rather than silently treat as success.
515
+ if (finishReason && finishReason !== "STOP" && finishReason !== "MAX_TOKENS") {
516
+ // SAFETY / RECITATION / OTHER — surface to agent rather than silently treat as success.
495
517
  throw new CliError("DRAFT FAILED: provider stopped abnormally", "Provider stopped before completing the response.", {
496
518
  exitCode: EXIT_RUNTIME,
497
- required: ["finishReason: stop"],
519
+ required: ["finishReason: STOP"],
498
520
  received: [`finishReason: ${finishReason}`],
499
521
  nextSteps: ["Inspect prompt / outline for triggering content; retry with --regen if it looks transient."],
500
522
  errorCode: "PROVIDER_ABNORMAL_STOP",
501
523
  });
502
524
  }
503
- if (!raw) {
525
+ const parts = candidate?.content?.parts ?? [];
526
+ const out = parts.map((p) => (typeof p.text === "string" ? p.text : "")).join("").trim();
527
+ if (!out) {
504
528
  throw new CliError("DRAFT FAILED: provider returned empty content", "Provider returned an empty response.", {
505
529
  exitCode: EXIT_RUNTIME,
506
- required: ["non-empty model output"],
507
- received: [`finishReason: ${finishReason || "<unset>"}`, "text: <empty>"],
530
+ required: ["non-empty candidate content"],
531
+ received: [`finishReason: ${finishReason || "<unset>"}`, `parts: ${parts.length}`],
508
532
  nextSteps: ["Retry once; if it persists, run `scriptctl doctor`."],
509
533
  errorCode: "PROVIDER_EMPTY_RESPONSE",
510
534
  });
511
535
  }
512
- return raw;
536
+ return out;
513
537
  }
514
538
  }
515
539
  // ---------------------------------------------------------------------------
516
- // Error translation
540
+ // DeepSeek provider (Vercel AI SDK, routed via LiteLLM gateway)
517
541
  // ---------------------------------------------------------------------------
518
- function translateLiteLLMError(exc, titlePrefix) {
519
- // ai-sdk wraps transient errors in RetryError after retries exhaust.
520
- // Unwrap so 401/403/429 still resolve to their precise CliError variant
521
- // instead of falling through to generic PROVIDER_NETWORK.
522
- if (RetryError.isInstance(exc) && APICallError.isInstance(exc.lastError)) {
523
- exc = exc.lastError;
542
+ //
543
+ // Selected by makeProvider() when model name starts with "deepseek-". Reuses
544
+ // the same PROJECT_LITELLM_GATEWAY_{API_KEY,BASE_URL} env pair as
545
+ // AnthropicProvider the gateway exposes both /v1/messages (Anthropic-style)
546
+ // and /chat/completions (OpenAI-style) endpoints, and createDeepSeek hits the
547
+ // OpenAI-style one. Non-streaming on purpose: streaming is what gives the
548
+ // gateway "Premature close" failures on long single-call requests.
549
+ export class DeepSeekProvider {
550
+ name = "deepseek";
551
+ client;
552
+ model;
553
+ constructor(model) {
554
+ const apiKey = (process.env.PROJECT_LITELLM_GATEWAY_API_KEY ?? "").trim();
555
+ if (!apiKey) {
556
+ throw new CliError("INIT FAILED: provider not configured", "Provider credentials are not configured for this environment.", {
557
+ exitCode: EXIT_RUNTIME,
558
+ required: ["PROJECT_LITELLM_GATEWAY_API_KEY"],
559
+ received: ["no API key environment variable"],
560
+ nextSteps: ["Run `scriptctl doctor` to identify missing configuration."],
561
+ errorCode: "PROVIDER_AUTH_MISSING",
562
+ });
563
+ }
564
+ const baseURL = (process.env.PROJECT_LITELLM_GATEWAY_BASE_URL ?? "").trim();
565
+ this.client = createDeepSeek(baseURL ? { apiKey, baseURL } : { apiKey });
566
+ this.model = model;
524
567
  }
525
- if (APICallError.isInstance(exc)) {
526
- const status = exc.statusCode ?? 0;
527
- const isAuth = status === 401 || status === 403;
528
- const isRateLimit = status === 429;
529
- const publicMessage = isAuth
530
- ? "Provider rejected the request: authentication failed."
531
- : isRateLimit
532
- ? "Provider is rate-limited."
533
- : "Provider returned an HTTP error.";
534
- const body = strOf(exc.responseBody).slice(0, 320) || "<empty>";
535
- return new CliError(`${titlePrefix}: provider returned error`, publicMessage, {
536
- exitCode: EXIT_RUNTIME,
537
- required: ["HTTP 2xx from provider"],
538
- received: [`status: ${status || "unknown"}`, `body: ${body}`],
539
- nextSteps: isRateLimit
540
- ? ["Back off and retry after a short wait."]
541
- : isAuth
542
- ? ["Run `scriptctl doctor` to verify provider configuration."]
543
- : ["Retry once; if the issue persists, run `scriptctl doctor`."],
544
- errorCode: isAuth
545
- ? "PROVIDER_AUTH_REJECTED"
546
- : isRateLimit
547
- ? "PROVIDER_RATE_LIMITED"
548
- : "PROVIDER_HTTP_ERROR",
568
+ modelHandle() {
569
+ return this.client(this.model);
570
+ }
571
+ async complete(prompt, maxTokens) {
572
+ const tokens = clampDeepSeekTokens(maxTokens ?? DEFAULT_BATCH_MAX_TOKENS);
573
+ let text = "";
574
+ let finishReason = "";
575
+ try {
576
+ const result = await generateText({
577
+ model: this.modelHandle(),
578
+ prompt,
579
+ maxOutputTokens: tokens,
580
+ });
581
+ text = result.text;
582
+ finishReason = String(result.finishReason ?? "");
583
+ }
584
+ catch (exc) {
585
+ throw mapDeepSeekError(exc, "complete", "DRAFT FAILED");
586
+ }
587
+ if (finishReason === "length") {
588
+ throw new CliError("DRAFT FAILED: Provider output truncated", "Provider output truncated.", {
589
+ exitCode: EXIT_RUNTIME,
590
+ required: ["complete markdown within provider max_tokens"],
591
+ received: [
592
+ `finishReason: length`,
593
+ `maxOutputTokens: ${tokens}`,
594
+ `raw chars: ${text.length}`,
595
+ `tail: ${text.slice(-160) || "<empty response>"}`,
596
+ ],
597
+ nextSteps: ["Re-run with --regen, or split the episode outline into smaller scopes."],
598
+ errorCode: "PROVIDER_OUTPUT_TRUNCATED",
599
+ });
600
+ }
601
+ return text;
602
+ }
603
+ extractEpisode(sourceText, episodePlan) {
604
+ return this.extractBatch(sourceText, episodePlan);
605
+ }
606
+ async extractBatch(sourceText, batchPlan) {
607
+ const prompt = buildBatchExtractPrompt(sourceText, batchPlan);
608
+ const tokens = clampDeepSeekTokens(DEFAULT_BATCH_MAX_TOKENS);
609
+ let text = "";
610
+ let finishReason = "";
611
+ try {
612
+ const result = await generateText({
613
+ model: this.modelHandle(),
614
+ prompt,
615
+ maxOutputTokens: tokens,
616
+ });
617
+ text = result.text;
618
+ finishReason = String(result.finishReason ?? "");
619
+ }
620
+ catch (exc) {
621
+ throw mapDeepSeekError(exc, "batch", "INIT FAILED");
622
+ }
623
+ if (finishReason === "length") {
624
+ throw new CliError("INIT FAILED: Provider output truncated", "Provider output truncated.", {
625
+ exitCode: EXIT_RUNTIME,
626
+ required: ["complete markdown within provider max_tokens"],
627
+ received: [
628
+ `finishReason: length`,
629
+ `maxOutputTokens: ${tokens}`,
630
+ `raw chars: ${text.length}`,
631
+ `tail: ${text.slice(-160) || "<empty response>"}`,
632
+ ],
633
+ nextSteps: ["Rerun init; completed batch checkpoints will be reused. If this repeats, split the source episode."],
634
+ errorCode: "PROVIDER_OUTPUT_TRUNCATED",
635
+ });
636
+ }
637
+ const parsed = parseMarkdownBatch(text, batchPlan);
638
+ parsed["_raw_markdown"] = text;
639
+ return parsed;
640
+ }
641
+ async extractEpisodeTitles(sourceText, episodePlan) {
642
+ const context = buildEpisodeTitleContext(sourceText, episodePlan);
643
+ if (context.length === 0)
644
+ return { episode_titles: [] };
645
+ return this.runJsonExtract({
646
+ prompt: buildEpisodeTitlesPrompt(context),
647
+ schema: EPISODE_TITLE_EXTRACTION_SCHEMA,
648
+ maxOutputTokens: 4096,
649
+ truncatedTitle: "INIT FAILED: Episode title output truncated",
650
+ truncatedMessage: "Episode title output truncated.",
651
+ truncatedNextSteps: ["Rerun init; if this repeats, split the source or provide explicit episode titles."],
652
+ invalidTitle: "INIT FAILED: Provider returned invalid episode title JSON",
653
+ invalidMessage: "Provider returned invalid episode title JSON.",
654
+ invalidNextSteps: ["Rerun init; title generation will retry before batch extraction."],
549
655
  });
550
656
  }
551
- if (RetryError.isInstance(exc)) {
552
- return new CliError(`${titlePrefix}: provider unreachable`, "Provider endpoint is unreachable.", {
553
- exitCode: EXIT_RUNTIME,
554
- required: ["reachable provider endpoint"],
555
- received: [strOf(exc.message).slice(0, 320) || "retries exhausted"],
556
- nextSteps: ["Check network connectivity and retry. Run `scriptctl doctor` if the issue persists."],
557
- errorCode: "PROVIDER_NETWORK",
657
+ async extractAssetCuration(_sourceText, script) {
658
+ const context = buildAssetCurationContext(script);
659
+ return this.runJsonExtract({
660
+ prompt: buildAssetCurationPrompt(context),
661
+ schema: ASSET_CURATION_SCHEMA,
662
+ maxOutputTokens: DEFAULT_MAX_TOKENS,
663
+ truncatedTitle: "INIT FAILED: Asset curation output truncated",
664
+ truncatedMessage: "Asset curation output truncated.",
665
+ truncatedNextSteps: ["Rerun init; extraction checkpoints will be reused and asset curation will retry."],
666
+ invalidTitle: "INIT FAILED: Provider returned invalid asset curation JSON",
667
+ invalidMessage: "Provider returned invalid asset curation JSON.",
668
+ invalidNextSteps: ["Rerun init; extraction checkpoints will be reused and asset curation will retry."],
558
669
  });
559
670
  }
560
- const msg = strOf(exc?.message).slice(0, 320);
561
- return new CliError(`${titlePrefix}: provider returned error`, "Provider returned an unexpected error.", {
562
- exitCode: EXIT_RUNTIME,
563
- required: ["successful provider response"],
564
- received: [msg || String(exc)],
565
- nextSteps: ["Retry once; if the issue persists, run `scriptctl doctor`."],
566
- errorCode: "PROVIDER_HTTP_ERROR",
567
- });
568
- }
569
- function translateGeminiError(exc, tokens) {
570
- if (RetryError.isInstance(exc) && APICallError.isInstance(exc.lastError)) {
571
- exc = exc.lastError;
671
+ async extractMetadata(_sourceText, script) {
672
+ const context = buildMetadataContext(script);
673
+ return this.runJsonExtract({
674
+ prompt: buildMetadataPrompt(context),
675
+ schema: METADATA_EXTRACTION_SCHEMA,
676
+ maxOutputTokens: DEFAULT_MAX_TOKENS,
677
+ truncatedTitle: "INIT FAILED: Metadata output truncated",
678
+ truncatedMessage: "Metadata output truncated.",
679
+ truncatedNextSteps: ["Rerun init; extraction checkpoints will be reused and metadata will retry."],
680
+ invalidTitle: "INIT FAILED: Provider returned invalid metadata JSON",
681
+ invalidMessage: "Provider returned invalid metadata JSON.",
682
+ invalidNextSteps: ["Rerun init; extraction checkpoints will be reused and metadata will retry."],
683
+ });
572
684
  }
573
- if (APICallError.isInstance(exc)) {
574
- const status = exc.statusCode ?? 0;
685
+ async runJsonExtract(opts) {
686
+ const tokens = clampDeepSeekTokens(opts.maxOutputTokens);
687
+ let object;
688
+ let finishReason = "";
689
+ try {
690
+ const result = await generateObject({
691
+ model: this.modelHandle(),
692
+ schema: jsonSchema(opts.schema),
693
+ prompt: opts.prompt,
694
+ maxOutputTokens: tokens,
695
+ });
696
+ object = result.object;
697
+ finishReason = String(result.finishReason ?? "");
698
+ }
699
+ catch (exc) {
700
+ if (NoObjectGeneratedError.isInstance(exc)) {
701
+ throw new CliError(opts.invalidTitle, opts.invalidMessage, {
702
+ exitCode: EXIT_RUNTIME,
703
+ required: ["JSON object matching configured schema"],
704
+ received: [`no object generated`, `error: ${exc.message?.slice(0, 200) ?? ""}`],
705
+ nextSteps: opts.invalidNextSteps,
706
+ });
707
+ }
708
+ throw mapDeepSeekError(exc, "json_extract", "INIT FAILED");
709
+ }
710
+ if (finishReason === "length") {
711
+ throw new CliError(opts.truncatedTitle, opts.truncatedMessage, {
712
+ exitCode: EXIT_RUNTIME,
713
+ required: ["complete JSON within provider max_tokens"],
714
+ received: [`finishReason: length`, `maxOutputTokens: ${tokens}`],
715
+ nextSteps: opts.truncatedNextSteps,
716
+ });
717
+ }
718
+ if (!object || typeof object !== "object" || Array.isArray(object)) {
719
+ throw new CliError(opts.invalidTitle, opts.invalidMessage, {
720
+ exitCode: EXIT_RUNTIME,
721
+ required: ["JSON object matching configured schema"],
722
+ received: [`type: ${Array.isArray(object) ? "array" : typeof object}`],
723
+ nextSteps: opts.invalidNextSteps,
724
+ });
725
+ }
726
+ return object;
727
+ }
728
+ }
729
+ function clampDeepSeekTokens(tokens) {
730
+ return Math.max(1024, Math.min(tokens, DEFAULT_MAX_TOKENS));
731
+ }
732
+ function mapDeepSeekError(exc, stage, titlePrefix) {
733
+ // ai sdk retries 429/5xx 3 times then wraps the last failure in RetryError.lastError;
734
+ // peel one layer so HTTP status mapping (429 / 401 / 5xx) still works.
735
+ const peeled = (exc?.lastError ?? exc);
736
+ if (APICallError.isInstance(peeled)) {
737
+ const status = peeled.statusCode ?? 0;
575
738
  const isAuth = status === 401 || status === 403;
576
739
  const isRateLimit = status === 429;
577
740
  const publicMessage = isAuth
@@ -579,11 +742,10 @@ function translateGeminiError(exc, tokens) {
579
742
  : isRateLimit
580
743
  ? "Provider is rate-limited."
581
744
  : "Provider returned an HTTP error.";
582
- const body = strOf(exc.responseBody).slice(0, 320) || "<empty>";
583
- return new CliError("DRAFT FAILED: provider returned error", publicMessage, {
745
+ return new CliError(`${titlePrefix}: provider returned error`, publicMessage, {
584
746
  exitCode: EXIT_RUNTIME,
585
- required: ["HTTP 2xx from Gemini"],
586
- received: [`status: ${status || "unknown"}`, `body: ${body}`],
747
+ required: ["HTTP 2xx from provider"],
748
+ received: [`status: ${status || "unknown"}`, `stage: ${stage}`, `message: ${peeled.message?.slice(0, 200) ?? ""}`],
587
749
  nextSteps: isRateLimit
588
750
  ? ["Back off and retry after a short wait."]
589
751
  : isAuth
@@ -596,32 +758,51 @@ function translateGeminiError(exc, tokens) {
596
758
  : "PROVIDER_HTTP_ERROR",
597
759
  });
598
760
  }
599
- if (RetryError.isInstance(exc)) {
600
- return new CliError("DRAFT FAILED: provider unreachable", "Provider endpoint is unreachable.", {
601
- exitCode: EXIT_RUNTIME,
602
- required: ["reachable Gemini endpoint"],
603
- received: [strOf(exc.message).slice(0, 320) || "retries exhausted"],
604
- nextSteps: ["Check network connectivity and retry. Run `scriptctl doctor` if the issue persists."],
605
- errorCode: "PROVIDER_NETWORK",
606
- });
607
- }
608
- const msg = strOf(exc?.message).slice(0, 320);
609
- return new CliError("DRAFT FAILED: provider returned invalid response", "Provider returned a response that could not be parsed.", {
761
+ const message = exc?.message ?? String(exc);
762
+ return new CliError(`${titlePrefix}: provider unreachable`, "Provider endpoint is unreachable.", {
610
763
  exitCode: EXIT_RUNTIME,
611
- required: ["valid response from Gemini"],
612
- received: [msg || String(exc), `maxOutputTokens: ${tokens}`],
613
- nextSteps: ["Retry once; if the issue persists, run `scriptctl doctor`."],
614
- errorCode: "PROVIDER_INVALID_RESPONSE",
764
+ required: ["reachable provider endpoint"],
765
+ received: [`stage: ${stage}`, message.slice(0, 200)],
766
+ nextSteps: ["Check network connectivity and retry. Run `scriptctl doctor` if the issue persists."],
767
+ errorCode: "PROVIDER_NETWORK",
615
768
  });
616
769
  }
770
+ function textFromResponse(response) {
771
+ const parts = [];
772
+ for (const block of response.content ?? []) {
773
+ if (block.text)
774
+ parts.push(block.text);
775
+ }
776
+ return parts.join("\n").trim();
777
+ }
778
+ /**
779
+ * Find a forced tool_use block by name in a Message's content array.
780
+ * Returns its `input` (already parsed by the SDK) or `null` when missing/non-object.
781
+ */
782
+ function findToolUseInput(content, toolName) {
783
+ for (const block of content) {
784
+ if (block?.type === "tool_use" && block.name === toolName) {
785
+ const inp = block.input;
786
+ if (inp && typeof inp === "object" && !Array.isArray(inp))
787
+ return inp;
788
+ return null;
789
+ }
790
+ }
791
+ return null;
792
+ }
617
793
  // ---------------------------------------------------------------------------
618
794
  // Factory
619
795
  // ---------------------------------------------------------------------------
620
796
  export function makeProvider(name, model) {
621
797
  if (name === "mock")
622
798
  return new MockProvider();
799
+ // Model-name prefix overrides provider name: deepseek-* models route to
800
+ // DeepSeekProvider regardless of --provider value. Lets the same litellm
801
+ // gateway expose Anthropic and DeepSeek models behind one --provider flag.
802
+ if (model.startsWith("deepseek-"))
803
+ return new DeepSeekProvider(model);
623
804
  if (name === "anthropic")
624
- return new LiteLLMProvider(model);
805
+ return new AnthropicProvider(model);
625
806
  if (name === "gemini")
626
807
  return new GeminiProvider(model);
627
808
  throw new CliError("INIT FAILED: Unsupported provider", "Unsupported provider.", {