aisubs 0.3.4 → 0.3.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.6 - 2026-09-15
4
+
5
+ - Preserve provider cache controls and cache usage across OpenAI-compatible,
6
+ Anthropic, Responses, and subscription transports.
7
+ - Keep ChatGPT cache routing state scoped to account, session, and turn.
8
+ - Normalize cache reads/writes and reasoning usage without losing native fields.
9
+
10
+ ## 0.3.5 - 2026-09-14
11
+
12
+ - Move ChatGPT Responses instructions into the developer input prefix so
13
+ stable subscription prompts participate in prompt caching.
14
+
3
15
  ## 0.3.4 - 2026-09-13
4
16
 
5
17
  - Refresh account model catalogs on demand and update ChatGPT compatibility so
@@ -29,13 +29,22 @@ function requiredModel(raw) {
29
29
  throw new CompatibilityError("A non-empty model is required");
30
30
  return model;
31
31
  }
32
+ function cacheControl(value) {
33
+ if (!isRecord(value) || value.cache_control == null)
34
+ return {};
35
+ const control = record(value.cache_control, "cache_control must be an object");
36
+ if (control.type !== "ephemeral" ||
37
+ (control.ttl != null && control.ttl !== "5m" && control.ttl !== "1h"))
38
+ throw new CompatibilityError("Unsupported cache_control type or TTL", "unsupported_feature");
39
+ return { cache_control: { type: "ephemeral", ...(control.ttl ? { ttl: control.ttl } : {}) } };
40
+ }
32
41
  function text(value) {
33
42
  if (typeof value === "string")
34
43
  return { type: "text", text: value };
35
44
  if (!isRecord(value))
36
45
  return null;
37
46
  const content = stringValue(value.text);
38
- return content == null ? null : { type: "text", text: content };
47
+ return content == null ? null : { type: "text", text: content, ...cacheControl(value) };
39
48
  }
40
49
  function imageUrl(value) {
41
50
  if (typeof value === "string")
@@ -61,14 +70,14 @@ function openAiParts(value) {
61
70
  const url = imageUrl(part.image_url) ?? stringValue(part.image_url) ?? stringValue(part.file_id);
62
71
  if (!url)
63
72
  throw new CompatibilityError("Image content requires image_url or file_id");
64
- return { type: "image", url, detail: stringValue(part.detail) };
73
+ return { type: "image", url, detail: stringValue(part.detail), ...cacheControl(part) };
65
74
  }
66
75
  if (type === "input_audio") {
67
76
  const audio = record(part.input_audio, "input_audio requires audio data");
68
77
  const data = stringValue(audio.data);
69
78
  if (!data)
70
79
  throw new CompatibilityError("input_audio requires audio data");
71
- return { type: "audio", data, format: stringValue(audio.format) };
80
+ return { type: "audio", data, format: stringValue(audio.format), ...cacheControl(part) };
72
81
  }
73
82
  if (type === "file" || type === "input_file") {
74
83
  return {
@@ -76,6 +85,7 @@ function openAiParts(value) {
76
85
  fileId: stringValue(part.file_id),
77
86
  data: stringValue(part.file_data),
78
87
  filename: stringValue(part.filename),
88
+ ...cacheControl(part),
79
89
  };
80
90
  }
81
91
  if (type === "refusal")
@@ -116,6 +126,7 @@ function chatTools(value) {
116
126
  description: stringValue(fn.description),
117
127
  parameters: fn.parameters,
118
128
  strict: typeof fn.strict === "boolean" ? fn.strict : undefined,
129
+ ...cacheControl(tool),
119
130
  };
120
131
  });
121
132
  }
@@ -134,6 +145,7 @@ function parseChat(body) {
134
145
  content: openAiParts(message.content),
135
146
  toolCallId: stringValue(message.tool_call_id),
136
147
  toolCalls: chatToolCalls(message.tool_calls),
148
+ ...cacheControl(message),
137
149
  };
138
150
  });
139
151
  return {
@@ -151,6 +163,7 @@ function parseChat(body) {
151
163
  metadata: raw.metadata,
152
164
  user: stringValue(raw.user),
153
165
  promptCacheKey: stringValue(raw.prompt_cache_key),
166
+ cacheControl: cacheControl(raw).cache_control,
154
167
  };
155
168
  }
156
169
  function responseTools(value) {
@@ -257,15 +270,16 @@ function anthropicParts(value) {
257
270
  for (const item of value) {
258
271
  const part = record(item, "Anthropic content blocks must be objects");
259
272
  if (part.type === "text")
260
- content.push({ type: "text", text: stringValue(part.text) ?? "" });
273
+ content.push({ type: "text", text: stringValue(part.text) ?? "", ...cacheControl(part) });
261
274
  else if (part.type === "image") {
262
275
  const source = record(part.source, "Anthropic image requires source");
263
276
  if (source.type === "url")
264
- content.push({ type: "image", url: stringValue(source.url) ?? "" });
277
+ content.push({ type: "image", url: stringValue(source.url) ?? "", ...cacheControl(part) });
265
278
  else
266
279
  content.push({
267
280
  type: "image",
268
281
  url: `data:${stringValue(source.media_type) ?? "image/png"};base64,${stringValue(source.data) ?? ""}`,
282
+ ...cacheControl(part),
269
283
  });
270
284
  }
271
285
  else if (part.type === "tool_use") {
@@ -273,6 +287,7 @@ function anthropicParts(value) {
273
287
  id: stringValue(part.id) ?? `call_${crypto.randomUUID()}`,
274
288
  name: stringValue(part.name) ?? "function",
275
289
  arguments: JSON.stringify(part.input ?? {}),
290
+ ...cacheControl(part),
276
291
  });
277
292
  }
278
293
  else if (part.type !== "thinking" && part.type !== "redacted_thinking") {
@@ -301,6 +316,7 @@ function parseAnthropic(body) {
301
316
  role: "tool",
302
317
  toolCallId: stringValue(part.tool_use_id),
303
318
  content: anthropicParts(part.content).content,
319
+ ...cacheControl(part),
304
320
  });
305
321
  }
306
322
  else
@@ -319,7 +335,12 @@ function parseAnthropic(body) {
319
335
  const name = stringValue(tool.name);
320
336
  if (!name)
321
337
  throw new CompatibilityError("Tool requires a name");
322
- return { name, description: stringValue(tool.description), parameters: tool.input_schema };
338
+ return {
339
+ name,
340
+ description: stringValue(tool.description),
341
+ parameters: tool.input_schema,
342
+ ...cacheControl(tool),
343
+ };
323
344
  })
324
345
  : undefined;
325
346
  return {
@@ -333,6 +354,7 @@ function parseAnthropic(body) {
333
354
  topP: numberValue(raw.top_p),
334
355
  stop: raw.stop_sequences,
335
356
  metadata: raw.metadata,
357
+ cacheControl: cacheControl(raw).cache_control,
336
358
  };
337
359
  }
338
360
  function parseGoogle(body, model, stream = false) {
@@ -420,15 +442,16 @@ function dataUri(url) {
420
442
  return match?.[1] && match[2] ? { mediaType: match[1], data: match[2] } : null;
421
443
  }
422
444
  function chatContent(parts) {
423
- if (parts.every((part) => part.type === "text"))
445
+ if (parts.every((part) => part.type === "text" && !part.cache_control))
424
446
  return parts.map((part) => (part.type === "text" ? part.text : "")).join("");
425
447
  return parts.map((part) => {
426
448
  if (part.type === "text")
427
- return { type: "text", text: part.text };
449
+ return { type: "text", text: part.text, ...cacheControl(part) };
428
450
  if (part.type === "image")
429
451
  return {
430
452
  type: "image_url",
431
453
  image_url: { url: part.url, ...(part.detail ? { detail: part.detail } : {}) },
454
+ ...cacheControl(part),
432
455
  };
433
456
  if (part.type === "audio")
434
457
  return {
@@ -447,6 +470,7 @@ function toChat(request) {
447
470
  const messages = request.messages.map((message) => ({
448
471
  role: message.role,
449
472
  content: chatContent(message.content),
473
+ ...cacheControl(message),
450
474
  ...(message.toolCallId ? { tool_call_id: message.toolCallId } : {}),
451
475
  ...(message.toolCalls
452
476
  ? {
@@ -463,7 +487,13 @@ function toChat(request) {
463
487
  messages,
464
488
  stream: false,
465
489
  ...(request.tools
466
- ? { tools: request.tools.map((tool) => ({ type: "function", function: tool })) }
490
+ ? {
491
+ tools: request.tools.map(({ cache_control, ...tool }) => ({
492
+ type: "function",
493
+ function: tool,
494
+ ...(cache_control ? { cache_control } : {}),
495
+ })),
496
+ }
467
497
  : {}),
468
498
  ...(request.toolChoice != null ? { tool_choice: request.toolChoice } : {}),
469
499
  ...(request.maxTokens != null ? { max_completion_tokens: request.maxTokens } : {}),
@@ -550,7 +580,12 @@ function toResponses(request) {
550
580
  stream: false,
551
581
  store: false,
552
582
  ...(request.tools
553
- ? { tools: request.tools.map((tool) => ({ type: "function", ...tool })) }
583
+ ? {
584
+ tools: request.tools.map(({ cache_control: _cacheControl, ...tool }) => ({
585
+ type: "function",
586
+ ...tool,
587
+ })),
588
+ }
554
589
  : {}),
555
590
  ...(toolChoice != null ? { tool_choice: toolChoice } : {}),
556
591
  ...(request.maxTokens != null ? { max_output_tokens: request.maxTokens } : {}),
@@ -566,12 +601,16 @@ function toResponses(request) {
566
601
  function anthropicContent(message) {
567
602
  const parts = message.content.map((part) => {
568
603
  if (part.type === "text")
569
- return { type: "text", text: part.text };
604
+ return { type: "text", text: part.text, ...cacheControl(part) };
570
605
  if (part.type === "image") {
571
606
  const data = dataUri(part.url);
572
607
  return data
573
- ? { type: "image", source: { type: "base64", media_type: data.mediaType, data: data.data } }
574
- : { type: "image", source: { type: "url", url: part.url } };
608
+ ? {
609
+ type: "image",
610
+ source: { type: "base64", media_type: data.mediaType, data: data.data },
611
+ ...cacheControl(part),
612
+ }
613
+ : { type: "image", source: { type: "url", url: part.url }, ...cacheControl(part) };
575
614
  }
576
615
  if (part.type === "file")
577
616
  return {
@@ -580,6 +619,7 @@ function anthropicContent(message) {
580
619
  ? { type: "file", file_id: part.fileId }
581
620
  : { type: "base64", media_type: "application/octet-stream", data: part.data ?? "" },
582
621
  ...(part.filename ? { title: part.filename } : {}),
622
+ ...cacheControl(part),
583
623
  };
584
624
  throw new CompatibilityError("Anthropic Messages does not support OpenAI input_audio", "unsupported_feature");
585
625
  });
@@ -589,7 +629,10 @@ function anthropicContent(message) {
589
629
  id: call.id,
590
630
  name: call.name,
591
631
  input: JSON.parse(call.arguments || "{}"),
632
+ ...cacheControl(call),
592
633
  });
634
+ if (message.role !== "tool" && message.cache_control && parts.length)
635
+ Object.assign(parts[parts.length - 1], cacheControl(message));
593
636
  return parts;
594
637
  }
595
638
  function toAnthropic(request) {
@@ -606,6 +649,7 @@ function toAnthropic(request) {
606
649
  type: "tool_result",
607
650
  tool_use_id: message.toolCallId,
608
651
  content: anthropicContent(message),
652
+ ...cacheControl(message),
609
653
  },
610
654
  ],
611
655
  }
@@ -621,12 +665,14 @@ function toAnthropic(request) {
621
665
  max_tokens: request.maxTokens ?? 4096,
622
666
  stream: false,
623
667
  ...(system.length ? { system } : {}),
668
+ ...(request.cacheControl ? { cache_control: request.cacheControl } : {}),
624
669
  ...(request.tools
625
670
  ? {
626
671
  tools: request.tools.map((tool) => ({
627
672
  name: tool.name,
628
673
  description: tool.description,
629
674
  input_schema: tool.parameters ?? { type: "object", properties: {} },
675
+ ...cacheControl(tool),
630
676
  })),
631
677
  }
632
678
  : {}),
@@ -740,7 +786,10 @@ function parseChatResult(raw, model) {
740
786
  finishReason: finish === "length" || finish === "tool_calls" || finish === "content_filter"
741
787
  ? finish
742
788
  : "stop",
743
- usage: usage(numberValue(details?.prompt_tokens), numberValue(details?.completion_tokens), numberValue(promptDetails?.cached_tokens), numberValue(completionDetails?.reasoning_tokens), numberValue(promptDetails?.cache_write_tokens)),
789
+ usage: usage(numberValue(details?.prompt_tokens), numberValue(details?.completion_tokens), numberValue(details?.cached_tokens) ??
790
+ numberValue(details?.prompt_cache_hit_tokens) ??
791
+ numberValue(promptDetails?.cached_tokens), numberValue(completionDetails?.reasoning_tokens), numberValue(promptDetails?.cache_write_tokens) ??
792
+ numberValue(promptDetails?.cache_creation_input_tokens)),
744
793
  };
745
794
  }
746
795
  function parseResponsesResult(raw, model) {
@@ -109,6 +109,12 @@ async function normalizeChatGptRequest(request) {
109
109
  if (!isRecord(raw))
110
110
  return request;
111
111
  const body = { ...raw };
112
+ const headers = new Headers(request.headers);
113
+ const sessionId = headers.get("session-id") ?? headers.get("session_id") ?? stringValue(body.prompt_cache_key);
114
+ if (sessionId)
115
+ headers.set("session-id", sessionId);
116
+ // Preserve instruction placement and message boundaries. The native Codex
117
+ // client sends top-level instructions too; moving them does not enable caching.
112
118
  delete body.prompt_cache_options;
113
119
  delete body.prompt_cache_retention;
114
120
  const stripBreakpoints = (value) => Array.isArray(value)
@@ -116,18 +122,42 @@ async function normalizeChatGptRequest(request) {
116
122
  ? Object.fromEntries(Object.entries(item).filter(([key]) => key !== "prompt_cache_breakpoint"))
117
123
  : item)
118
124
  : value;
119
- body.input = stripBreakpoints(body.input);
125
+ const inputWithoutBreakpoints = stripBreakpoints(body.input);
126
+ body.input = Array.isArray(inputWithoutBreakpoints)
127
+ ? inputWithoutBreakpoints.map((item) => isRecord(item)
128
+ ? {
129
+ ...item,
130
+ ...(Array.isArray(item.content) ? { content: stripBreakpoints(item.content) } : {}),
131
+ ...(Array.isArray(item.output) ? { output: stripBreakpoints(item.output) } : {}),
132
+ }
133
+ : item)
134
+ : inputWithoutBreakpoints;
120
135
  body.tools = stripBreakpoints(body.tools);
121
136
  return new Request(request, {
122
137
  method: request.method,
123
138
  body: JSON.stringify(body),
124
- headers: { ...Object.fromEntries(request.headers), "content-type": "application/json" },
139
+ headers: { ...Object.fromEntries(headers), "content-type": "application/json" },
125
140
  });
126
141
  }
127
142
  export function chatGptProvider(options = {}) {
128
143
  const clientId = options.clientId ?? DEFAULT_CLIENT_ID;
129
144
  const compatibilityVersion = options.compatibilityVersion ?? "0.154.0";
130
145
  const fetcher = options.fetch ?? globalThis.fetch;
146
+ // Routing tokens belong to one account/session/turn, never to the whole provider.
147
+ const turnStates = new Map();
148
+ const turnKey = (request, accountId) => {
149
+ const sessionId = request.headers.get("session-id");
150
+ try {
151
+ const metadata = JSON.parse(request.headers.get("x-codex-turn-metadata") ?? "null");
152
+ const turnId = isRecord(metadata) ? stringValue(metadata.turn_id) : undefined;
153
+ return accountId && sessionId && turnId
154
+ ? JSON.stringify([accountId, sessionId, turnId])
155
+ : undefined;
156
+ }
157
+ catch {
158
+ return undefined;
159
+ }
160
+ };
131
161
  async function startDeviceLogin(signal) {
132
162
  const response = await fetcher(DEVICE_CODE_URL, {
133
163
  method: "POST",
@@ -382,12 +412,32 @@ export function chatGptProvider(options = {}) {
382
412
  const accountId = credential.account?.id;
383
413
  if (!accountId)
384
414
  throw new Error("ChatGPT account id is missing");
415
+ const key = turnKey(request, accountId);
416
+ const state = key ? turnStates.get(key) : undefined;
385
417
  return bearerRequest(request, credential, {
386
418
  "chatgpt-account-id": accountId,
387
419
  originator: "aisubs",
388
420
  "user-agent": `aisubs/${compatibilityVersion}`,
421
+ ...(state && state.expiresAt > Date.now() && !request.headers.has("x-codex-turn-state")
422
+ ? { "x-codex-turn-state": state.token }
423
+ : {}),
389
424
  });
390
425
  },
426
+ normalizeResponse(request, response) {
427
+ const key = turnKey(request, request.headers.get("chatgpt-account-id") ?? undefined);
428
+ const token = response.headers.get("x-codex-turn-state");
429
+ if (response.ok && key && token) {
430
+ for (const [entryKey, entry] of turnStates)
431
+ if (entry.expiresAt <= Date.now())
432
+ turnStates.delete(entryKey);
433
+ if (!turnStates.has(key)) {
434
+ if (turnStates.size >= 128)
435
+ turnStates.delete(turnStates.keys().next().value);
436
+ turnStates.set(key, { token, expiresAt: Date.now() + 30 * 60_000 });
437
+ }
438
+ }
439
+ return response;
440
+ },
391
441
  async getUsage({ fetch, signal }) {
392
442
  const response = await fetch(USAGE_URL, { headers: { accept: "application/json" }, signal });
393
443
  const raw = await responseJson(response, "ChatGPT usage");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aisubs",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Connect AI provider accounts and use those subscriptions from any local tool or as api.",
5
5
  "keywords": [
6
6
  "ai",