@steve31415/baselib 3.58.1 → 3.59.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/email.js CHANGED
@@ -28,6 +28,27 @@
28
28
  import { serializeError, truncate } from './log-core.js';
29
29
  export const RESEND_API_URL = 'https://api.resend.com/emails';
30
30
  export const DEFAULT_EMAIL_TIMEOUT_MS = 30_000;
31
+ /** The JSON body of Resend's `POST /emails`: optional fields are omitted
32
+ * rather than sent empty. */
33
+ function resendRequestBody(msg) {
34
+ return {
35
+ from: msg.from,
36
+ to: [msg.to],
37
+ subject: msg.subject,
38
+ ...(msg.text !== undefined ? { text: msg.text } : {}),
39
+ ...(msg.html !== undefined ? { html: msg.html } : {}),
40
+ ...(msg.headers && Object.keys(msg.headers).length ? { headers: msg.headers } : {}),
41
+ ...(msg.attachments?.length
42
+ ? {
43
+ attachments: msg.attachments.map((attachment) => ({
44
+ filename: attachment.filename,
45
+ content: attachment.content,
46
+ ...(attachment.contentType ? { content_type: attachment.contentType } : {}),
47
+ })),
48
+ }
49
+ : {}),
50
+ };
51
+ }
31
52
  /**
32
53
  * Send one email through Resend. Never throws: every failure is an ERROR
33
54
  * log (with enough of the message to redo the send by hand) and an
@@ -69,30 +90,14 @@ export async function sendEmail(msg, deps) {
69
90
  ...(deps.idempotencyKey !== undefined ? { 'Idempotency-Key': deps.idempotencyKey } : {}),
70
91
  },
71
92
  signal: AbortSignal.timeout(timeoutMs),
72
- body: JSON.stringify({
73
- from: msg.from,
74
- to: [msg.to],
75
- subject: msg.subject,
76
- ...(msg.text !== undefined ? { text: msg.text } : {}),
77
- ...(msg.html !== undefined ? { html: msg.html } : {}),
78
- ...(msg.headers && Object.keys(msg.headers).length ? { headers: msg.headers } : {}),
79
- ...(msg.attachments?.length
80
- ? {
81
- attachments: msg.attachments.map((attachment) => ({
82
- filename: attachment.filename,
83
- content: attachment.content,
84
- ...(attachment.contentType ? { content_type: attachment.contentType } : {}),
85
- })),
86
- }
87
- : {}),
88
- }),
93
+ body: JSON.stringify(resendRequestBody(msg)),
89
94
  });
90
95
  const elapsedMs = Date.now() - start;
91
96
  const body = await res.text().catch(() => '');
92
97
  if (!res.ok) {
93
- const error = `Resend API ${res.status}: ${truncate(body, 500)}`;
94
- logger.error('email_send_failed', { ...base, status: res.status, body: truncate(body, 500), elapsedMs, ...preview });
95
- return { ok: false, error };
98
+ const bodyPreview = truncate(body, 500);
99
+ logger.error('email_send_failed', { ...base, status: res.status, body: bodyPreview, elapsedMs, ...preview });
100
+ return { ok: false, error: `Resend API ${res.status}: ${bodyPreview}` };
96
101
  }
97
102
  let emailId;
98
103
  try {
@@ -155,6 +155,7 @@ export async function llmComplete(request) {
155
155
  model: modelEntry.apiModelId,
156
156
  tokensUsed: result.tokensUsed,
157
157
  outputTokens: result.outputTokens,
158
+ reasoningTokens: result.reasoningTokens,
158
159
  cacheReadTokens: result.cacheReadTokens,
159
160
  cacheCreationTokens: result.cacheCreationTokens,
160
161
  finishReason: result.finishReason,
@@ -151,8 +151,19 @@ export const geminiProvider = {
151
151
  const data = JSON.parse(responseText);
152
152
  const latencyMs = Date.now() - startTime;
153
153
  const text = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
154
- const tokensUsed = data.usageMetadata?.totalTokenCount || 0;
155
- const outputTokens = data.usageMetadata?.candidatesTokenCount ?? undefined;
154
+ // totalTokenCount = promptTokenCount (cached part included) + candidates
155
+ // + thoughts (verified live on 3.6 Flash, 2026-11-07). Google bills
156
+ // thoughts at the output rate and implicit-cache hits at a tenth of the
157
+ // input rate, so thoughts count as output and the cached part is reported
158
+ // apart and kept out of tokensUsed — the contract Anthropic's usage sets
159
+ // (types.ts LLMResponse.tokensUsed).
160
+ const usage = data.usageMetadata;
161
+ const reasoningTokens = usage?.thoughtsTokenCount || undefined;
162
+ const cacheReadTokens = usage?.cachedContentTokenCount || undefined;
163
+ const outputTokens = usage?.candidatesTokenCount === undefined && reasoningTokens === undefined
164
+ ? undefined
165
+ : (usage?.candidatesTokenCount ?? 0) + (reasoningTokens ?? 0);
166
+ const tokensUsed = Math.max(0, (usage?.totalTokenCount || 0) - (cacheReadTokens ?? 0));
156
167
  const finishReason = data.candidates?.[0]?.finishReason?.toLowerCase() ?? undefined;
157
168
  let json;
158
169
  if (request.jsonSchema) {
@@ -164,8 +175,13 @@ export const geminiProvider = {
164
175
  request.logger?.info('llm.response', {
165
176
  provider: 'gemini', model: request.apiModelId,
166
177
  status: response.status, latencyMs, tokensUsed, outputTokens, finishReason,
178
+ ...(reasoningTokens !== undefined ? { reasoningTokens } : {}),
179
+ ...(cacheReadTokens !== undefined ? { cacheReadTokens } : {}),
167
180
  ...(request.callSite ? { callSite: request.callSite } : {}),
168
181
  });
169
- return { text, json, tokensUsed, outputTokens, finishReason, finishCategory: classifyFinish(finishReason), latencyMs };
182
+ return {
183
+ text, json, tokensUsed, outputTokens, reasoningTokens, cacheReadTokens,
184
+ finishReason, finishCategory: classifyFinish(finishReason), latencyMs,
185
+ };
170
186
  },
171
187
  };
@@ -181,8 +181,18 @@ export const openaiProvider = {
181
181
  const data = JSON.parse(responseText);
182
182
  const latencyMs = Date.now() - startTime;
183
183
  const text = data.choices?.[0]?.message?.content || '';
184
- const outputTokens = data.usage?.completion_tokens ?? undefined;
185
- const tokensUsed = (data.usage?.prompt_tokens || 0) + (data.usage?.completion_tokens || 0);
184
+ // prompt_tokens includes cache reads and writes; completion_tokens
185
+ // includes reasoning (verified live on gpt-5.6-luna, 2026-11-07). OpenAI
186
+ // bills cache reads at a tenth and 30-minute cache writes at 1.25x the
187
+ // input rate, so both are reported apart and kept out of tokensUsed —
188
+ // the contract Anthropic's usage sets (types.ts LLMResponse.tokensUsed).
189
+ const usage = data.usage;
190
+ const outputTokens = usage?.completion_tokens ?? undefined;
191
+ const reasoningTokens = usage?.completion_tokens_details?.reasoning_tokens || undefined;
192
+ const cacheReadTokens = usage?.prompt_tokens_details?.cached_tokens || undefined;
193
+ const cacheCreationTokens = usage?.prompt_tokens_details?.cache_write_tokens || undefined;
194
+ const uncachedInput = Math.max(0, (usage?.prompt_tokens || 0) - (cacheReadTokens ?? 0) - (cacheCreationTokens ?? 0));
195
+ const tokensUsed = uncachedInput + (usage?.completion_tokens || 0);
186
196
  const finishReason = data.choices?.[0]?.finish_reason ?? undefined;
187
197
  let json;
188
198
  if (request.jsonSchema) {
@@ -194,8 +204,14 @@ export const openaiProvider = {
194
204
  request.logger?.info('llm.response', {
195
205
  provider: 'openai', model: request.apiModelId,
196
206
  status: response.status, latencyMs, tokensUsed, outputTokens, finishReason,
207
+ ...(reasoningTokens !== undefined ? { reasoningTokens } : {}),
208
+ ...(cacheReadTokens !== undefined ? { cacheReadTokens } : {}),
209
+ ...(cacheCreationTokens !== undefined ? { cacheCreationTokens } : {}),
197
210
  ...(request.callSite ? { callSite: request.callSite } : {}),
198
211
  });
199
- return { text, json, tokensUsed, outputTokens, finishReason, finishCategory: classifyFinish(finishReason), latencyMs };
212
+ return {
213
+ text, json, tokensUsed, outputTokens, reasoningTokens, cacheReadTokens, cacheCreationTokens,
214
+ finishReason, finishCategory: classifyFinish(finishReason), latencyMs,
215
+ };
200
216
  },
201
217
  };
@@ -116,18 +116,39 @@ export interface LLMResponse {
116
116
  provider: ProviderName;
117
117
  /** The API model ID that produced this response. */
118
118
  model: string;
119
- /** Total tokens used (input + output). */
119
+ /**
120
+ * Tokens billed at the regular rates: uncached input + output (hidden
121
+ * reasoning included). Prompt-cache reads and writes are NOT in it — they
122
+ * are reported apart, as every provider bills them at their own rate — so
123
+ * total input = tokensUsed − outputTokens + cacheReadTokens +
124
+ * cacheCreationTokens, and uncached input = tokensUsed − outputTokens.
125
+ * The same on all three providers (since 3.59.0; before it, Gemini and
126
+ * OpenAI counted cached input in here and Gemini left thinking out of
127
+ * outputTokens).
128
+ */
120
129
  tokensUsed: number;
121
- /** Output tokens only, when the provider reports them separately. */
130
+ /**
131
+ * Output tokens, hidden reasoning included (Gemini's thoughts, OpenAI's
132
+ * reasoning tokens, Anthropic's thinking) — all billed at the output rate.
133
+ * Undefined when the provider reports no split.
134
+ */
122
135
  outputTokens?: number;
123
136
  /**
124
- * Prompt-cache tokens read from the cache (Anthropic only, when caching is
125
- * used). These are billed at a reduced rate vs. regular input tokens.
137
+ * The hidden-reasoning share of {@link outputTokens}, already included in
138
+ * it — informational, never added to output (Gemini `thoughtsTokenCount`,
139
+ * OpenAI `reasoning_tokens`; Anthropic does not report it). Absent when 0.
140
+ */
141
+ reasoningTokens?: number;
142
+ /**
143
+ * Prompt-cache tokens read from the cache: Anthropic when `cacheSystemPrompt`
144
+ * is used, Gemini implicit-cache hits, OpenAI automatic caching. Billed at
145
+ * about a tenth of the input rate (gpt-4o: half). Not in tokensUsed.
126
146
  */
127
147
  cacheReadTokens?: number;
128
148
  /**
129
- * Prompt-cache tokens written to the cache (Anthropic only, when caching is
130
- * used). These are billed at a premium over regular input tokens.
149
+ * Prompt-cache tokens written to the cache (Anthropic with
150
+ * `cacheSystemPrompt`; OpenAI's 30-minute writes on the gpt-5.6 family).
151
+ * Billed at 1.25x the input rate. Not in tokensUsed.
131
152
  */
132
153
  cacheCreationTokens?: number;
133
154
  /**
@@ -230,12 +251,15 @@ export interface ProviderCallRequest {
230
251
  export interface ProviderCallResponse {
231
252
  text: string;
232
253
  json?: unknown;
254
+ /** Uncached input + output; see {@link LLMResponse.tokensUsed}. */
233
255
  tokensUsed: number;
234
- /** Output tokens only, when the provider reports them separately. */
256
+ /** Output tokens, reasoning included; see {@link LLMResponse.outputTokens}. */
235
257
  outputTokens?: number;
236
- /** Prompt-cache read tokens (Anthropic, when caching is used). */
258
+ /** The reasoning share of outputTokens; see {@link LLMResponse.reasoningTokens}. */
259
+ reasoningTokens?: number;
260
+ /** Prompt-cache read tokens; see {@link LLMResponse.cacheReadTokens}. */
237
261
  cacheReadTokens?: number;
238
- /** Prompt-cache creation tokens (Anthropic, when caching is used). */
262
+ /** Prompt-cache creation tokens; see {@link LLMResponse.cacheCreationTokens}. */
239
263
  cacheCreationTokens?: number;
240
264
  /** Normalized finish reason (lowercased common values where known). */
241
265
  finishReason?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.58.1",
3
+ "version": "3.59.1",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",