@rex0220/llm-task-router 0.1.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.
@@ -0,0 +1,1465 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import "dotenv/config";
5
+ import { readFileSync } from "fs";
6
+ import { readFile as readFile5 } from "fs/promises";
7
+ import { basename as basename3 } from "path";
8
+ import { fileURLToPath } from "url";
9
+ import { Command } from "commander";
10
+
11
+ // src/cli/inputs.ts
12
+ import { readFile } from "fs/promises";
13
+ import { basename, resolve, sep } from "path";
14
+ function assertSafeInputPath(filePath, cwd = process.cwd()) {
15
+ const name = basename(filePath).toLowerCase();
16
+ if (name === ".env" || name.startsWith(".env.")) {
17
+ throw new Error(`Refusing to read a secret file: ${filePath}`);
18
+ }
19
+ const resolved = resolve(cwd, filePath);
20
+ const root = resolve(cwd);
21
+ if (resolved !== root && !resolved.startsWith(`${root}${sep}`)) {
22
+ process.stderr.write(`Warning: reading a file outside the workspace: ${resolved}
23
+ `);
24
+ }
25
+ }
26
+ function assertSafeOutputPath(filePath, cwd = process.cwd()) {
27
+ const name = basename(filePath).toLowerCase();
28
+ if (name === ".env" || name.startsWith(".env.")) {
29
+ throw new Error(`Refusing to write to a secret file: ${filePath}`);
30
+ }
31
+ const resolved = resolve(cwd, filePath);
32
+ const root = resolve(cwd);
33
+ if (resolved !== root && !resolved.startsWith(`${root}${sep}`)) {
34
+ process.stderr.write(`Warning: writing a file outside the workspace: ${resolved}
35
+ `);
36
+ }
37
+ }
38
+ async function resolveText(inline, filePath, label, inlineFlag, fileFlag) {
39
+ if (inline !== void 0 && filePath !== void 0) {
40
+ throw new Error(`Specify only one of ${inlineFlag} or ${fileFlag}, not both`);
41
+ }
42
+ if (filePath !== void 0) {
43
+ assertSafeInputPath(filePath);
44
+ const content = (await readFile(filePath, "utf8")).trim();
45
+ if (!content) {
46
+ throw new Error(`${label} file is empty: ${filePath}`);
47
+ }
48
+ return content;
49
+ }
50
+ const text = inline?.trim();
51
+ if (text) {
52
+ return text;
53
+ }
54
+ throw new Error(`Provide ${label} with ${inlineFlag} "..." or ${fileFlag} <path>`);
55
+ }
56
+
57
+ // src/cli/export.ts
58
+ import { access, mkdir, writeFile } from "fs/promises";
59
+ import { dirname, resolve as resolve2 } from "path";
60
+ async function exportFinalArticle(store, runId, outPath, options = {}) {
61
+ const content = await store.read(runId, "final.md");
62
+ assertSafeOutputPath(outPath);
63
+ const resolved = resolve2(outPath);
64
+ if (!options.force) {
65
+ const exists2 = await access(resolved).then(
66
+ () => true,
67
+ () => false
68
+ );
69
+ if (exists2) {
70
+ throw new Error(`Output already exists: ${resolved} (use --force to overwrite)`);
71
+ }
72
+ }
73
+ await mkdir(dirname(resolved), { recursive: true });
74
+ await writeFile(resolved, content, "utf8");
75
+ return resolved;
76
+ }
77
+
78
+ // src/cli/init.ts
79
+ import { access as access2, copyFile, mkdir as mkdir2, readdir } from "fs/promises";
80
+ import { dirname as dirname2, join, relative } from "path";
81
+ async function exists(path) {
82
+ return access2(path).then(
83
+ () => true,
84
+ () => false
85
+ );
86
+ }
87
+ async function listFiles(dir) {
88
+ const entries = await readdir(dir, { withFileTypes: true });
89
+ const files = [];
90
+ for (const entry of entries) {
91
+ const full = join(dir, entry.name);
92
+ if (entry.isDirectory()) {
93
+ files.push(...await listFiles(full));
94
+ } else {
95
+ files.push(full);
96
+ }
97
+ }
98
+ return files;
99
+ }
100
+ async function initConfig(targetDir, sourceDir, options = {}) {
101
+ const created = [];
102
+ const skipped = [];
103
+ const sources = await listFiles(join(sourceDir, "config"));
104
+ const envExample = join(sourceDir, ".env.example");
105
+ if (await exists(envExample)) {
106
+ sources.push(envExample);
107
+ }
108
+ for (const src of sources) {
109
+ const rel = relative(sourceDir, src);
110
+ const dest = join(targetDir, rel);
111
+ if (!options.force && await exists(dest)) {
112
+ skipped.push(rel);
113
+ continue;
114
+ }
115
+ await mkdir2(dirname2(dest), { recursive: true });
116
+ await copyFile(src, dest);
117
+ created.push(rel);
118
+ }
119
+ return { created, skipped };
120
+ }
121
+
122
+ // src/workflows/profile.ts
123
+ import { readFile as readFile2 } from "fs/promises";
124
+ import { basename as basename2, join as join2 } from "path";
125
+ import { parse } from "yaml";
126
+ import { z } from "zod";
127
+
128
+ // src/router/errors.ts
129
+ var RouterError = class extends Error {
130
+ kind;
131
+ statusCode;
132
+ constructor(message, kind, statusCode) {
133
+ super(message);
134
+ this.name = "RouterError";
135
+ this.kind = kind;
136
+ this.statusCode = statusCode;
137
+ }
138
+ };
139
+ function isRouterError(error) {
140
+ return error instanceof RouterError;
141
+ }
142
+ function normalizeProviderError(error) {
143
+ if (isRouterError(error)) {
144
+ return error;
145
+ }
146
+ if (error instanceof DOMException && error.name === "AbortError") {
147
+ return new RouterError("Provider request was aborted", "timeout");
148
+ }
149
+ const maybeError = error;
150
+ const statusCode = toNumber(maybeError.status ?? maybeError.statusCode);
151
+ const code = toLowerString(maybeError.code);
152
+ const type = toLowerString(maybeError.type);
153
+ const name = toLowerString(maybeError.name);
154
+ const message = safeMessage(maybeError.message ?? String(error));
155
+ const haystack = `${name} ${code} ${type}`;
156
+ if (statusCode === 401 || statusCode === 403 || includesAny(haystack, ["authentication", "permission", "auth"])) {
157
+ return new RouterError(message, "auth", statusCode);
158
+ }
159
+ if (includesAny(haystack, ["insufficient_quota", "billing", "payment", "credit"])) {
160
+ return new RouterError(message, "billing_quota", statusCode);
161
+ }
162
+ if (statusCode === 429 || includesAny(haystack, ["rate_limit", "ratelimit"])) {
163
+ return new RouterError(message, "rate_limit", statusCode);
164
+ }
165
+ if (includesAny(haystack, ["timeout", "timedout", "abort"])) {
166
+ return new RouterError(message, "timeout", statusCode);
167
+ }
168
+ if (statusCode === 529 || includesAny(haystack, ["overloaded"])) {
169
+ return new RouterError(message, "overloaded", statusCode);
170
+ }
171
+ if (statusCode === 503 || statusCode === 502 || statusCode === 504) {
172
+ return new RouterError(message, "service_unavailable", statusCode);
173
+ }
174
+ if (statusCode && statusCode >= 500) {
175
+ return new RouterError(message, "service_unavailable", statusCode);
176
+ }
177
+ if (includesAny(haystack, ["connection", "network", "fetch", "econnreset", "enotfound"])) {
178
+ return new RouterError(message, "connection", statusCode);
179
+ }
180
+ if (statusCode === 400 || includesAny(haystack, ["badrequest", "bad_request", "invalid_request"])) {
181
+ return new RouterError(message, "bad_request", statusCode);
182
+ }
183
+ if (includesAny(haystack, ["context_length", "too_large", "token"])) {
184
+ return new RouterError(message, "context_length", statusCode);
185
+ }
186
+ return new RouterError(message, "unknown", statusCode);
187
+ }
188
+ function shouldFallback(kind) {
189
+ return [
190
+ "rate_limit",
191
+ "timeout",
192
+ "overloaded",
193
+ "service_unavailable",
194
+ "connection",
195
+ "schema_validation"
196
+ ].includes(kind);
197
+ }
198
+ function errorForLog(error) {
199
+ const normalized = normalizeProviderError(error);
200
+ return {
201
+ kind: normalized.kind,
202
+ message: safeMessage(normalized.message),
203
+ statusCode: normalized.statusCode
204
+ };
205
+ }
206
+ function includesAny(value, needles) {
207
+ return needles.some((needle) => value.includes(needle));
208
+ }
209
+ function toLowerString(value) {
210
+ return typeof value === "string" ? value.toLowerCase() : "";
211
+ }
212
+ function toNumber(value) {
213
+ return typeof value === "number" ? value : void 0;
214
+ }
215
+ function safeMessage(value) {
216
+ const text = typeof value === "string" ? value : "Provider request failed";
217
+ return text.replace(/sk-[A-Za-z0-9_-]+/g, "[redacted]").slice(0, 300);
218
+ }
219
+
220
+ // src/workflows/profile.ts
221
+ var profileSchema = z.object({
222
+ platform: z.string().min(1),
223
+ language: z.string().optional(),
224
+ style: z.string().optional(),
225
+ criteria_file: z.string().optional()
226
+ });
227
+ async function loadProfile(name, dir = "config/profiles") {
228
+ if (!/^[A-Za-z0-9._-]+$/.test(name) || name.includes("..")) {
229
+ throw new RouterError(`Invalid profile name: ${name}`, "config");
230
+ }
231
+ const path = join2(dir, `${basename2(name)}.yaml`);
232
+ let raw;
233
+ try {
234
+ raw = await readFile2(path, "utf8");
235
+ } catch {
236
+ throw new RouterError(`Profile not found: ${name} (expected ${path})`, "config");
237
+ }
238
+ const parsed = profileSchema.safeParse(parse(raw));
239
+ if (!parsed.success) {
240
+ throw new RouterError(`Invalid profile ${name}: ${parsed.error.message}`, "config");
241
+ }
242
+ return {
243
+ platform: parsed.data.platform,
244
+ language: parsed.data.language,
245
+ style: parsed.data.style?.trim() || void 0,
246
+ criteriaFile: parsed.data.criteria_file?.trim() || void 0
247
+ };
248
+ }
249
+
250
+ // src/logger/RunLogger.ts
251
+ import { mkdir as mkdir3, appendFile } from "fs/promises";
252
+ import { dirname as dirname3 } from "path";
253
+
254
+ // src/utils/hash.ts
255
+ import { createHash } from "crypto";
256
+ function sha256(text) {
257
+ return `sha256:${createHash("sha256").update(text).digest("hex")}`;
258
+ }
259
+
260
+ // src/logger/RunLogger.ts
261
+ var RunLogger = class {
262
+ constructor(logPath = "runs/router.log") {
263
+ this.logPath = logPath;
264
+ }
265
+ logPath;
266
+ async logSuccess(request, response) {
267
+ await this.append({
268
+ at: (/* @__PURE__ */ new Date()).toISOString(),
269
+ task: request.task,
270
+ provider: response.provider,
271
+ model: response.model,
272
+ status: "success",
273
+ input_hash: sha256(request.input),
274
+ elapsed_ms: response.elapsedMs,
275
+ input_tokens: response.usage?.inputTokens,
276
+ output_tokens: response.usage?.outputTokens,
277
+ cost_usd: response.usage?.costUsd
278
+ });
279
+ }
280
+ async logFailure(request, candidate, error) {
281
+ const normalized = errorForLog(error);
282
+ await this.append({
283
+ at: (/* @__PURE__ */ new Date()).toISOString(),
284
+ task: request.task,
285
+ provider: candidate.provider,
286
+ model: candidate.model,
287
+ status: "failure",
288
+ input_hash: sha256(request.input),
289
+ error_kind: normalized.kind,
290
+ error_message: normalized.message,
291
+ status_code: normalized.statusCode
292
+ });
293
+ }
294
+ async append(entry) {
295
+ await mkdir3(dirname3(this.logPath), { recursive: true });
296
+ await appendFile(this.logPath, `${JSON.stringify(withoutUndefined(entry))}
297
+ `, "utf8");
298
+ }
299
+ };
300
+ function withoutUndefined(entry) {
301
+ return Object.fromEntries(Object.entries(entry).filter(([, value]) => value !== void 0));
302
+ }
303
+
304
+ // src/router/config.ts
305
+ import { readFile as readFile3 } from "fs/promises";
306
+ import { parse as parse2 } from "yaml";
307
+ import { z as z2 } from "zod";
308
+ var modelTaskSchema = z2.enum([
309
+ "article_brief",
310
+ "outline",
311
+ "draft_markdown",
312
+ "technical_review",
313
+ "final_review",
314
+ "rewrite",
315
+ "markdown_format",
316
+ "title_suggestions"
317
+ ]);
318
+ var candidateSchema = z2.object({
319
+ provider: z2.string().min(1),
320
+ model: z2.string().min(1)
321
+ });
322
+ var taskConfigSchema = z2.object({
323
+ primary: candidateSchema,
324
+ fallback: z2.array(candidateSchema).optional(),
325
+ temperature: z2.number().optional(),
326
+ max_tokens: z2.number().int().positive().optional(),
327
+ timeout_ms: z2.number().int().positive().optional()
328
+ });
329
+ var routerConfigSchema = z2.object({
330
+ providers: z2.record(z2.object({ api_key_env: z2.string().min(1).optional() })).default({}),
331
+ prices: z2.record(
332
+ z2.record(
333
+ z2.object({
334
+ input_usd_per_1m_tokens: z2.number().nonnegative().optional(),
335
+ output_usd_per_1m_tokens: z2.number().nonnegative().optional()
336
+ })
337
+ )
338
+ ).default({}),
339
+ defaults: z2.object({ timeout_ms: z2.number().int().positive().default(12e4) }).default({ timeout_ms: 12e4 }),
340
+ tasks: z2.record(modelTaskSchema, taskConfigSchema)
341
+ });
342
+ async function loadRouterConfig(path = "config/models.yaml") {
343
+ const raw = await readFile3(path, "utf8");
344
+ const parsed = routerConfigSchema.safeParse(parse2(raw));
345
+ if (!parsed.success) {
346
+ throw new RouterError(`Invalid router config: ${parsed.error.message}`, "config");
347
+ }
348
+ const config = parsed.data;
349
+ for (const [task, taskConfig] of Object.entries(config.tasks)) {
350
+ if (!taskConfig.primary) {
351
+ throw new RouterError(`Task ${task} is missing primary model`, "config");
352
+ }
353
+ }
354
+ return config;
355
+ }
356
+ function resolveApiKeyEnv(providerName, config) {
357
+ const configured = config.providers[providerName]?.api_key_env;
358
+ if (configured) {
359
+ return configured;
360
+ }
361
+ switch (providerName) {
362
+ case "openai":
363
+ return "OPENAI_API_KEY";
364
+ case "anthropic":
365
+ return "ANTHROPIC_API_KEY";
366
+ case "gemini":
367
+ return "GEMINI_API_KEY";
368
+ default:
369
+ return `${providerName.toUpperCase()}_API_KEY`;
370
+ }
371
+ }
372
+
373
+ // src/providers/AnthropicProvider.ts
374
+ import Anthropic from "@anthropic-ai/sdk";
375
+ var AnthropicProvider = class {
376
+ client;
377
+ constructor(apiKey, options = {}) {
378
+ if (!apiKey) {
379
+ throw new RouterError("Anthropic API key is not configured", "auth");
380
+ }
381
+ this.client = new Anthropic({
382
+ apiKey,
383
+ maxRetries: options.maxRetries ?? 2,
384
+ timeout: options.timeoutMs
385
+ });
386
+ }
387
+ async generate(request) {
388
+ const body = {
389
+ model: request.model,
390
+ max_tokens: request.maxTokens ?? 4e3,
391
+ messages: [{ role: "user", content: request.input }]
392
+ };
393
+ if (request.system) {
394
+ body.system = request.system;
395
+ }
396
+ if (supportsTemperature(request.model) && request.temperature !== void 0) {
397
+ body.temperature = request.temperature;
398
+ }
399
+ const response = await this.client.messages.create(body, {
400
+ signal: request.abortSignal
401
+ });
402
+ return {
403
+ text: extractAnthropicText(response),
404
+ usage: response.usage ? {
405
+ inputTokens: response.usage.input_tokens,
406
+ outputTokens: response.usage.output_tokens
407
+ } : void 0,
408
+ truncated: response.stop_reason === "max_tokens"
409
+ };
410
+ }
411
+ };
412
+ function supportsTemperature(model) {
413
+ const normalized = model.toLowerCase();
414
+ return !(normalized.includes("opus") || normalized.includes("sonnet-4") || normalized.includes("haiku-4") || normalized.includes("fable"));
415
+ }
416
+ function extractAnthropicText(response) {
417
+ const content = response.content;
418
+ if (!Array.isArray(content)) {
419
+ return "";
420
+ }
421
+ return content.map((block) => {
422
+ const text = block.text;
423
+ return typeof text === "string" ? text : "";
424
+ }).filter(Boolean).join("\n");
425
+ }
426
+
427
+ // src/providers/OpenAIProvider.ts
428
+ import OpenAI from "openai";
429
+ var OpenAIProvider = class {
430
+ client;
431
+ constructor(apiKey, options = {}) {
432
+ if (!apiKey) {
433
+ throw new RouterError("OpenAI API key is not configured", "auth");
434
+ }
435
+ this.client = new OpenAI({
436
+ apiKey,
437
+ maxRetries: options.maxRetries ?? 2,
438
+ timeout: options.timeoutMs
439
+ });
440
+ }
441
+ async generate(request) {
442
+ const body = {
443
+ model: request.model,
444
+ input: request.input
445
+ };
446
+ if (request.system) {
447
+ body.instructions = request.system;
448
+ }
449
+ if (request.maxTokens) {
450
+ body.max_output_tokens = request.maxTokens;
451
+ }
452
+ if (supportsOpenAITemperature(request.model) && request.temperature !== void 0) {
453
+ body.temperature = request.temperature;
454
+ }
455
+ if (request.responseFormat?.type === "json_schema") {
456
+ body.text = { format: { type: "json_object" } };
457
+ }
458
+ const response = await this.client.responses.create(body, {
459
+ signal: request.abortSignal
460
+ });
461
+ const usage = response.usage ? {
462
+ inputTokens: response.usage.input_tokens,
463
+ outputTokens: response.usage.output_tokens
464
+ } : void 0;
465
+ return {
466
+ text: extractOpenAIText(response),
467
+ usage,
468
+ truncated: isOpenAITruncated(response)
469
+ };
470
+ }
471
+ };
472
+ function isOpenAITruncated(response) {
473
+ const r = response;
474
+ return r.status === "incomplete" && r.incomplete_details?.reason === "max_output_tokens";
475
+ }
476
+ function supportsOpenAITemperature(model) {
477
+ const normalized = model.toLowerCase();
478
+ return !(normalized.startsWith("o") || normalized.startsWith("gpt-5") || normalized.includes("reasoning"));
479
+ }
480
+ function extractOpenAIText(response) {
481
+ const outputText = response.output_text;
482
+ if (typeof outputText === "string") {
483
+ return outputText;
484
+ }
485
+ const output = response.output;
486
+ if (Array.isArray(output)) {
487
+ const parts = [];
488
+ for (const item of output) {
489
+ const content = item.content;
490
+ if (!Array.isArray(content)) {
491
+ continue;
492
+ }
493
+ for (const block of content) {
494
+ const text = block.text;
495
+ if (typeof text === "string") {
496
+ parts.push(text);
497
+ }
498
+ }
499
+ }
500
+ if (parts.length > 0) {
501
+ return parts.join("\n");
502
+ }
503
+ }
504
+ return "";
505
+ }
506
+
507
+ // src/providers/index.ts
508
+ function createProviders(config, env = process.env) {
509
+ const providers = {};
510
+ if (hasProviderReference(config, "openai")) {
511
+ const keyEnv = resolveApiKeyEnv("openai", config);
512
+ const apiKey = env[keyEnv] || env.OPENAI_API_KEY;
513
+ if (apiKey) {
514
+ providers.openai = new OpenAIProvider(apiKey);
515
+ }
516
+ }
517
+ if (hasProviderReference(config, "anthropic")) {
518
+ const keyEnv = resolveApiKeyEnv("anthropic", config);
519
+ const apiKey = env[keyEnv] || env.ANTHROPIC_API_KEY;
520
+ if (apiKey) {
521
+ providers.anthropic = new AnthropicProvider(apiKey);
522
+ }
523
+ }
524
+ return providers;
525
+ }
526
+ function hasProviderReference(config, provider) {
527
+ return Object.values(config.tasks).some(
528
+ (task) => task.primary.provider === provider || task.fallback?.some((candidate) => candidate.provider === provider)
529
+ );
530
+ }
531
+
532
+ // src/schemas/ArticleBriefSchema.ts
533
+ import { z as z3 } from "zod";
534
+ var ArticleBriefSchema = z3.object({
535
+ title: z3.string(),
536
+ targetReaders: z3.array(z3.string()),
537
+ goal: z3.array(z3.string()),
538
+ mainClaim: z3.string(),
539
+ sections: z3.array(
540
+ z3.object({
541
+ heading: z3.string(),
542
+ points: z3.array(z3.string())
543
+ })
544
+ ),
545
+ codeExamples: z3.array(
546
+ z3.object({
547
+ language: z3.string(),
548
+ purpose: z3.string()
549
+ })
550
+ )
551
+ });
552
+
553
+ // src/schemas/ArticleOutlineSchema.ts
554
+ import { z as z4 } from "zod";
555
+ var ArticleOutlineSchema = z4.object({
556
+ title: z4.string(),
557
+ introduction: z4.string().optional(),
558
+ sections: z4.array(
559
+ z4.object({
560
+ heading: z4.string(),
561
+ summary: z4.string().optional(),
562
+ points: z4.array(z4.string()).default([]),
563
+ codeExample: z4.object({
564
+ language: z4.string(),
565
+ purpose: z4.string()
566
+ }).optional()
567
+ })
568
+ ),
569
+ conclusion: z4.string().optional()
570
+ });
571
+
572
+ // src/schemas/ReviewResultSchema.ts
573
+ import { z as z5 } from "zod";
574
+ var ReviewResultSchema = z5.object({
575
+ summary: z5.string(),
576
+ issues: z5.array(
577
+ z5.object({
578
+ severity: z5.enum(["critical", "major", "minor", "suggestion"]),
579
+ location: z5.string().optional(),
580
+ problem: z5.string(),
581
+ recommendation: z5.string()
582
+ })
583
+ ),
584
+ approved: z5.boolean().optional()
585
+ });
586
+
587
+ // src/schemas/index.ts
588
+ var schemaRegistry = {
589
+ ArticleBrief: ArticleBriefSchema,
590
+ ArticleOutline: ArticleOutlineSchema,
591
+ ReviewResult: ReviewResultSchema
592
+ };
593
+ var schemaHints = {
594
+ ArticleBrief: `{
595
+ "title": "string",
596
+ "targetReaders": ["string"],
597
+ "goal": ["string"],
598
+ "mainClaim": "string",
599
+ "sections": [{ "heading": "string", "points": ["string"] }],
600
+ "codeExamples": [{ "language": "string", "purpose": "string" }]
601
+ }`,
602
+ ArticleOutline: `{
603
+ "title": "string",
604
+ "introduction": "string (\u4EFB\u610F)",
605
+ "sections": [{
606
+ "heading": "string",
607
+ "summary": "string (\u4EFB\u610F)",
608
+ "points": ["string"],
609
+ "codeExample": { "language": "string", "purpose": "string" } (\u4EFB\u610F)
610
+ }],
611
+ "conclusion": "string (\u4EFB\u610F)"
612
+ }`,
613
+ ReviewResult: `{
614
+ "summary": "string",
615
+ "issues": [{
616
+ "severity": "critical | major | minor | suggestion",
617
+ "location": "string (\u4EFB\u610F)",
618
+ "problem": "string",
619
+ "recommendation": "string"
620
+ }],
621
+ "approved": "boolean (\u4EFB\u610F)"
622
+ }`
623
+ };
624
+
625
+ // src/utils/cost.ts
626
+ function estimateCostUsd(usage, price) {
627
+ if (!usage || !price) {
628
+ return void 0;
629
+ }
630
+ const inputRate = price.input_usd_per_1m_tokens ?? 0;
631
+ const outputRate = price.output_usd_per_1m_tokens ?? 0;
632
+ if (inputRate <= 0 && outputRate <= 0) {
633
+ return void 0;
634
+ }
635
+ const inputCost = (usage.inputTokens ?? 0) / 1e6 * inputRate;
636
+ const outputCost = (usage.outputTokens ?? 0) / 1e6 * outputRate;
637
+ return Number((inputCost + outputCost).toFixed(6));
638
+ }
639
+
640
+ // src/utils/json.ts
641
+ function parseJsonObject(text) {
642
+ const trimmed = text.trim();
643
+ try {
644
+ return JSON.parse(trimmed);
645
+ } catch {
646
+ const extracted = extractJsonCandidate(trimmed);
647
+ if (!extracted) {
648
+ throw new RouterError("Model output was not valid JSON", "schema_validation");
649
+ }
650
+ try {
651
+ return JSON.parse(extracted);
652
+ } catch {
653
+ throw new RouterError("Model output JSON could not be parsed", "schema_validation");
654
+ }
655
+ }
656
+ }
657
+ function extractJsonCandidate(text) {
658
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
659
+ if (fenced?.[1]) {
660
+ return fenced[1].trim();
661
+ }
662
+ const firstObject = text.indexOf("{");
663
+ const lastObject = text.lastIndexOf("}");
664
+ if (firstObject >= 0 && lastObject > firstObject) {
665
+ return text.slice(firstObject, lastObject + 1);
666
+ }
667
+ const firstArray = text.indexOf("[");
668
+ const lastArray = text.lastIndexOf("]");
669
+ if (firstArray >= 0 && lastArray > firstArray) {
670
+ return text.slice(firstArray, lastArray + 1);
671
+ }
672
+ return void 0;
673
+ }
674
+
675
+ // src/utils/timeout.ts
676
+ async function withAbortableTimeout(timeoutMs, run) {
677
+ const controller = new AbortController();
678
+ if (!timeoutMs || timeoutMs <= 0) {
679
+ return run(controller.signal);
680
+ }
681
+ let timer;
682
+ const timeout = new Promise((_, reject) => {
683
+ timer = setTimeout(() => {
684
+ controller.abort();
685
+ reject(new RouterError(`Provider request timed out after ${timeoutMs}ms`, "timeout"));
686
+ }, timeoutMs);
687
+ });
688
+ try {
689
+ return await Promise.race([run(controller.signal), timeout]);
690
+ } finally {
691
+ if (timer) {
692
+ clearTimeout(timer);
693
+ }
694
+ }
695
+ }
696
+
697
+ // src/router/ModelRouter.ts
698
+ var ModelRouter = class {
699
+ constructor(providers, config, logger = new RunLogger()) {
700
+ this.providers = providers;
701
+ this.config = config;
702
+ this.logger = logger;
703
+ }
704
+ providers;
705
+ config;
706
+ logger;
707
+ async run(request) {
708
+ const taskConfig = this.config.tasks[request.task];
709
+ if (!taskConfig) {
710
+ throw new RouterError(`Task is not configured: ${request.task}`, "config");
711
+ }
712
+ if (request.schemaName && !schemaRegistry[request.schemaName]) {
713
+ throw new RouterError(`Schema is not registered: ${request.schemaName}`, "config");
714
+ }
715
+ const candidates = [taskConfig.primary, ...taskConfig.fallback ?? []];
716
+ let lastError;
717
+ for (const candidate of candidates) {
718
+ const provider = this.providers[candidate.provider];
719
+ if (!provider) {
720
+ lastError = new RouterError(`Provider is not registered: ${candidate.provider}`, "config");
721
+ continue;
722
+ }
723
+ try {
724
+ const startedAt = Date.now();
725
+ const providerRequest = this.buildProviderRequest(request, candidate);
726
+ const response = await this.callProvider(provider, providerRequest);
727
+ const result = this.toModelResponse(candidate, response, Date.now() - startedAt);
728
+ const validated = await this.validateAndMaybeRepair(provider, providerRequest, candidate, result, request.schemaName);
729
+ await this.logger.logSuccess(request, validated);
730
+ return validated;
731
+ } catch (error) {
732
+ const normalized = normalizeProviderError(error);
733
+ lastError = normalized;
734
+ await this.logger.logFailure(request, candidate, normalized);
735
+ if (!shouldFallback(normalized.kind)) {
736
+ throw normalized;
737
+ }
738
+ }
739
+ }
740
+ if (lastError) {
741
+ const normalized = normalizeProviderError(lastError);
742
+ throw new RouterError(`All model candidates failed: ${normalized.message}`, normalized.kind, normalized.statusCode);
743
+ }
744
+ throw new RouterError(`No model candidates configured for task: ${request.task}`, "config");
745
+ }
746
+ buildProviderRequest(request, candidate) {
747
+ const taskConfig = this.config.tasks[request.task];
748
+ return {
749
+ model: candidate.model,
750
+ input: request.schemaName ? withSchemaInstruction(request.input, request.schemaName) : request.input,
751
+ system: request.system,
752
+ temperature: request.temperature ?? taskConfig.temperature,
753
+ maxTokens: request.maxTokens ?? taskConfig.max_tokens,
754
+ timeoutMs: taskConfig.timeout_ms ?? this.config.defaults.timeout_ms,
755
+ responseFormat: request.schemaName ? { type: "json_schema", schemaName: request.schemaName } : { type: "text" }
756
+ };
757
+ }
758
+ async callProvider(provider, request) {
759
+ return withAbortableTimeout(request.timeoutMs, (abortSignal) => provider.generate({ ...request, abortSignal }));
760
+ }
761
+ toModelResponse(candidate, response, elapsedMs) {
762
+ const usage = response.usage ? { ...response.usage } : void 0;
763
+ if (usage && usage.costUsd === void 0) {
764
+ usage.costUsd = estimateCostUsd(usage, this.config.prices[candidate.provider]?.[candidate.model]);
765
+ }
766
+ return {
767
+ provider: candidate.provider,
768
+ model: candidate.model,
769
+ text: response.text,
770
+ usage,
771
+ elapsedMs,
772
+ truncated: response.truncated
773
+ };
774
+ }
775
+ async validateAndMaybeRepair(provider, originalRequest, candidate, response, schemaName) {
776
+ if (!schemaName) {
777
+ return response;
778
+ }
779
+ try {
780
+ return this.validateResponse(response, schemaName);
781
+ } catch (firstError) {
782
+ if (normalizeProviderError(firstError).kind !== "schema_validation") {
783
+ throw firstError;
784
+ }
785
+ }
786
+ const repairRequest = {
787
+ ...originalRequest,
788
+ input: buildRepairPrompt(schemaName, response.text),
789
+ temperature: 0,
790
+ responseFormat: { type: "json_schema", schemaName }
791
+ };
792
+ const startedAt = Date.now();
793
+ const repaired = await this.callProvider(provider, repairRequest);
794
+ const repairedResponse = this.toModelResponse(candidate, repaired, Date.now() - startedAt);
795
+ try {
796
+ return this.validateResponse(repairedResponse, schemaName);
797
+ } catch {
798
+ throw new RouterError(`Model output failed ${schemaName} validation after one repair attempt`, "schema_validation");
799
+ }
800
+ }
801
+ validateResponse(response, schemaName) {
802
+ const schema = schemaRegistry[schemaName];
803
+ if (!schema) {
804
+ throw new RouterError(`Schema is not registered: ${schemaName}`, "config");
805
+ }
806
+ const parsed = parseJsonObject(response.text);
807
+ const validated = schema.safeParse(parsed);
808
+ if (!validated.success) {
809
+ throw new RouterError(`Model output did not match schema ${schemaName}`, "schema_validation");
810
+ }
811
+ return {
812
+ ...response,
813
+ text: `${JSON.stringify(validated.data, null, 2)}
814
+ `
815
+ };
816
+ }
817
+ };
818
+ function withSchemaInstruction(input, schemaName) {
819
+ return [
820
+ input,
821
+ "",
822
+ "\u51FA\u529B\u306F\u6B21\u306EJSON\u30B9\u30AD\u30FC\u30DE\u306B\u53B3\u5BC6\u306B\u5F93\u3063\u3066\u304F\u3060\u3055\u3044\u3002",
823
+ "\u6307\u5B9A\u3055\u308C\u305F\u30AD\u30FC\u306E\u307F\u3092\u542B\u3080JSON\u3092\u8FD4\u3057\u3001\u30B3\u30FC\u30C9\u30D5\u30A7\u30F3\u30B9\u3084\u8AAC\u660E\u6587\u306F\u4ED8\u3051\u306A\u3044\u3067\u304F\u3060\u3055\u3044\u3002",
824
+ "",
825
+ "JSON\u30B9\u30AD\u30FC\u30DE:",
826
+ schemaHints[schemaName]
827
+ ].join("\n");
828
+ }
829
+ function buildRepairPrompt(schemaName, invalidOutput) {
830
+ return [
831
+ `The previous response did not match the ${schemaName} JSON schema.`,
832
+ "Return only corrected JSON that uses exactly the keys below.",
833
+ "Do not include markdown fences or commentary.",
834
+ "",
835
+ "JSON schema:",
836
+ schemaHints[schemaName],
837
+ "",
838
+ "Invalid response:",
839
+ invalidOutput
840
+ ].join("\n");
841
+ }
842
+
843
+ // src/storage/RunStore.ts
844
+ import { mkdir as mkdir4, readFile as readFile4, rm, writeFile as writeFile2 } from "fs/promises";
845
+ import { join as join3, resolve as resolve3, sep as sep2 } from "path";
846
+ var RunStore = class {
847
+ root;
848
+ constructor(root = "runs") {
849
+ this.root = resolve3(root);
850
+ }
851
+ async create(runId, topic, steps, platform, style, profile) {
852
+ const meta = {
853
+ runId: this.validateRunId(runId),
854
+ topic,
855
+ platform,
856
+ style,
857
+ profile,
858
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
859
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
860
+ steps: Object.fromEntries(steps.map((step) => [step, { status: "pending" }]))
861
+ };
862
+ await mkdir4(this.runPath(meta.runId), { recursive: true });
863
+ await this.writeMeta(meta);
864
+ return meta;
865
+ }
866
+ async readMeta(runId) {
867
+ const content = await readFile4(this.filePath(runId, "meta.json"), "utf8");
868
+ return JSON.parse(content);
869
+ }
870
+ async writeMeta(meta) {
871
+ const updated = { ...meta, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
872
+ await mkdir4(this.runPath(updated.runId), { recursive: true });
873
+ await writeFile2(this.filePath(updated.runId, "meta.json"), `${JSON.stringify(updated, null, 2)}
874
+ `, "utf8");
875
+ }
876
+ async save(runId, fileName, content) {
877
+ await mkdir4(this.runPath(runId), { recursive: true });
878
+ await writeFile2(this.filePath(runId, fileName), content.endsWith("\n") ? content : `${content}
879
+ `, "utf8");
880
+ }
881
+ async read(runId, fileName) {
882
+ return readFile4(this.filePath(runId, fileName), "utf8");
883
+ }
884
+ async remove(runId, fileName) {
885
+ await rm(this.filePath(runId, fileName), { force: true });
886
+ }
887
+ async markDone(runId, step, fileName) {
888
+ const meta = await this.readMeta(runId);
889
+ meta.steps[step] = { status: "done", file: fileName };
890
+ await this.writeMeta(meta);
891
+ return meta;
892
+ }
893
+ runPath(runId) {
894
+ const safeRunId = this.validateRunId(runId);
895
+ const candidate = resolve3(this.root, safeRunId);
896
+ if (!isInside(this.root, candidate)) {
897
+ throw new Error("Run path escapes runs root");
898
+ }
899
+ return candidate;
900
+ }
901
+ filePath(runId, fileName) {
902
+ if (fileName.includes("/") || fileName.includes("\\") || fileName.includes("..")) {
903
+ throw new Error(`Invalid file name: ${fileName}`);
904
+ }
905
+ const candidate = resolve3(join3(this.runPath(runId), fileName));
906
+ if (!isInside(this.runPath(runId), candidate)) {
907
+ throw new Error("File path escapes run directory");
908
+ }
909
+ return candidate;
910
+ }
911
+ validateRunId(runId) {
912
+ if (!/^[A-Za-z0-9._-]+$/.test(runId) || runId === "." || runId === ".." || runId.includes("..")) {
913
+ throw new Error(`Invalid run id: ${runId}`);
914
+ }
915
+ return runId;
916
+ }
917
+ };
918
+ function isInside(root, candidate) {
919
+ return candidate === root || candidate.startsWith(`${root}${sep2}`);
920
+ }
921
+
922
+ // src/utils/text.ts
923
+ function stripWrappingCodeFence(text) {
924
+ const trimmed = text.trim();
925
+ const match = trimmed.match(/^```[^\n]*\n([\s\S]*?)\n```$/);
926
+ if (!match) {
927
+ return text;
928
+ }
929
+ const inner = match[1];
930
+ if (inner.includes("```")) {
931
+ return text;
932
+ }
933
+ return inner;
934
+ }
935
+ function detectWrapText(markdown) {
936
+ const warnings = [];
937
+ const trimmed = markdown.trim();
938
+ if (!trimmed) {
939
+ return warnings;
940
+ }
941
+ const lines = trimmed.split("\n");
942
+ const firstNonEmpty = lines.find((line) => line.trim() !== "")?.trim() ?? "";
943
+ const preamblePatterns = [
944
+ /^(以下|下記)(は|に|の)/,
945
+ /改稿|書き直し|リライト|修正版|改訂版/,
946
+ /^(here is|here's|below is)\b/i,
947
+ /^(承知しました|了解しました|わかりました)/
948
+ ];
949
+ if (preamblePatterns.some((pattern) => pattern.test(firstNonEmpty))) {
950
+ warnings.push("\u5192\u982D\u306B\u524D\u7F6E\u304D\uFF08\u4F8B:\u300E\u4EE5\u4E0B\u306F\u2026\u6539\u7A3F\u7248\u3067\u3059\u300F\uFF09\u304C\u6DF7\u5165\u3057\u3066\u3044\u308B\u53EF\u80FD\u6027\u304C\u3042\u308A\u307E\u3059\u3002");
951
+ }
952
+ const tail = lines.filter((line) => line.trim() !== "").slice(-3).join("\n");
953
+ const offerPatterns = [
954
+ "\u51FA\u3057\u76F4\u305B",
955
+ "\u51FA\u3057\u76F4\u3057",
956
+ "\u4F5C\u308A\u76F4\u305B",
957
+ "\u4F5C\u308A\u76F4\u3057",
958
+ "\u3054\u8981\u671B",
959
+ "\u3054\u5E0C\u671B",
960
+ "\u3044\u304B\u304C\u3067\u3057\u3087\u3046\u304B",
961
+ "\u3069\u308C\u304B\u3067",
962
+ "\u3044\u305A\u308C\u304B\u3067",
963
+ "\u5225\u306E\u7248",
964
+ "\u4ED6\u306E\u30D0\u30FC\u30B8\u30E7\u30F3",
965
+ "\u304A\u77E5\u3089\u305B\u304F\u3060\u3055\u3044",
966
+ "\u3054\u6307\u5B9A\u304F\u3060\u3055\u3044",
967
+ "\u5BFE\u5FDC\u3057\u307E\u3059"
968
+ ];
969
+ if (offerPatterns.some((pattern) => tail.includes(pattern))) {
970
+ warnings.push("\u672B\u5C3E\u306B\u8FFD\u52A0\u63D0\u6848\u30FB\u554F\u3044\u304B\u3051\uFF08\u4F8B:\u300E\u2026\u3067\u51FA\u3057\u76F4\u305B\u307E\u3059\u300F\uFF09\u304C\u6DF7\u5165\u3057\u3066\u3044\u308B\u53EF\u80FD\u6027\u304C\u3042\u308A\u307E\u3059\u3002");
971
+ }
972
+ return warnings;
973
+ }
974
+
975
+ // src/workflows/qiitaSteps.ts
976
+ function styleBlock(style) {
977
+ return style ? `
978
+ \u4F5C\u6CD5:
979
+ ${style}
980
+ ` : "";
981
+ }
982
+ var DEFAULT_PLATFORM = "Qiita";
983
+ var qiitaSteps = [
984
+ {
985
+ name: "brief",
986
+ task: "article_brief",
987
+ schemaName: "ArticleBrief",
988
+ file: "brief.json",
989
+ buildInput: async ({ topic, platform }) => `
990
+ \u6B21\u306E\u30C6\u30FC\u30DE\u3067${platform}\u8A18\u4E8B\u306EArticle Brief\u3092\u4F5C\u6210\u3057\u3066\u304F\u3060\u3055\u3044\u3002
991
+
992
+ \u30C6\u30FC\u30DE:
993
+ ${topic}
994
+
995
+ \u51FA\u529B\u306FJSON\u5F62\u5F0F\u3002
996
+ `.trim()
997
+ },
998
+ {
999
+ name: "outline",
1000
+ task: "outline",
1001
+ schemaName: "ArticleOutline",
1002
+ file: "outline.json",
1003
+ buildInput: async ({ runId, store, platform }) => {
1004
+ const brief = await store.read(runId, "brief.json");
1005
+ return `
1006
+ \u6B21\u306EArticle Brief\u304B\u3089${platform}\u8A18\u4E8B\u306E\u69CB\u6210\u3092\u4F5C\u3063\u3066\u304F\u3060\u3055\u3044\u3002
1007
+
1008
+ ${brief}
1009
+ `.trim();
1010
+ }
1011
+ },
1012
+ {
1013
+ name: "draft",
1014
+ task: "draft_markdown",
1015
+ file: "draft.md",
1016
+ buildInput: async ({ runId, store, platform, style }) => {
1017
+ const outline = await store.read(runId, "outline.json");
1018
+ return `
1019
+ \u6B21\u306E\u69CB\u6210\u304B\u3089${platform}\u5411\u3051Markdown\u672C\u6587\u3092\u66F8\u3044\u3066\u304F\u3060\u3055\u3044\u3002
1020
+ \u8A18\u4E8B\u672C\u6587\u306E\u307F\u3092\u51FA\u529B\u3057\u3066\u304F\u3060\u3055\u3044\u3002\u524D\u7F6E\u304D\u30FB\u5F8C\u66F8\u304D\u30FB\u6539\u7A3F\u306E\u8AAC\u660E\u30FB\u8FFD\u52A0\u63D0\u6848\u3084\u9078\u629E\u80A2\u306E\u63D0\u793A\u306F\u542B\u3081\u306A\u3044\u3067\u304F\u3060\u3055\u3044\u3002
1021
+ ${styleBlock(style)}
1022
+ ${outline}
1023
+ `.trim();
1024
+ }
1025
+ },
1026
+ {
1027
+ name: "review",
1028
+ task: "technical_review",
1029
+ schemaName: "ReviewResult",
1030
+ file: "review.json",
1031
+ buildInput: async ({ runId, store, platform }) => {
1032
+ const draft = await store.read(runId, "draft.md");
1033
+ return `
1034
+ \u6B21\u306E${platform}\u8A18\u4E8B\u3092\u6280\u8853\u30EC\u30D3\u30E5\u30FC\u3057\u3066\u304F\u3060\u3055\u3044\u3002
1035
+ \u554F\u984C\u70B9\u3001\u6539\u5584\u6848\u3001\u4FEE\u6B63\u3059\u3079\u304D\u7B87\u6240\u3092JSON\u3067\u8FD4\u3057\u3066\u304F\u3060\u3055\u3044\u3002
1036
+
1037
+ ${draft}
1038
+ `.trim();
1039
+ }
1040
+ },
1041
+ {
1042
+ name: "final",
1043
+ task: "rewrite",
1044
+ file: "final.md",
1045
+ buildInput: async ({ runId, store, platform, style }) => {
1046
+ const draft = await store.read(runId, "draft.md");
1047
+ const review = await store.read(runId, "review.json");
1048
+ return `
1049
+ \u6B21\u306E\u30EC\u30D3\u30E5\u30FC\u3092\u53CD\u6620\u3057\u3066\u3001${platform}\u8A18\u4E8B\u3092\u6539\u5584\u3057\u3066\u304F\u3060\u3055\u3044\u3002
1050
+ \u8A18\u4E8B\u672C\u6587\u306E\u307F\u3092\u51FA\u529B\u3057\u3066\u304F\u3060\u3055\u3044\u3002\u524D\u7F6E\u304D\u30FB\u5F8C\u66F8\u304D\u30FB\u6539\u7A3F\u306E\u8AAC\u660E\u30FB\u8FFD\u52A0\u63D0\u6848\u3084\u9078\u629E\u80A2\u306E\u63D0\u793A\u306F\u542B\u3081\u306A\u3044\u3067\u304F\u3060\u3055\u3044\u3002
1051
+ ${styleBlock(style)}
1052
+ \u8A18\u4E8B:
1053
+ ${draft}
1054
+
1055
+ \u30EC\u30D3\u30E5\u30FC:
1056
+ ${review}
1057
+ `.trim();
1058
+ }
1059
+ }
1060
+ ];
1061
+ function toModelRequest(step, input) {
1062
+ return {
1063
+ task: step.task,
1064
+ input,
1065
+ schemaName: step.schemaName
1066
+ };
1067
+ }
1068
+
1069
+ // src/workflows/createQiitaArticle.ts
1070
+ var noop = () => void 0;
1071
+ async function createQiitaArticle(router, store, topic, options = {}, onEvent = noop) {
1072
+ const runId = options.runId ?? createRunId(topic);
1073
+ await store.create(
1074
+ runId,
1075
+ topic,
1076
+ qiitaSteps.map((step) => step.name),
1077
+ options.platform ?? DEFAULT_PLATFORM,
1078
+ options.style,
1079
+ options.profile
1080
+ );
1081
+ return runQiitaArticle(router, store, runId, void 0, onEvent);
1082
+ }
1083
+ async function resumeQiitaArticle(router, store, runId, onEvent = noop) {
1084
+ return runQiitaArticle(router, store, runId, void 0, onEvent);
1085
+ }
1086
+ async function reviseQiitaFinal(router, store, runId, instruction, onEvent = noop) {
1087
+ const meta = await store.readMeta(runId);
1088
+ const platform = meta.platform ?? DEFAULT_PLATFORM;
1089
+ const current = await store.read(runId, "final.md");
1090
+ await store.save(runId, "final.bak.md", current);
1091
+ const input = [
1092
+ `\u6B21\u306E${platform}\u8A18\u4E8B\u3092\u3001\u4EE5\u4E0B\u306E\u4FEE\u6B63\u6307\u793A\u306B\u5F93\u3063\u3066\u6539\u5584\u3057\u3066\u304F\u3060\u3055\u3044\u3002`,
1093
+ "Markdown\u672C\u6587\u3060\u3051\u3092\u8FD4\u3057\u3066\u304F\u3060\u3055\u3044\u3002\u8AAC\u660E\u3084\u30B3\u30FC\u30C9\u30D5\u30A7\u30F3\u30B9\u3067\u5168\u4F53\u3092\u56F2\u307E\u306A\u3044\u3067\u304F\u3060\u3055\u3044\u3002",
1094
+ ...meta.style ? ["", "\u4F5C\u6CD5:", meta.style] : [],
1095
+ "",
1096
+ "\u4FEE\u6B63\u6307\u793A:",
1097
+ instruction,
1098
+ "",
1099
+ "\u73FE\u5728\u306E\u8A18\u4E8B:",
1100
+ current
1101
+ ].join("\n");
1102
+ onEvent({ type: "step:start", index: 1, total: 1, name: "revise", task: "rewrite" });
1103
+ const response = await router.run({ task: "rewrite", input });
1104
+ const text = stripWrappingCodeFence(response.text);
1105
+ await store.save(runId, "final.md", text);
1106
+ await store.markDone(runId, "final", "final.md");
1107
+ onEvent({
1108
+ type: "step:done",
1109
+ index: 1,
1110
+ total: 1,
1111
+ name: "revise",
1112
+ provider: response.provider,
1113
+ model: response.model,
1114
+ elapsedMs: response.elapsedMs,
1115
+ costUsd: response.usage?.costUsd,
1116
+ truncated: response.truncated,
1117
+ warnings: detectWrapText(text)
1118
+ });
1119
+ return { runId, finalText: text };
1120
+ }
1121
+ var severityRank = {
1122
+ suggestion: 0,
1123
+ minor: 1,
1124
+ major: 2,
1125
+ critical: 3
1126
+ };
1127
+ async function evaluateQiitaFinal(router, store, runId, options = {}, onEvent = noop) {
1128
+ const minSeverity = options.minSeverity ?? "suggestion";
1129
+ const meta = await store.readMeta(runId);
1130
+ const platform = meta.platform ?? DEFAULT_PLATFORM;
1131
+ const final = await store.read(runId, "final.md");
1132
+ const input = [
1133
+ `\u6B21\u306E${platform}\u8A18\u4E8B\u3092\u6280\u8853\u30EC\u30D3\u30E5\u30FC\u3057\u3066\u304F\u3060\u3055\u3044\u3002`,
1134
+ "\u554F\u984C\u70B9\u30FB\u6539\u5584\u6848\u30FB\u4FEE\u6B63\u3059\u3079\u304D\u7B87\u6240\u3092JSON\u3067\u8FD4\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
1135
+ ...options.criteria ? ["", "\u7279\u306B\u6B21\u306E\u8A55\u4FA1\u89B3\u70B9\u3092\u91CD\u8996\u3057\u3066\u304F\u3060\u3055\u3044:", options.criteria] : [],
1136
+ "",
1137
+ "\u8A18\u4E8B:",
1138
+ final
1139
+ ].join("\n");
1140
+ onEvent({ type: "step:start", index: 1, total: 1, name: "evaluate", task: "final_review" });
1141
+ const response = await router.run({ task: "final_review", input, schemaName: "ReviewResult" });
1142
+ await store.save(runId, "final-review.json", response.text);
1143
+ onEvent({
1144
+ type: "step:done",
1145
+ index: 1,
1146
+ total: 1,
1147
+ name: "evaluate",
1148
+ provider: response.provider,
1149
+ model: response.model,
1150
+ elapsedMs: response.elapsedMs,
1151
+ costUsd: response.usage?.costUsd,
1152
+ truncated: response.truncated
1153
+ });
1154
+ const review = JSON.parse(response.text);
1155
+ await store.save(runId, "final-review.md", buildReviewSummary(review));
1156
+ const filtered = review.issues.filter((issue) => severityRank[issue.severity] >= severityRank[minSeverity]);
1157
+ let instructionFile;
1158
+ if (filtered.length > 0) {
1159
+ await store.save(runId, "revise-instruction.md", buildRevisionInstruction(filtered));
1160
+ instructionFile = "revise-instruction.md";
1161
+ } else {
1162
+ await store.remove(runId, "revise-instruction.md");
1163
+ }
1164
+ return {
1165
+ runId,
1166
+ approved: review.approved,
1167
+ issueCount: filtered.length,
1168
+ reviewFile: "final-review.json",
1169
+ reviewSummaryFile: "final-review.md",
1170
+ instructionFile
1171
+ };
1172
+ }
1173
+ function buildReviewSummary(review) {
1174
+ const order = ["critical", "major", "minor", "suggestion"];
1175
+ const countLine = order.map((sev) => `${sev}: ${review.issues.filter((i) => i.severity === sev).length}`).join(" / ");
1176
+ const verdict = review.approved === true ? "approved \u2705" : review.approved === false ? "\u8981\u4FEE\u6B63 \u26A0\uFE0F" : "n/a";
1177
+ const lines = [
1178
+ "# \u30EC\u30D3\u30E5\u30FC\u30B5\u30DE\u30EA",
1179
+ "",
1180
+ `- \u5224\u5B9A: ${verdict}`,
1181
+ `- \u6307\u6458\u4EF6\u6570: ${countLine}\uFF08\u5408\u8A08 ${review.issues.length}\uFF09`,
1182
+ ""
1183
+ ];
1184
+ if (review.summary) {
1185
+ lines.push("## \u6982\u8981", "", review.summary, "");
1186
+ }
1187
+ lines.push("## \u6307\u6458\u4E00\u89A7", "");
1188
+ if (review.issues.length === 0) {
1189
+ lines.push("\u6307\u6458\u306F\u3042\u308A\u307E\u305B\u3093\u3002", "");
1190
+ } else {
1191
+ for (const sev of order) {
1192
+ const items = review.issues.filter((issue) => issue.severity === sev);
1193
+ if (items.length === 0) {
1194
+ continue;
1195
+ }
1196
+ lines.push(`### ${sev}`);
1197
+ for (const issue of items) {
1198
+ const loc = issue.location ? `\uFF08${issue.location}\uFF09` : "";
1199
+ lines.push(`- **\u554F\u984C${loc}**: ${issue.problem}`);
1200
+ lines.push(` - \u63A8\u5968: ${issue.recommendation}`);
1201
+ }
1202
+ lines.push("");
1203
+ }
1204
+ }
1205
+ return lines.join("\n");
1206
+ }
1207
+ function buildRevisionInstruction(issues) {
1208
+ const lines = ["# \u4FEE\u6B63\u6307\u793A\uFF08\u8A55\u4FA1\u7D50\u679C\u304B\u3089\u81EA\u52D5\u751F\u6210 / \u8981\u78BA\u8A8D\uFF09", ""];
1209
+ const order = ["critical", "major", "minor", "suggestion"];
1210
+ for (const severity of order) {
1211
+ const items = issues.filter((issue) => issue.severity === severity);
1212
+ if (items.length === 0) {
1213
+ continue;
1214
+ }
1215
+ lines.push(`## ${severity}`);
1216
+ for (const issue of items) {
1217
+ const loc = issue.location ? `\uFF08${issue.location}\uFF09` : "";
1218
+ lines.push(`- \u554F\u984C${loc}: ${issue.problem}`);
1219
+ lines.push(` - \u63A8\u5968: ${issue.recommendation}`);
1220
+ }
1221
+ lines.push("");
1222
+ }
1223
+ return lines.join("\n");
1224
+ }
1225
+ async function rerunQiitaReview(router, store, runId, onEvent = noop) {
1226
+ const meta = await store.readMeta(runId);
1227
+ meta.steps.review = { status: "pending" };
1228
+ meta.steps.final = { status: "pending" };
1229
+ await store.writeMeta(meta);
1230
+ return runQiitaArticle(router, store, runId, "review", onEvent);
1231
+ }
1232
+ async function runQiitaArticle(router, store, runId, startAt, onEvent = noop) {
1233
+ const meta = await store.readMeta(runId);
1234
+ const platform = meta.platform ?? DEFAULT_PLATFORM;
1235
+ const style = meta.style;
1236
+ const startIndex = startAt ? qiitaSteps.findIndex((step) => step.name === startAt) : 0;
1237
+ const total = qiitaSteps.length;
1238
+ for (const [index, step] of qiitaSteps.entries()) {
1239
+ const position = index + 1;
1240
+ if (startIndex > 0 && index < startIndex) {
1241
+ onEvent({ type: "step:skip", index: position, total, name: step.name });
1242
+ continue;
1243
+ }
1244
+ const currentMeta = await store.readMeta(runId);
1245
+ if (currentMeta.steps[step.name]?.status === "done") {
1246
+ onEvent({ type: "step:skip", index: position, total, name: step.name });
1247
+ continue;
1248
+ }
1249
+ onEvent({ type: "step:start", index: position, total, name: step.name, task: step.task });
1250
+ const input = await step.buildInput({ topic: meta.topic, platform, style, runId, store });
1251
+ const response = await router.run(toModelRequest(step, input));
1252
+ const isProse = !step.schemaName;
1253
+ const text = isProse ? stripWrappingCodeFence(response.text) : response.text;
1254
+ await store.save(runId, step.file, text);
1255
+ await store.markDone(runId, step.name, step.file);
1256
+ onEvent({
1257
+ type: "step:done",
1258
+ index: position,
1259
+ total,
1260
+ name: step.name,
1261
+ provider: response.provider,
1262
+ model: response.model,
1263
+ elapsedMs: response.elapsedMs,
1264
+ costUsd: response.usage?.costUsd,
1265
+ truncated: response.truncated,
1266
+ warnings: isProse ? detectWrapText(text) : void 0
1267
+ });
1268
+ }
1269
+ let finalText;
1270
+ try {
1271
+ finalText = await store.read(runId, "final.md");
1272
+ } catch {
1273
+ finalText = void 0;
1274
+ }
1275
+ return { runId, finalText };
1276
+ }
1277
+ function createRunId(topic) {
1278
+ const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1279
+ const slug = topic.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
1280
+ return `${date}-${slug || "article"}`;
1281
+ }
1282
+
1283
+ // src/index.ts
1284
+ var pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
1285
+ var program = new Command();
1286
+ program.name("llm-task-router").description("Thin ModelRouter CLI for article workflows").version(pkg.version, "-v, --version", "Show the version").showHelpAfterError("(run with --help for usage)");
1287
+ program.command("init").description("Scaffold config/ and .env.example into the current directory").option("--force", "Overwrite existing files").action(async (options) => {
1288
+ const sourceDir = fileURLToPath(new URL("..", import.meta.url));
1289
+ const result = await initConfig(process.cwd(), sourceDir, { force: options.force });
1290
+ for (const file of result.created) {
1291
+ console.log(`created: ${file}`);
1292
+ }
1293
+ for (const file of result.skipped) {
1294
+ console.log(`skipped (exists): ${file}`);
1295
+ }
1296
+ if (result.skipped.length > 0) {
1297
+ console.log("Some files already exist; re-run with --force to overwrite them.");
1298
+ }
1299
+ console.log("Next: copy .env.example to .env and set your API keys, then edit config/models.yaml.");
1300
+ });
1301
+ program.command("article:create").option("--topic <topic>", "Article topic (inline text)").option("--topic-file <path>", "Path to a text file containing the topic / instructions").option("--profile <name>", "Article profile under config/profiles/ (platform + style)", "qiita").option("--platform <name>", "Override the platform label from the profile").option("--run <runId>", "Run id").option("--config <path>", "Path to models.yaml", "config/models.yaml").action(
1302
+ async (options) => {
1303
+ const topic = await resolveText(options.topic, options.topicFile, "topic", "--topic", "--topic-file");
1304
+ const profile = await loadProfile(options.profile);
1305
+ const platform = options.platform ?? profile.platform;
1306
+ const runIdSeed = options.topicFile ? basename3(options.topicFile).replace(/\.[^.]+$/, "") : topic;
1307
+ const runId = options.run ?? createRunId(runIdSeed);
1308
+ const { router, store } = await createRuntime(options.config);
1309
+ const reporter = createProgressReporter();
1310
+ const result = await createQiitaArticle(
1311
+ router,
1312
+ store,
1313
+ topic,
1314
+ { runId, platform, style: profile.style, profile: options.profile },
1315
+ reporter.report
1316
+ );
1317
+ reporter.printTotal();
1318
+ console.log(`runId: ${result.runId}`);
1319
+ console.log(`final: runs/${result.runId}/final.md`);
1320
+ }
1321
+ );
1322
+ program.command("article:resume").requiredOption("--run <runId>", "Run id").option("--config <path>", "Path to models.yaml", "config/models.yaml").action(async (options) => {
1323
+ const { router, store } = await createRuntime(options.config);
1324
+ const reporter = createProgressReporter();
1325
+ const result = await resumeQiitaArticle(router, store, options.run, reporter.report);
1326
+ reporter.printTotal();
1327
+ console.log(`runId: ${result.runId}`);
1328
+ console.log(`final: runs/${result.runId}/final.md`);
1329
+ });
1330
+ program.command("article:review").requiredOption("--run <runId>", "Run id").option("--config <path>", "Path to models.yaml", "config/models.yaml").action(async (options) => {
1331
+ const { router, store } = await createRuntime(options.config);
1332
+ const reporter = createProgressReporter();
1333
+ const result = await rerunQiitaReview(router, store, options.run, reporter.report);
1334
+ reporter.printTotal();
1335
+ console.log(`runId: ${result.runId}`);
1336
+ console.log(`final: runs/${result.runId}/final.md`);
1337
+ });
1338
+ program.command("article:revise").requiredOption("--run <runId>", "Run id").option("--instruction <text>", "Revision instruction for final.md (inline text)").option("--instruction-file <path>", "Path to a text file containing the revision instruction").option("--config <path>", "Path to models.yaml", "config/models.yaml").action(async (options) => {
1339
+ const instruction = await resolveText(
1340
+ options.instruction,
1341
+ options.instructionFile,
1342
+ "instruction",
1343
+ "--instruction",
1344
+ "--instruction-file"
1345
+ );
1346
+ const { router, store } = await createRuntime(options.config);
1347
+ const reporter = createProgressReporter();
1348
+ const result = await reviseQiitaFinal(router, store, options.run, instruction, reporter.report);
1349
+ reporter.printTotal();
1350
+ console.log(`runId: ${result.runId}`);
1351
+ console.log(`final: runs/${result.runId}/final.md (previous: runs/${result.runId}/final.bak.md)`);
1352
+ });
1353
+ program.command("article:export").requiredOption("--run <runId>", "Run id").requiredOption("--out <path>", "Destination path for the final article").option("--force", "Overwrite the destination if it already exists").action(async (options) => {
1354
+ const store = new RunStore();
1355
+ const dest = await exportFinalArticle(store, options.run, options.out, { force: options.force });
1356
+ console.log(`exported: ${dest}`);
1357
+ });
1358
+ function createProgressReporter() {
1359
+ let totalCostUsd = 0;
1360
+ let hasCost = false;
1361
+ const report = (event) => {
1362
+ switch (event.type) {
1363
+ case "step:start":
1364
+ process.stderr.write(`[${event.index}/${event.total}] ${event.name} (${event.task}) ...
1365
+ `);
1366
+ break;
1367
+ case "step:skip":
1368
+ process.stderr.write(`[${event.index}/${event.total}] ${event.name} - skip (done)
1369
+ `);
1370
+ break;
1371
+ case "step:done": {
1372
+ if (event.costUsd !== void 0) {
1373
+ totalCostUsd += event.costUsd;
1374
+ hasCost = true;
1375
+ }
1376
+ const cost = event.costUsd !== void 0 ? `, ~$${event.costUsd.toFixed(4)}` : "";
1377
+ process.stderr.write(
1378
+ `[${event.index}/${event.total}] ${event.name} - done via ${event.provider}/${event.model} (${event.elapsedMs}ms${cost})
1379
+ `
1380
+ );
1381
+ if (event.truncated) {
1382
+ process.stderr.write(
1383
+ ` \u26A0 ${event.name}: \u51FA\u529B\u304C max_tokens \u3067\u6253\u3061\u5207\u3089\u308C\u305F\u53EF\u80FD\u6027\u304C\u3042\u308A\u307E\u3059\u3002models.yaml \u306E max_tokens \u3092\u5897\u3084\u3057\u3066\u518D\u5B9F\u884C\u3057\u3066\u304F\u3060\u3055\u3044\u3002
1384
+ `
1385
+ );
1386
+ }
1387
+ for (const warning of event.warnings ?? []) {
1388
+ process.stderr.write(` \u26A0 ${event.name}: ${warning}
1389
+ `);
1390
+ }
1391
+ break;
1392
+ }
1393
+ }
1394
+ };
1395
+ const printTotal = () => {
1396
+ if (hasCost) {
1397
+ process.stderr.write(`total: ~$${totalCostUsd.toFixed(4)} (estimate)
1398
+ `);
1399
+ }
1400
+ };
1401
+ return { report, printTotal };
1402
+ }
1403
+ program.command("article:evaluate").requiredOption("--run <runId>", "Run id").option("--min-severity <level>", "Minimum severity to include (critical|major|minor|suggestion)", "suggestion").option("--criteria <text>", "Evaluation focus / points (inline text)").option("--criteria-file <path>", "Path to a text file with evaluation focus").option("--config <path>", "Path to models.yaml", "config/models.yaml").action(
1404
+ async (options) => {
1405
+ const minSeverity = parseSeverity(options.minSeverity);
1406
+ const { router, store } = await createRuntime(options.config);
1407
+ const criteria = await resolveEvaluationCriteria(store, options);
1408
+ const reporter = createProgressReporter();
1409
+ const result = await evaluateQiitaFinal(router, store, options.run, { minSeverity, criteria }, reporter.report);
1410
+ reporter.printTotal();
1411
+ console.log(`runId: ${result.runId}`);
1412
+ console.log(
1413
+ `review: runs/${result.runId}/${result.reviewFile} (approved: ${result.approved ?? "n/a"}, issues>=${minSeverity}: ${result.issueCount})`
1414
+ );
1415
+ console.log(`summary: runs/${result.runId}/${result.reviewSummaryFile}`);
1416
+ if (result.instructionFile) {
1417
+ console.log(`instruction: runs/${result.runId}/${result.instructionFile}`);
1418
+ } else {
1419
+ console.log(`instruction: (none \u2014 no issues at or above ${minSeverity})`);
1420
+ }
1421
+ }
1422
+ );
1423
+ async function resolveEvaluationCriteria(store, options) {
1424
+ if (options.criteria !== void 0 || options.criteriaFile !== void 0) {
1425
+ return resolveText(options.criteria, options.criteriaFile, "criteria", "--criteria", "--criteria-file");
1426
+ }
1427
+ const meta = await store.readMeta(options.run);
1428
+ if (meta.profile) {
1429
+ const profile = await loadProfile(meta.profile);
1430
+ if (profile.criteriaFile) {
1431
+ assertSafeInputPath(profile.criteriaFile);
1432
+ const content = (await readFile5(profile.criteriaFile, "utf8")).trim();
1433
+ if (content) {
1434
+ process.stderr.write(`criteria: ${meta.profile} \u30D7\u30ED\u30D5\u30A1\u30A4\u30EB\u306E ${profile.criteriaFile} \u3092\u4F7F\u7528
1435
+ `);
1436
+ return content;
1437
+ }
1438
+ }
1439
+ }
1440
+ return void 0;
1441
+ }
1442
+ function parseSeverity(value) {
1443
+ const allowed = ["critical", "major", "minor", "suggestion"];
1444
+ if (!allowed.includes(value)) {
1445
+ throw new Error(`Invalid --min-severity: ${value} (use critical|major|minor|suggestion)`);
1446
+ }
1447
+ return value;
1448
+ }
1449
+ async function createRuntime(configPath) {
1450
+ const config = await loadRouterConfig(configPath);
1451
+ const providers = createProviders(config);
1452
+ const logger = new RunLogger();
1453
+ const router = new ModelRouter(providers, config, logger);
1454
+ const store = new RunStore();
1455
+ return { router, store };
1456
+ }
1457
+ if (process.argv.slice(2).length === 0) {
1458
+ program.outputHelp();
1459
+ } else {
1460
+ program.parseAsync().catch((error) => {
1461
+ const message = error instanceof Error ? error.message : String(error);
1462
+ console.error(message);
1463
+ process.exitCode = 1;
1464
+ });
1465
+ }