aisubs 0.3.5 → 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 +7 -0
- package/dist/compatibility.js +63 -14
- package/dist/providers/chatgpt.js +52 -21
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
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
|
+
|
|
3
10
|
## 0.3.5 - 2026-09-14
|
|
4
11
|
|
|
5
12
|
- Move ChatGPT Responses instructions into the developer input prefix so
|
package/dist/compatibility.js
CHANGED
|
@@ -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 {
|
|
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
|
-
? {
|
|
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
|
-
? {
|
|
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
|
-
? {
|
|
574
|
-
|
|
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(
|
|
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,25 +109,12 @@ async function normalizeChatGptRequest(request) {
|
|
|
109
109
|
if (!isRecord(raw))
|
|
110
110
|
return request;
|
|
111
111
|
const body = { ...raw };
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
: typeof body.input === "string"
|
|
119
|
-
? [{ type: "message", role: "user", content: body.input }]
|
|
120
|
-
: [];
|
|
121
|
-
body.input = [
|
|
122
|
-
{
|
|
123
|
-
type: "message",
|
|
124
|
-
role: "developer",
|
|
125
|
-
content: [{ type: "input_text", text: body.instructions }],
|
|
126
|
-
},
|
|
127
|
-
...input,
|
|
128
|
-
];
|
|
129
|
-
delete body.instructions;
|
|
130
|
-
}
|
|
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.
|
|
131
118
|
delete body.prompt_cache_options;
|
|
132
119
|
delete body.prompt_cache_retention;
|
|
133
120
|
const stripBreakpoints = (value) => Array.isArray(value)
|
|
@@ -135,18 +122,42 @@ async function normalizeChatGptRequest(request) {
|
|
|
135
122
|
? Object.fromEntries(Object.entries(item).filter(([key]) => key !== "prompt_cache_breakpoint"))
|
|
136
123
|
: item)
|
|
137
124
|
: value;
|
|
138
|
-
|
|
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;
|
|
139
135
|
body.tools = stripBreakpoints(body.tools);
|
|
140
136
|
return new Request(request, {
|
|
141
137
|
method: request.method,
|
|
142
138
|
body: JSON.stringify(body),
|
|
143
|
-
headers: { ...Object.fromEntries(
|
|
139
|
+
headers: { ...Object.fromEntries(headers), "content-type": "application/json" },
|
|
144
140
|
});
|
|
145
141
|
}
|
|
146
142
|
export function chatGptProvider(options = {}) {
|
|
147
143
|
const clientId = options.clientId ?? DEFAULT_CLIENT_ID;
|
|
148
144
|
const compatibilityVersion = options.compatibilityVersion ?? "0.154.0";
|
|
149
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
|
+
};
|
|
150
161
|
async function startDeviceLogin(signal) {
|
|
151
162
|
const response = await fetcher(DEVICE_CODE_URL, {
|
|
152
163
|
method: "POST",
|
|
@@ -401,12 +412,32 @@ export function chatGptProvider(options = {}) {
|
|
|
401
412
|
const accountId = credential.account?.id;
|
|
402
413
|
if (!accountId)
|
|
403
414
|
throw new Error("ChatGPT account id is missing");
|
|
415
|
+
const key = turnKey(request, accountId);
|
|
416
|
+
const state = key ? turnStates.get(key) : undefined;
|
|
404
417
|
return bearerRequest(request, credential, {
|
|
405
418
|
"chatgpt-account-id": accountId,
|
|
406
419
|
originator: "aisubs",
|
|
407
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
|
+
: {}),
|
|
408
424
|
});
|
|
409
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
|
+
},
|
|
410
441
|
async getUsage({ fetch, signal }) {
|
|
411
442
|
const response = await fetch(USAGE_URL, { headers: { accept: "application/json" }, signal });
|
|
412
443
|
const raw = await responseJson(response, "ChatGPT usage");
|