glotfile 0.7.0 → 0.7.2

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.
@@ -3082,6 +3082,67 @@ async function runBatched(reqs, batchSize, callBatch, onBatchComplete, signal, o
3082
3082
  return results;
3083
3083
  }
3084
3084
 
3085
+ // src/server/ai/pricing.ts
3086
+ function addUsage(into, add) {
3087
+ into.inputTokens += add.inputTokens;
3088
+ into.outputTokens += add.outputTokens;
3089
+ into.cacheCreationInputTokens += add.cacheCreationInputTokens;
3090
+ into.cacheReadInputTokens += add.cacheReadInputTokens;
3091
+ }
3092
+ var BATCH_PRICE_MULTIPLIER = 0.5;
3093
+ var CACHE_WRITE_MULTIPLIER = 1.25;
3094
+ var CACHE_READ_MULTIPLIER = 0.1;
3095
+ function usageCostUsd(usage, ai, multiplier = 1) {
3096
+ if (!usage) return void 0;
3097
+ const pricing = resolvePricing(ai);
3098
+ return pricing ? estimateUsageCostUsd(usage, pricing, multiplier) : void 0;
3099
+ }
3100
+ function estimateUsageCostUsd(usage, pricing, multiplier = 1) {
3101
+ const inputCost = (usage.inputTokens + usage.cacheCreationInputTokens * CACHE_WRITE_MULTIPLIER + usage.cacheReadInputTokens * CACHE_READ_MULTIPLIER) * pricing.inputPerMTok;
3102
+ return (inputCost + usage.outputTokens * pricing.outputPerMTok) / 1e6 * multiplier;
3103
+ }
3104
+ var PRICE_TABLE = [
3105
+ ["claude-fable-5", 10, 50],
3106
+ ["claude-mythos-5", 10, 50],
3107
+ // Deprecated Opus 4.1 / 4.0 cost 3x the 4.5+ generation — they must outrank
3108
+ // the shorter "claude-opus-4" prefix (4-2025 covers the dated Opus 4 full IDs).
3109
+ ["claude-opus-4-1", 15, 75],
3110
+ ["claude-opus-4-0", 15, 75],
3111
+ ["claude-opus-4-2025", 15, 75],
3112
+ ["claude-opus-4", 5, 25],
3113
+ ["claude-sonnet-4", 3, 15],
3114
+ ["claude-haiku-4", 1, 5],
3115
+ ["claude-3-5-haiku", 0.8, 4],
3116
+ ["gpt-5.5-pro", 30, 180],
3117
+ ["gpt-5.5", 5, 30],
3118
+ ["gpt-5.4-pro", 30, 180],
3119
+ ["gpt-5.4-mini", 0.75, 4.5],
3120
+ ["gpt-5.4-nano", 0.2, 1.25],
3121
+ ["gpt-5.4", 2.5, 15],
3122
+ ["gpt-5.3-codex", 1.75, 14]
3123
+ ];
3124
+ var FREE_PROVIDERS = /* @__PURE__ */ new Set(["ollama", "claude-code"]);
3125
+ function bareModelId(model) {
3126
+ let id = model.trim().toLowerCase();
3127
+ const slash = id.lastIndexOf("/");
3128
+ if (slash !== -1) id = id.slice(slash + 1);
3129
+ const anth = id.lastIndexOf("anthropic.");
3130
+ if (anth !== -1) id = id.slice(anth + "anthropic.".length);
3131
+ return id;
3132
+ }
3133
+ function resolvePricing(ai) {
3134
+ if (ai.inputPricePerMTok !== void 0 && ai.outputPricePerMTok !== void 0) {
3135
+ return { source: "profile", inputPerMTok: ai.inputPricePerMTok, outputPerMTok: ai.outputPricePerMTok };
3136
+ }
3137
+ if (FREE_PROVIDERS.has(ai.provider)) return { source: "builtin", inputPerMTok: 0, outputPerMTok: 0 };
3138
+ const id = bareModelId(ai.model);
3139
+ let best;
3140
+ for (const row of PRICE_TABLE) {
3141
+ if (id.startsWith(row[0]) && (!best || row[0].length > best[0].length)) best = row;
3142
+ }
3143
+ return best ? { source: "builtin", inputPerMTok: best[1], outputPerMTok: best[2] } : null;
3144
+ }
3145
+
3085
3146
  // src/server/ai/anthropic.ts
3086
3147
  var AnthropicProvider = class {
3087
3148
  constructor(config, client) {
@@ -3097,9 +3158,25 @@ var AnthropicProvider = class {
3097
3158
  }
3098
3159
  config;
3099
3160
  client;
3161
+ usage = { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 };
3100
3162
  supportsVision() {
3101
3163
  return true;
3102
3164
  }
3165
+ recordUsage(usage) {
3166
+ if (!usage) return;
3167
+ addUsage(this.usage, {
3168
+ inputTokens: usage.input_tokens ?? 0,
3169
+ outputTokens: usage.output_tokens ?? 0,
3170
+ cacheCreationInputTokens: usage.cache_creation_input_tokens ?? 0,
3171
+ cacheReadInputTokens: usage.cache_read_input_tokens ?? 0
3172
+ });
3173
+ }
3174
+ takeUsage() {
3175
+ const taken = this.usage;
3176
+ this.usage = { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 };
3177
+ const any = taken.inputTokens || taken.outputTokens || taken.cacheCreationInputTokens || taken.cacheReadInputTokens;
3178
+ return any ? taken : void 0;
3179
+ }
3103
3180
  translate(reqs, onBatchComplete, signal, onMalformedReply) {
3104
3181
  return runBatched(reqs, this.config.batchSize, (batch, sig) => this.callBatch(batch, sig), onBatchComplete, signal, onMalformedReply);
3105
3182
  }
@@ -3132,6 +3209,7 @@ var AnthropicProvider = class {
3132
3209
  output_config: { format: { type: "json_schema", schema: req.schema } },
3133
3210
  messages: [{ role: "user", content }]
3134
3211
  });
3212
+ this.recordUsage(res.usage);
3135
3213
  const text = res.content.find((b) => b.type === "text")?.text ?? "{}";
3136
3214
  try {
3137
3215
  return JSON.parse(text);
@@ -3173,6 +3251,7 @@ var AnthropicProvider = class {
3173
3251
  out.set(entry.custom_id, { type: "failed", error: entry.result.error?.message ?? entry.result.type });
3174
3252
  continue;
3175
3253
  }
3254
+ this.recordUsage(entry.result.message?.usage);
3176
3255
  const text = entry.result.message?.content.find((b) => b.type === "text")?.text ?? "";
3177
3256
  try {
3178
3257
  out.set(entry.custom_id, { type: "items", items: parseReplyItems(text) });
@@ -3195,6 +3274,7 @@ var AnthropicProvider = class {
3195
3274
  output_config: { format: { type: "json_schema", schema: BATCH_SCHEMA } },
3196
3275
  messages: [{ role: "user", content }]
3197
3276
  }, { signal });
3277
+ this.recordUsage(res.usage);
3198
3278
  const text = res.content.find((b) => b.type === "text")?.text ?? "";
3199
3279
  return parseReplyItems(text);
3200
3280
  }
@@ -3760,6 +3840,24 @@ function clearPendingBatch(projectRoot) {
3760
3840
  rmSync4(pendingBatchPath(projectRoot), { force: true });
3761
3841
  }
3762
3842
 
3843
+ // src/server/log.ts
3844
+ import { appendFileSync, readFileSync as readFileSync9, existsSync as existsSync9 } from "fs";
3845
+ import { resolve as resolve6 } from "path";
3846
+ function logPath(projectRoot) {
3847
+ return resolve6(projectRoot, ".glotfile", "log.jsonl");
3848
+ }
3849
+ function appendLog(projectRoot, entry) {
3850
+ ensureGlotfileDir(projectRoot);
3851
+ appendFileSync(logPath(projectRoot), JSON.stringify(entry) + "\n", "utf8");
3852
+ }
3853
+ function readLog(projectRoot, limit = 100) {
3854
+ const path = logPath(projectRoot);
3855
+ if (!existsSync9(path)) return [];
3856
+ const lines = readFileSync9(path, "utf8").split("\n").filter((l) => l.trim() !== "");
3857
+ const entries = lines.map((l) => JSON.parse(l));
3858
+ return entries.reverse().slice(0, limit);
3859
+ }
3860
+
3763
3861
  // src/server/ai/batch-run.ts
3764
3862
  function buildBatchJobs(reqs, batchSize) {
3765
3863
  const byLocale = /* @__PURE__ */ new Map();
@@ -3774,7 +3872,8 @@ function buildBatchJobs(reqs, batchSize) {
3774
3872
  const jobs = [];
3775
3873
  for (const [locale, group] of byLocale) {
3776
3874
  chunk(group, Math.max(1, batchSize)).forEach((batch, i) => {
3777
- jobs.push({ customId: `${locale}#${i}`, locale, requests: batch });
3875
+ const safeLocale = locale.replace(/[^a-zA-Z0-9-]/g, "-").slice(0, 56);
3876
+ jobs.push({ customId: `${safeLocale}_${i}`, locale, requests: batch });
3778
3877
  });
3779
3878
  }
3780
3879
  return jobs;
@@ -3806,7 +3905,9 @@ async function submitBatchTranslation(state, provider, reqs, batchSize, model, p
3806
3905
  return pending;
3807
3906
  }
3808
3907
  async function applyBatchResults(load, persist, provider, pending, projectRoot, ai) {
3908
+ provider.takeUsage?.();
3809
3909
  const outcomes = await provider.translationBatchResults(pending.batchId);
3910
+ const batchUsage = provider.takeUsage?.();
3810
3911
  const fresh = load();
3811
3912
  const isStale = (r) => {
3812
3913
  const entry = fresh.keys[r.key];
@@ -3815,13 +3916,19 @@ async function applyBatchResults(load, persist, provider, pending, projectRoot,
3815
3916
  const applied = [];
3816
3917
  const results = [];
3817
3918
  const retryReqs = [];
3818
- let staleSkipped = 0;
3919
+ const stale = [];
3920
+ const jobFailures = [];
3819
3921
  for (const job of pending.jobs) {
3820
3922
  const outcome = outcomes.get(job.customId);
3821
3923
  const itemsById = outcome?.type === "items" ? new Map(outcome.items.map((i) => [i.id, i])) : null;
3924
+ if (!itemsById) {
3925
+ if (!outcome) jobFailures.push({ customId: job.customId, locale: job.locale, type: "missing" });
3926
+ else if (outcome.type === "malformed") jobFailures.push({ customId: job.customId, locale: job.locale, type: "malformed", raw: outcome.raw });
3927
+ else if (outcome.type === "failed") jobFailures.push({ customId: job.customId, locale: job.locale, type: "failed", error: outcome.error });
3928
+ }
3822
3929
  for (const stored of job.requests) {
3823
3930
  if (isStale(stored)) {
3824
- staleSkipped++;
3931
+ stale.push({ key: stored.key, locale: stored.targetLocale });
3825
3932
  continue;
3826
3933
  }
3827
3934
  const { sourceHash: _hash, ...req } = stored;
@@ -3840,7 +3947,20 @@ async function applyBatchResults(load, persist, provider, pending, projectRoot,
3840
3947
  const retryResults = await runLocaleParallel(
3841
3948
  retryReqs,
3842
3949
  provider,
3843
- {},
3950
+ {
3951
+ // Record the raw reply so an unparseable retry response is diagnosable
3952
+ // from the activity log instead of vanishing into per-item errors.
3953
+ onMalformedReply: (raw, batchSize, locale) => {
3954
+ appendLog(projectRoot, {
3955
+ at: (/* @__PURE__ */ new Date()).toISOString(),
3956
+ kind: "translate",
3957
+ summary: `Malformed model reply (${locale}, batch of ${batchSize})`,
3958
+ model: pending.model,
3959
+ locale,
3960
+ raw
3961
+ });
3962
+ }
3963
+ },
3844
3964
  ai.concurrency,
3845
3965
  void 0,
3846
3966
  ai.batchSize
@@ -3848,53 +3968,34 @@ async function applyBatchResults(load, persist, provider, pending, projectRoot,
3848
3968
  applied.push(...retryReqs);
3849
3969
  results.push(...retryResults);
3850
3970
  }
3971
+ const retryUsage = provider.takeUsage?.();
3972
+ const pricing = resolvePricing({ ...ai, model: pending.model });
3973
+ let estimatedCostUsd;
3974
+ if (pricing && (batchUsage || retryUsage)) {
3975
+ estimatedCostUsd = (batchUsage ? estimateUsageCostUsd(batchUsage, pricing, BATCH_PRICE_MULTIPLIER) : 0) + (retryUsage ? estimateUsageCostUsd(retryUsage, pricing) : 0);
3976
+ }
3977
+ let usage;
3978
+ if (batchUsage || retryUsage) {
3979
+ usage = batchUsage ?? { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 };
3980
+ if (retryUsage) addUsage(usage, retryUsage);
3981
+ }
3851
3982
  const { written, errors } = applyResults(fresh, applied, results);
3852
3983
  persist(fresh);
3853
3984
  clearPendingBatch(projectRoot);
3854
- return { written, errors, staleSkipped, retried: retryReqs.length, screenshotsSkipped };
3855
- }
3856
-
3857
- // src/server/ai/pricing.ts
3858
- var PRICE_TABLE = [
3859
- ["claude-fable-5", 10, 50],
3860
- ["claude-mythos-5", 10, 50],
3861
- // Deprecated Opus 4.1 / 4.0 cost 3x the 4.5+ generation — they must outrank
3862
- // the shorter "claude-opus-4" prefix (4-2025 covers the dated Opus 4 full IDs).
3863
- ["claude-opus-4-1", 15, 75],
3864
- ["claude-opus-4-0", 15, 75],
3865
- ["claude-opus-4-2025", 15, 75],
3866
- ["claude-opus-4", 5, 25],
3867
- ["claude-sonnet-4", 3, 15],
3868
- ["claude-haiku-4", 1, 5],
3869
- ["claude-3-5-haiku", 0.8, 4],
3870
- ["gpt-5.5-pro", 30, 180],
3871
- ["gpt-5.5", 5, 30],
3872
- ["gpt-5.4-pro", 30, 180],
3873
- ["gpt-5.4-mini", 0.75, 4.5],
3874
- ["gpt-5.4-nano", 0.2, 1.25],
3875
- ["gpt-5.4", 2.5, 15],
3876
- ["gpt-5.3-codex", 1.75, 14]
3877
- ];
3878
- var FREE_PROVIDERS = /* @__PURE__ */ new Set(["ollama", "claude-code"]);
3879
- function bareModelId(model) {
3880
- let id = model.trim().toLowerCase();
3881
- const slash = id.lastIndexOf("/");
3882
- if (slash !== -1) id = id.slice(slash + 1);
3883
- const anth = id.lastIndexOf("anthropic.");
3884
- if (anth !== -1) id = id.slice(anth + "anthropic.".length);
3885
- return id;
3886
- }
3887
- function resolvePricing(ai) {
3888
- if (ai.inputPricePerMTok !== void 0 && ai.outputPricePerMTok !== void 0) {
3889
- return { source: "profile", inputPerMTok: ai.inputPricePerMTok, outputPerMTok: ai.outputPricePerMTok };
3890
- }
3891
- if (FREE_PROVIDERS.has(ai.provider)) return { source: "builtin", inputPerMTok: 0, outputPerMTok: 0 };
3892
- const id = bareModelId(ai.model);
3893
- let best;
3894
- for (const row of PRICE_TABLE) {
3895
- if (id.startsWith(row[0]) && (!best || row[0].length > best[0].length)) best = row;
3896
- }
3897
- return best ? { source: "builtin", inputPerMTok: best[1], outputPerMTok: best[2] } : null;
3985
+ const costSuffix = estimatedCostUsd !== void 0 ? ` (~$${estimatedCostUsd.toFixed(2)})` : "";
3986
+ appendLog(projectRoot, {
3987
+ at: (/* @__PURE__ */ new Date()).toISOString(),
3988
+ kind: "translate",
3989
+ summary: `Applied batch ${pending.batchId}: wrote ${written}, ${errors.length} error(s), ${retryReqs.length} retried, ${stale.length} stale${costSuffix}`,
3990
+ model: pending.model,
3991
+ items: applied.map((r) => ({ id: r.id, key: r.key, source: r.source, targetLocale: r.targetLocale })),
3992
+ results,
3993
+ jobFailures: jobFailures.length ? jobFailures : void 0,
3994
+ stale: stale.length ? stale : void 0,
3995
+ usage,
3996
+ estimatedCostUsd
3997
+ });
3998
+ return { written, errors, staleSkipped: stale.length, retried: retryReqs.length, screenshotsSkipped };
3898
3999
  }
3899
4000
 
3900
4001
  // src/server/ai/estimate.ts
@@ -3950,24 +4051,6 @@ function estimateTranslation(state, ai, opts) {
3950
4051
  };
3951
4052
  }
3952
4053
 
3953
- // src/server/log.ts
3954
- import { appendFileSync, readFileSync as readFileSync9, existsSync as existsSync9 } from "fs";
3955
- import { resolve as resolve6 } from "path";
3956
- function logPath(projectRoot) {
3957
- return resolve6(projectRoot, ".glotfile", "log.jsonl");
3958
- }
3959
- function appendLog(projectRoot, entry) {
3960
- ensureGlotfileDir(projectRoot);
3961
- appendFileSync(logPath(projectRoot), JSON.stringify(entry) + "\n", "utf8");
3962
- }
3963
- function readLog(projectRoot, limit = 100) {
3964
- const path = logPath(projectRoot);
3965
- if (!existsSync9(path)) return [];
3966
- const lines = readFileSync9(path, "utf8").split("\n").filter((l) => l.trim() !== "");
3967
- const entries = lines.map((l) => JSON.parse(l));
3968
- return entries.reverse().slice(0, limit);
3969
- }
3970
-
3971
4054
  // src/server/import/run.ts
3972
4055
  import { relative as relative3 } from "path";
3973
4056
 
@@ -6292,6 +6375,7 @@ function createApi(deps) {
6292
6375
  persist(fresh);
6293
6376
  totalWritten += written;
6294
6377
  allErrors.push(...errors);
6378
+ const usage = provider.takeUsage?.();
6295
6379
  appendLog(projectRoot, {
6296
6380
  at: (/* @__PURE__ */ new Date()).toISOString(),
6297
6381
  kind: "translate",
@@ -6302,7 +6386,9 @@ function createApi(deps) {
6302
6386
  const req = reqById.get(r.id);
6303
6387
  return { id: r.id, key: req?.key ?? "", source: req?.source ?? "", targetLocale: req?.targetLocale, context: req?.context, glossary: req?.glossary, screenshot: req ? fresh.keys[req.key]?.screenshot : void 0 };
6304
6388
  }),
6305
- results: batchResults
6389
+ results: batchResults,
6390
+ usage,
6391
+ estimatedCostUsd: usageCostUsd(usage, aiCfg)
6306
6392
  });
6307
6393
  const ld = (localeDone.get(locale) ?? 0) + batchResults.length;
6308
6394
  localeDone.set(locale, ld);
@@ -6374,11 +6460,14 @@ function createApi(deps) {
6374
6460
  }, aiCfg.concurrency, void 0, aiCfg.batchSize);
6375
6461
  const latest = load();
6376
6462
  ({ written, errors } = applyResults(latest, toTranslate, results, void 0, force));
6463
+ const usage = provider.takeUsage?.();
6377
6464
  const entry = {
6378
6465
  at: (/* @__PURE__ */ new Date()).toISOString(),
6379
6466
  kind: "translate",
6380
6467
  summary: `Translated ${toTranslate.length} item(s)`,
6381
6468
  model: aiCfg.model,
6469
+ usage,
6470
+ estimatedCostUsd: usageCostUsd(usage, aiCfg),
6382
6471
  system: buildSystemPrompt(toTranslate.some((r) => r.plural !== void 0)),
6383
6472
  // Log the screenshot PATH only — never the image bytes.
6384
6473
  items: toTranslate.map((r) => ({
@@ -6476,17 +6565,7 @@ function createApi(deps) {
6476
6565
  if (!supportsBatchTranslate(provider)) {
6477
6566
  return c.json({ error: `Provider "${aiCfg.provider}" does not support batch mode.` }, 400);
6478
6567
  }
6479
- const outcome = await applyBatchResults(load, persist, provider, pending, projectRoot, {
6480
- batchSize: aiCfg.batchSize,
6481
- concurrency: aiCfg.concurrency
6482
- });
6483
- appendLog(projectRoot, {
6484
- at: (/* @__PURE__ */ new Date()).toISOString(),
6485
- kind: "translate",
6486
- summary: `Applied batch ${pending.batchId}: wrote ${outcome.written}, ${outcome.retried} retried, ${outcome.staleSkipped} stale`,
6487
- model: aiCfg.model,
6488
- results: []
6489
- });
6568
+ const outcome = await applyBatchResults(load, persist, provider, pending, projectRoot, aiCfg);
6490
6569
  console.log(`[batch] applied ${pending.batchId} \u2014 wrote ${outcome.written}, ${outcome.errors.length} error(s)`);
6491
6570
  return c.json(outcome);
6492
6571
  }));
@@ -6637,6 +6716,7 @@ function createApi(deps) {
6637
6716
  const batch = raw;
6638
6717
  const fresh = load();
6639
6718
  const { written, errors } = applyContext(fresh, chunk2, batch.items ?? [], void 0, body.force === true);
6719
+ const usage = provider.takeUsage?.();
6640
6720
  appendLog(projectRoot, {
6641
6721
  at: (/* @__PURE__ */ new Date()).toISOString(),
6642
6722
  kind: "context",
@@ -6644,7 +6724,9 @@ function createApi(deps) {
6644
6724
  model: aiCfg.model,
6645
6725
  system,
6646
6726
  items: chunk2.map((t) => ({ id: t.id, key: t.key, source: t.source })),
6647
- results: (batch.items ?? []).map((r) => ({ id: r.id, value: r.context, error: r.error }))
6727
+ results: (batch.items ?? []).map((r) => ({ id: r.id, value: r.context, error: r.error })),
6728
+ usage,
6729
+ estimatedCostUsd: usageCostUsd(usage, aiCfg)
6648
6730
  });
6649
6731
  persist(fresh);
6650
6732
  totalWritten += written;