@chrischall/gemini-mcp 1.13.0 → 1.14.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/dist/client.js CHANGED
@@ -175,7 +175,7 @@ export function normalizeFileName(ref) {
175
175
  const match = /(?:^|\/)(files\/[\w-]+)\/?$/.exec(trimmed);
176
176
  if (!match) {
177
177
  throw new McpToolError(`Not a Gemini Files API reference: "${ref}"`, {
178
- hint: 'Pass the `files/<id>` name (or the full https://…/v1beta/files/<id> uri) returned by gemini_upload_file / POST /upload.',
178
+ hint: 'Pass the `files/<id>` name (or the full https://…/v1beta/files/<id> uri) returned by gemini_upload_file.',
179
179
  });
180
180
  }
181
181
  return match[1];
@@ -372,8 +372,13 @@ export class GeminiClient {
372
372
  * payload, an inline `gemini_upload_file` body) to the Files API. Same three
373
373
  * steps as {@link uploadVideo}, minus the filesystem — which is what makes it
374
374
  * usable on the hosted connector, where there is no disk to stream from.
375
+ *
376
+ * `signal` bounds the two HTTP round trips. It exists for callers that
377
+ * upload OPPORTUNISTICALLY — the base64 promotion in `inputs.ts` puts an
378
+ * upload in front of a generation the caller did not ask to wait for, so a
379
+ * stalled upload must be abandonable rather than open-ended.
375
380
  */
376
- async uploadBytes(bytes, mimeType, displayName) {
381
+ async uploadBytes(bytes, mimeType, displayName, signal) {
377
382
  const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
378
383
  if (view.byteLength > FILE_MAX_BYTES) {
379
384
  throw new McpToolError(`File is ${view.byteLength} bytes, over the Gemini Files API limit of ${FILE_MAX_BYTES} bytes (2 GB).`);
@@ -386,24 +391,7 @@ export class GeminiClient {
386
391
  // `ArrayBuffer`-backed views, while a `Uint8Array` is generic in its buffer
387
392
  // type. The runtime accepts any view.
388
393
  const body = new Blob([view], { type: mimeType });
389
- return this.uploadToFilesApi(body, mimeType, displayName, view.byteLength);
390
- }
391
- /**
392
- * Upload a request body straight through to the Files API without buffering
393
- * it — the direct-upload HTTP endpoint (`POST /upload` on the Worker) hands
394
- * us a `ReadableStream` and a `Content-Length`, and the whole point of that
395
- * endpoint is that the bytes never materialize anywhere they'd cost context
396
- * or memory.
397
- *
398
- * `contentLength` is REQUIRED because the resumable protocol's start step
399
- * declares it up front (`X-Goog-Upload-Header-Content-Length`); a stream of
400
- * unknown length has to be buffered by the caller first.
401
- */
402
- async uploadStream(stream, mimeType, displayName, contentLength) {
403
- if (contentLength > FILE_MAX_BYTES) {
404
- throw new McpToolError(`File is ${contentLength} bytes, over the Gemini Files API limit of ${FILE_MAX_BYTES} bytes (2 GB).`);
405
- }
406
- return this.uploadToFilesApi(stream, mimeType, displayName, contentLength);
394
+ return this.uploadToFilesApi(body, mimeType, displayName, view.byteLength, signal);
407
395
  }
408
396
  /**
409
397
  * The resumable upload itself. Three live-verified steps (docs/GEMINI-API.md
@@ -420,10 +408,15 @@ export class GeminiClient {
420
408
  * These two upload calls are raw `fetchImpl` (not `createApiClient`): step 1
421
409
  * needs the response *header* and step 2 posts a binary body — neither fits
422
410
  * `fetchJson`. The poll DOES go through the shared client (timeout + retry).
423
- * Uploads deliberately have no abort timeout: a multi-hundred-MB file can
424
- * legitimately take longer than any fixed budget.
411
+ * Uploads carry no abort timeout by default: a multi-hundred-MB file can
412
+ * legitimately take longer than any fixed budget. A caller that is uploading
413
+ * OPPORTUNISTICALLY passes its own `signal` (see `uploadBytes`).
425
414
  */
426
- async uploadToFilesApi(body, mimeType, displayName, contentLength) {
415
+ async uploadToFilesApi(body, mimeType, displayName, contentLength,
416
+ /** Bounds the two upload round trips. The PROCESSING poll below is left
417
+ * alone: it is already bounded by FILE_POLL_MAX_ATTEMPTS, and it runs for
418
+ * video rather than for the images this is used to bound. */
419
+ signal) {
427
420
  const key = this.requireKey();
428
421
  // Local alias so the calls below carry NO receiver: `this.fetchImpl(...)`
429
422
  // would hand native fetch this GeminiClient as `this`, which workerd
@@ -442,6 +435,7 @@ export class GeminiClient {
442
435
  'Content-Type': 'application/json',
443
436
  },
444
437
  body: JSON.stringify({ file: { display_name: displayName } }),
438
+ ...(signal ? { signal } : {}),
445
439
  });
446
440
  if (!startRes.ok) {
447
441
  throw new McpToolError(formatApiError(startRes.status, 'POST', `/upload/v1beta${startPath}`, await startRes.text(), { service: SERVICE }));
@@ -454,23 +448,19 @@ export class GeminiClient {
454
448
  }
455
449
  // 2. upload + finalize — single shot; the session URL is self-authorizing
456
450
  // (no api-key header needed; verified live).
457
- // A Blob body is left EXACTLY as the live-verified video upload sent it:
451
+ // The Blob body is left EXACTLY as the live-verified video upload sent it:
458
452
  // fetch derives Content-Length from the blob, and setting the header by
459
- // hand is the kind of change that breaks a verified path for no gain. A
460
- // stream has no inherent length, so it declares one — otherwise the runtime
461
- // falls back to chunked encoding, which the resumable protocol does not
462
- // accept (this half is docs-derived; see docs/GEMINI-API.md).
463
- const streaming = !(body instanceof Blob);
453
+ // hand is the kind of change that breaks a verified path for no gain.
454
+ // (A ReadableStream branch lived here for the retired direct-upload HTTP
455
+ // endpoint. Nothing streams into this function any more.)
464
456
  const upRes = await doFetch(uploadUrl, {
465
457
  method: 'POST',
466
458
  headers: {
467
459
  'X-Goog-Upload-Offset': '0',
468
460
  'X-Goog-Upload-Command': 'upload, finalize',
469
- ...(streaming ? { 'Content-Length': String(contentLength) } : {}),
470
461
  },
471
462
  body: body,
472
- // Required by undici/workerd to send a ReadableStream body at all.
473
- ...(streaming ? { duplex: 'half' } : {}),
463
+ ...(signal ? { signal } : {}),
474
464
  });
475
465
  if (!upRes.ok) {
476
466
  throw new McpToolError(formatApiError(upRes.status, 'POST', '/upload/v1beta/files (finalize)', await upRes.text(), { service: SERVICE }));
@@ -541,7 +531,7 @@ export class GeminiClient {
541
531
  const file = await this.call('GET', `/${resource}`);
542
532
  if (!file.uri) {
543
533
  throw new McpToolError(`${SERVICE} returned no uri for ${resource}`, {
544
- hint: 'Files expire ~48h after upload — re-upload with gemini_upload_file (or POST /upload) and use the new uri.',
534
+ hint: 'Files expire ~48h after upload — re-upload with gemini_upload_file and use the new uri.',
545
535
  });
546
536
  }
547
537
  if (file.state && file.state !== 'ACTIVE') {
@@ -706,6 +696,8 @@ export class GeminiClient {
706
696
  responseFormat.delivery = delivery;
707
697
  if (opts.aspectRatio)
708
698
  responseFormat.aspect_ratio = opts.aspectRatio;
699
+ if (opts.resolution)
700
+ responseFormat.resolution = opts.resolution;
709
701
  const body = { model, input: inputParts, response_format: responseFormat };
710
702
  if (opts.task)
711
703
  body.generation_config = { video_config: { task: opts.task } };
@@ -755,9 +747,20 @@ export class GeminiClient {
755
747
  const inputParts = [{ type: 'text', text: opts.input }];
756
748
  for (const img of opts.images ?? [])
757
749
  inputParts.push(interactPart(img));
750
+ // No format field. The one the server used to send — `audio_format` —
751
+ // does not exist: every call carrying it answered `400 Unknown parameter
752
+ // 'audio_format' at 'response_format'`, on every Lyria model, which is
753
+ // what a docs-derived shape buys you. The real field is `mime_type`
754
+ // (`audio/mp3 | audio/ogg_opus | audio/l16 | audio/wav | audio/alaw |
755
+ // audio/mulaw`), and as of 2026-09-09 every value but MP3 is refused
756
+ // per-model — `Audio MIME type AUDIO_WAV is not supported for
757
+ // models/lyria-3.5`, and the same for the clip model. So there is no
758
+ // format lever to expose yet; when there is, it goes here as `mime_type`.
759
+ //
760
+ // `delivery` is absent for the same class of reason: the enum accepts
761
+ // `uri`, the runtime answers `Audio delivery mode is not supported.`
762
+ // (re-verified 2026-09-09). Lyria audio always comes back inline base64.
758
763
  const responseFormat = { type: 'audio' };
759
- if (opts.audioFormat)
760
- responseFormat.audio_format = opts.audioFormat;
761
764
  const body = { model, input: inputParts, response_format: responseFormat };
762
765
  if (opts.previousInteractionId !== undefined)
763
766
  body.previous_interaction_id = opts.previousInteractionId;
package/dist/inputs.js CHANGED
@@ -10,6 +10,14 @@ export function hasImageInput(args) {
10
10
  args.images_r2_keys?.length ||
11
11
  args.from_clipboard);
12
12
  }
13
+ /**
14
+ * How long the opportunistic base64 upload gets before it is abandoned.
15
+ *
16
+ * The promotion adds two round trips in front of a generation nobody asked to
17
+ * wait for, and `uploadToFilesApi` is otherwise untimed. Past this the call
18
+ * proceeds inline: slower to repeat, but not stalled.
19
+ */
20
+ const BASE64_UPLOAD_TIMEOUT_MS = 30_000;
13
21
  /** Conservative TTL when the API doesn't tell us: documented retention is ~48h. */
14
22
  const ASSUMED_TTL_MS = 47 * 60 * 60 * 1000;
15
23
  function expiresAtMs(expirationTime, now) {
@@ -44,11 +52,14 @@ export async function resolveImageInputs(args, client) {
44
52
  for (const path of args.images ?? []) {
45
53
  inputs.push(await resolveLocalPath(path, client, onDisk, report));
46
54
  }
47
- // 3. raw base64 / data URIs
55
+ // 3. raw base64 / data URIs — the one form that already cost the caller
56
+ // tokens, so it is promoted to a Files API upload immediately (see
57
+ // resolveBase64).
48
58
  if (args.images_base64?.length) {
49
59
  const { decodeImageInput } = await import('./images.js');
50
- for (const b64 of args.images_base64)
51
- inputs.push(decodeImageInput(b64));
60
+ for (const [index, b64] of args.images_base64.entries()) {
61
+ inputs.push(await resolveBase64(decodeImageInput(b64), index, client, report));
62
+ }
52
63
  }
53
64
  // 4. https URLs, fetched by the server. Deduped within the call.
54
65
  const fetched = new Map();
@@ -169,6 +180,72 @@ async function resolveLocalPath(path, client, onDisk, report) {
169
180
  return inline;
170
181
  }
171
182
  }
183
+ /**
184
+ * One base64 input → a Files API reference, uploaded on FIRST sighting.
185
+ *
186
+ * Every other form in this funnel keeps bytes out of the conversation
187
+ * altogether. `images_base64` cannot: by the time the server sees it the
188
+ * caller has already spent ~14k tokens per photo emitting it. So the rule is
189
+ * deliberately not the local-path rule (inline once, upload on the second
190
+ * sighting) — waiting for a second sighting here means waiting until the cost
191
+ * this is meant to prevent has been paid twice. Uploading now buys a
192
+ * `files/<id>` that the report hands back, and the next call references that
193
+ * instead of pasting anything.
194
+ *
195
+ * Identity is the bytes, so the same image pasted into two calls uploads once.
196
+ * Best-effort: an upload failure falls back to inline, because a promotion
197
+ * that fails the call is worse than one that does not happen.
198
+ */
199
+ async function resolveBase64(inline, index, client, report) {
200
+ if (!inline.base64)
201
+ return inline;
202
+ // EVERYTHING is inside the try, decoding included. `decodeImageInput` does
203
+ // not validate a data: payload, so a URL-safe alphabet or a truncated paste
204
+ // reaches `atob` here and throws — and a promotion that fails the generation
205
+ // it was meant to make cheaper is worse than no promotion.
206
+ try {
207
+ const bytes = base64ToBytes(inline.base64);
208
+ // NUL-separated, written as the \u0000 escape — see resolveLocalPath for
209
+ // why a literal NUL must not appear in this file.
210
+ const key = `b64\u0000${inline.mimeType}\u0000${bytes.byteLength}\u0000${await digestOf(bytes)}`;
211
+ const cached = client.session.cachedUpload(key);
212
+ if (cached) {
213
+ // Reported on the cache hit as well. The description tells callers to
214
+ // reuse `base64_uploaded[].file_uri`, and the SECOND paste of a photo is
215
+ // precisely the call that needs to hear it — staying silent there tells
216
+ // the one caller who is repeating themselves nothing.
217
+ (report.base64_uploaded ??= []).push({ index, file_uri: cached.name, bytes: bytes.byteLength });
218
+ return { uri: cached.uri, mimeType: cached.mimeType };
219
+ }
220
+ const uploaded = await client.uploadBytes(bytes, inline.mimeType, `pasted-${index + 1}`, AbortSignal.timeout(BASE64_UPLOAD_TIMEOUT_MS));
221
+ client.session.uploadCache.set(key, {
222
+ name: uploaded.name,
223
+ uri: uploaded.uri,
224
+ mimeType: uploaded.mimeType,
225
+ expiresAtMs: expiresAtMs(uploaded.expirationTime, Date.now()),
226
+ });
227
+ (report.base64_uploaded ??= []).push({
228
+ index,
229
+ file_uri: uploaded.name,
230
+ bytes: bytes.byteLength,
231
+ ...(uploaded.expirationTime ? { expires: uploaded.expirationTime } : {}),
232
+ });
233
+ return { uri: uploaded.uri, mimeType: uploaded.mimeType };
234
+ }
235
+ catch {
236
+ return inline;
237
+ }
238
+ }
239
+ /**
240
+ * SHA-256 of the bytes, hex. Content identity for the upload cache — two
241
+ * pastes of the same photo must resolve to one upload even when the base64
242
+ * text differs (a data: prefix, different line wrapping).
243
+ */
244
+ async function digestOf(bytes) {
245
+ const view = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
246
+ const hash = await crypto.subtle.digest('SHA-256', view);
247
+ return [...new Uint8Array(hash)].map((n) => n.toString(16).padStart(2, '0')).join('');
248
+ }
172
249
  /** One https URL → inline bytes, or a Files API reference when it's large. */
173
250
  async function resolveUrl(url, client, report) {
174
251
  const fetched = await client.fetchRemoteImage(url);
package/dist/jobs.js CHANGED
@@ -39,6 +39,17 @@ function isExpired(entry, now) {
39
39
  export function fingerprintRequest(toolName, parts) {
40
40
  return createHash('sha256').update(`${toolName}\u0000${JSON.stringify(parts)}`).digest('hex');
41
41
  }
42
+ /**
43
+ * The in-memory replay key: an idempotency key is only ever honoured for the
44
+ * REQUEST it was first used with.
45
+ *
46
+ * Same NUL separator, same reason, as {@link fingerprintRequest} — and the
47
+ * fingerprint is already namespaced by tool, so two tools handed the same "1"
48
+ * cannot collide either.
49
+ */
50
+ function replayKey(idempotencyKey, fingerprint) {
51
+ return `${idempotencyKey}\u0000${fingerprint}`;
52
+ }
42
53
  /**
43
54
  * Re-mint any media URLs a recorded result carries, before it is replayed.
44
55
  *
@@ -150,10 +161,19 @@ function annotateReused(result, jobId) {
150
161
  };
151
162
  }
152
163
  /** Immediate handle for an async call — the caller polls `gemini_get_result`. */
153
- function jobHandle(jobId, status, durable = false) {
164
+ /**
165
+ * `interactionId` is on the handle, not held back until the job settles.
166
+ *
167
+ * It is the one thing that outlives a lost response: the generation continues
168
+ * upstream whatever happens to this request, and the id is how a later call
169
+ * chains onto it or recovers its output. Withholding it until the settle is
170
+ * withholding it exactly in the case where the settle never comes.
171
+ */
172
+ function jobHandle(jobId, status, durable = false, interactionId) {
154
173
  return minifiedResult({
155
174
  job_id: jobId,
156
175
  status,
176
+ ...(interactionId ? { interaction_id: interactionId } : {}),
157
177
  hint: durable
158
178
  ? `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done". The job record is stored durably, so a poll still works if this connector restarts — but background work is NOT restarted, so a long generation is safer with max_wait_ms than with async.`
159
179
  : `Generation running in the background. Poll gemini_get_result with job_id "${jobId}" until status is "done" (results are per-process and expire ~10 min after completion).`,
@@ -232,7 +252,18 @@ export class JobRegistry {
232
252
  /** Injectable clock (tests). */
233
253
  now = () => Date.now();
234
254
  jobs = new Map();
235
- byKey = new Map(); // idempotencyKey -> jobId
255
+ /**
256
+ * `idempotencyKey + fingerprint` -> jobId. Keyed on BOTH, never the key
257
+ * alone: an idempotency_key is a habit as often as a promise ("1", "test",
258
+ * "retry"), so a key-only lookup hands a caller a different prompt's result
259
+ * and labels it `reused`. This is the rule `durableByKey` has always applied
260
+ * to the 24h window; the 10-minute in-memory window now applies it too.
261
+ *
262
+ * Composite rather than a stored comparison so that reusing a key for a
263
+ * second request cannot evict the first: both pairings coexist, and a retry
264
+ * of either still replays.
265
+ */
266
+ byKey = new Map();
236
267
  runningByFingerprint = new Map(); // fingerprint -> jobId (only while running)
237
268
  /** Durable bookkeeping still in flight — awaited by `drain()` in tests. */
238
269
  pending = new Set();
@@ -345,8 +376,11 @@ export class JobRegistry {
345
376
  }
346
377
  remove(id, entry) {
347
378
  this.jobs.delete(id);
348
- if (entry.idempotencyKey !== undefined && this.byKey.get(entry.idempotencyKey) === id)
349
- this.byKey.delete(entry.idempotencyKey);
379
+ if (entry.idempotencyKey !== undefined) {
380
+ const key = replayKey(entry.idempotencyKey, entry.fingerprint);
381
+ if (this.byKey.get(key) === id)
382
+ this.byKey.delete(key);
383
+ }
350
384
  if (this.runningByFingerprint.get(entry.fingerprint) === id)
351
385
  this.runningByFingerprint.delete(entry.fingerprint);
352
386
  }
@@ -376,7 +410,7 @@ export class JobRegistry {
376
410
  const now = this.now();
377
411
  let hit;
378
412
  if (idempotencyKey !== undefined) {
379
- const id = this.byKey.get(idempotencyKey);
413
+ const id = this.byKey.get(replayKey(idempotencyKey, fingerprint));
380
414
  const entry = id ? this.jobs.get(id) : undefined;
381
415
  if (entry && entry.status !== 'failed' && !isExpired(entry, now))
382
416
  hit = entry;
@@ -397,12 +431,12 @@ export class JobRegistry {
397
431
  }
398
432
  if (hit) {
399
433
  if (async)
400
- return jobHandle(hit.jobId, hit.status);
434
+ return jobHandle(hit.jobId, hit.status, this.store !== undefined, hit.interactionId);
401
435
  const settled = waitMs !== undefined && hit.status === 'running'
402
436
  ? await awaitWithBudget(hit.promise, waitMs)
403
437
  : await hit.promise;
404
438
  if (settled === undefined)
405
- return jobHandle(hit.jobId, 'running');
439
+ return jobHandle(hit.jobId, 'running', this.store !== undefined, hit.interactionId);
406
440
  // Refresh before annotating: a recorded result can outlive the signed
407
441
  // URLs inside it, and replaying an expired link is its own kind of wrong
408
442
  // answer — it looks like success.
@@ -437,7 +471,7 @@ export class JobRegistry {
437
471
  };
438
472
  this.jobs.set(jobId, entry);
439
473
  if (idempotencyKey !== undefined)
440
- this.byKey.set(idempotencyKey, jobId);
474
+ this.byKey.set(replayKey(idempotencyKey, fingerprint), jobId);
441
475
  this.runningByFingerprint.set(fingerprint, jobId);
442
476
  // Write the "running" record BEFORE the work can finish. A record that only
443
477
  // appeared on completion would be missing for exactly the window this whole
@@ -459,13 +493,13 @@ export class JobRegistry {
459
493
  if (this.store)
460
494
  void settle.then(() => this.track(this.writeRecord(entry)));
461
495
  if (async)
462
- return jobHandle(jobId, 'running', this.store !== undefined);
496
+ return jobHandle(jobId, 'running', this.store !== undefined, this.jobs.get(jobId)?.interactionId);
463
497
  if (waitMs !== undefined) {
464
498
  const settled = await awaitWithBudget(promise, waitMs);
465
499
  // Budget expired with the work still running: hand back the job id — the
466
500
  // work continues and the caller polls, exactly as with `async: true`,
467
501
  // except a fast result would have been returned in-band.
468
- return settled ?? jobHandle(jobId, 'running', this.store !== undefined);
502
+ return settled ?? jobHandle(jobId, 'running', this.store !== undefined, this.jobs.get(jobId)?.interactionId);
469
503
  }
470
504
  return promise;
471
505
  }
@@ -497,7 +531,7 @@ export class JobRegistry {
497
531
  if (this.now() - record.updatedAt > DURABLE_JOB_REUSE_MS)
498
532
  return undefined;
499
533
  if (record.status === 'running')
500
- return jobHandle(id, 'running', true);
534
+ return jobHandle(id, 'running', true, record.interactionId);
501
535
  if (record.status !== 'done' || !record.result)
502
536
  return undefined;
503
537
  return annotateReused(await refreshMedia(record.result, this.resigner), id);
@@ -571,7 +605,7 @@ export class JobRegistry {
571
605
  });
572
606
  }
573
607
  if (entry.status === 'running')
574
- return jobHandle(jobId, 'running', this.store !== undefined);
608
+ return jobHandle(jobId, 'running', this.store !== undefined, entry.interactionId);
575
609
  if (entry.status === 'failed') {
576
610
  throw new McpToolError(entry.error?.message ?? 'Job failed.', entry.error?.hint ? { hint: entry.error.hint } : undefined);
577
611
  }
@@ -593,7 +627,7 @@ export class JobRegistry {
593
627
  */
594
628
  async fromRecord(record) {
595
629
  if (record.status === 'running')
596
- return jobHandle(record.jobId, 'running', true);
630
+ return jobHandle(record.jobId, 'running', true, record.interactionId);
597
631
  if (record.status === 'failed') {
598
632
  throw new McpToolError(record.error?.message ?? 'Job failed.', record.error?.hint ? { hint: record.error.hint } : undefined);
599
633
  }
package/dist/models.js CHANGED
@@ -5,10 +5,20 @@
5
5
  * `model` param when the task warrants it (see MODEL_CHOICE_GUIDE).
6
6
  */
7
7
  export const DEFAULT_IMAGE_MODEL = 'gemini-3.1-flash-image';
8
- /** Default video model (omni, Interactions API). Preview — overridable per-call. */
9
- export const DEFAULT_VIDEO_MODEL = 'gemini-omni-flash-preview';
10
- /** Default music model (Lyria clips, Interactions API). `lyria-3-pro-preview` is
11
- * the longer-form opt-in; both are preview and need a funded account. */
8
+ /**
9
+ * Default video model (omni, Interactions API) — overridable per-call.
10
+ *
11
+ * GA since 2026-08-27. Its predecessor `gemini-omni-flash-preview` shuts down
12
+ * 2026-09-30, so this is a migration and not a preference; the GA model bills
13
+ * at the same rate and adds `resolution` and the `extend` task.
14
+ */
15
+ export const DEFAULT_VIDEO_MODEL = 'gemini-omni-1.1-flash';
16
+ /**
17
+ * Default music model (Lyria clips, Interactions API) — 30s, MP3, the cheapest
18
+ * of the three at $0.04 a song. `lyria-3.5` (minutes-long, vocals) and
19
+ * `lyria-3-pro-preview` are the opt-ins at twice the price, so neither is the
20
+ * default. All need a funded account.
21
+ */
12
22
  export const DEFAULT_MUSIC_MODEL = 'lyria-3-clip-preview';
13
23
  /** Per-call → env override → hardcoded default. Blank/whitespace counts as unset. */
14
24
  export function resolveModel(perCall, envOverride) {
package/dist/pricing.js CHANGED
@@ -49,7 +49,7 @@
49
49
  */
50
50
  import { readEnvVar } from '@chrischall/mcp-utils';
51
51
  /** The date the shipped rates were read from Google's pricing page. */
52
- export const PRICED_AT = '2026-08-28';
52
+ export const PRICED_AT = '2026-09-09';
53
53
  /**
54
54
  * Paid-tier rates, USD per 1M tokens. Image generation has no free tier.
55
55
  * `-preview` ids are priced as their GA twin (see {@link normalizeModel}).
@@ -73,9 +73,17 @@ export const RATE_CARD = Object.freeze({
73
73
  // from flash-image), along with a $0.50 input and $3.00 text rate that were
74
74
  // both a third of the real figures. Every number here is now quoted.
75
75
  'gemini-omni-flash': { input: 1.5, text_output: 9.0, video_output: 17.5 },
76
+ // The GA model (2026-08-27) bills at the preview's rates. It needs its own
77
+ // key: `normalizeModel` strips a `-preview` suffix and nothing else, so
78
+ // `gemini-omni-1.1-flash` would otherwise price as undefined — and undefined
79
+ // is what the server's DEFAULT video model would have reported.
80
+ 'gemini-omni-1.1-flash': { input: 1.5, text_output: 9.0, video_output: 17.5 },
76
81
  // Lyria bills per song, so tokens describe the work and not the bill.
77
82
  'lyria-3-clip': { input: 0, text_output: 0, per_generation: 0.04 },
78
83
  'lyria-3-pro': { input: 0, text_output: 0, per_generation: 0.08 },
84
+ // lyria-3.5 (2026-09-03): minutes-long songs with vocals, $0.08 a song. A
85
+ // bare id with no `-preview` to strip, so it is listed rather than derived.
86
+ 'lyria-3.5': { input: 0, text_output: 0, per_generation: 0.08 },
79
87
  });
80
88
  /** `foo-preview` prices as `foo` — the fleet uses both spellings. */
81
89
  function normalizeModel(model) {
package/dist/session.js CHANGED
@@ -33,12 +33,14 @@ export class SessionState {
33
33
  */
34
34
  lastInteractionId;
35
35
  /**
36
- * The same, for the media-specific tools, which keep their own chains.
37
- * `gemini_music_generate` IS served by the hosted connector, so this one
38
- * leaked across tenants exactly as `lastInteractionId` did; video is
39
- * stdio-only today but carries the identical hazard, so both are scoped here.
36
+ * The same, for `gemini_video_generate`, which keeps its own chain. Video is
37
+ * stdio-only today, but it carries the identical cross-tenant hazard
38
+ * `lastInteractionId` did, so it is scoped here rather than at module level.
39
+ *
40
+ * There is deliberately no music equivalent: Lyria is single-turn (a chained
41
+ * call 400s on the track it is handed back), so `gemini_music_generate` has
42
+ * no continue_last to remember.
40
43
  */
41
- lastMusicInteractionId;
42
44
  lastVideoInteractionId;
43
45
  /**
44
46
  * Absolute paths of every image this session has written. Used to drop a
@@ -138,7 +140,6 @@ export class SessionState {
138
140
  this.costUsd = undefined;
139
141
  this.pricedCalls = 0;
140
142
  this.lastInteractionId = undefined;
141
- this.lastMusicInteractionId = undefined;
142
143
  this.lastVideoInteractionId = undefined;
143
144
  this.writtenOutputs.clear();
144
145
  this.uploadCache.clear();