@lingjingai/scriptctl 0.8.3 → 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.
- package/README.md +1 -1
- package/dist/common.d.ts +4 -0
- package/dist/common.js +87 -0
- package/dist/common.js.map +1 -1
- package/dist/help-text.js +3 -1
- package/dist/help-text.js.map +1 -1
- package/dist/infra/providers.d.ts +53 -7
- package/dist/infra/providers.js +550 -369
- package/dist/infra/providers.js.map +1 -1
- package/package.json +4 -5
package/dist/infra/providers.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import { APICallError, NoObjectGeneratedError,
|
|
4
|
-
import {
|
|
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
|
-
//
|
|
142
|
-
//
|
|
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
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
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
|
|
176
|
+
export class AnthropicProvider {
|
|
153
177
|
name = "anthropic";
|
|
178
|
+
client;
|
|
154
179
|
model;
|
|
155
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
204
|
+
configuredMaxTokens = parsed;
|
|
209
205
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
239
|
-
`max_tokens: ${
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
`
|
|
293
|
-
`max_tokens: ${
|
|
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 =
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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 =
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
"
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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 =
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
"
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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 !== "
|
|
494
|
-
//
|
|
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:
|
|
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
|
-
|
|
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
|
|
507
|
-
received: [`finishReason: ${finishReason || "<unset>"}`,
|
|
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
|
|
536
|
+
return out;
|
|
513
537
|
}
|
|
514
538
|
}
|
|
515
539
|
// ---------------------------------------------------------------------------
|
|
516
|
-
//
|
|
540
|
+
// DeepSeek provider (Vercel AI SDK, routed via LiteLLM gateway)
|
|
517
541
|
// ---------------------------------------------------------------------------
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
:
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
574
|
-
const
|
|
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
|
-
|
|
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
|
|
586
|
-
received: [`status: ${status || "unknown"}`, `
|
|
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
|
-
|
|
600
|
-
|
|
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: ["
|
|
612
|
-
received: [
|
|
613
|
-
nextSteps: ["
|
|
614
|
-
errorCode: "
|
|
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
|
|
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.", {
|