@vincemakes/kiso-provider-openai-responses 0.31.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kiso contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # @vincemakes/kiso-provider-openai-responses
2
+
3
+ The OpenAI **Responses** adapter — one adapter, two targets, no SDK and no
4
+ new dependency (plain `fetch` plus a hand-rolled SSE reader).
5
+
6
+ | target | auth | endpoint | what is different |
7
+ |---|---|---|---|
8
+ | OpenAI first-party | API key (`kiso login openai`, or `OPENAI_API_KEY`) | `https://api.openai.com/v1/responses` | the public Responses API |
9
+ | ChatGPT subscription | the stored OAuth credential (`kiso login chatgpt`) | `https://chatgpt.com/backend-api/codex/responses` | `chatgpt-account-id` / `originator` / `OpenAI-Beta` headers; `store: false`, `include: ["reasoning.encrypted_content"]`, `prompt_cache_key` |
10
+
11
+ The target is inferred from the options the factory is handed — an
12
+ `apiKey` builds the first-party adapter, an `oauth` thunk builds the
13
+ ChatGPT one — so there is no second switch that could disagree with the
14
+ credential. The thunk is awaited **once per request**, which is what lets
15
+ the credential store refresh a token that expires mid-session.
16
+
17
+ Requires Node >= 22. See the repository README for the framework overview.
18
+
19
+ ## Support level
20
+
21
+ Filled from this package's own gates. Every one of them runs against a
22
+ LOCAL double (`tests/helpers/rig.ts`); no test reaches a vendor.
23
+
24
+ | capability | first-party | ChatGPT | evidence |
25
+ |---|---|---|---|
26
+ | auth header | ✓ | ✓ | `or1-request-rig` — the frozen request bytes per target, and the ChatGPT-only headers absent on the first-party one |
27
+ | streaming | ✓ | ✓ | `or1-stream` — the nine adapter events in order, `usage` before `stop`, both from the same terminal frame |
28
+ | tool call + next turn | ✓ | ✓ | `or1-tool-turn` — the emitted `callId` is the `call_id`, and the next request replays it as `function_call_output` |
29
+ | reasoning effort | ✓ | ✓ | `or1-request-rig` shape 3 — `reasoning.effort` carries the level it was handed |
30
+ | reasoning replay (`store: false`) | n/a | ✓ | `or1-continuation` — one `stop.continuation` entry per encrypted reasoning item, replayed on scope match only |
31
+ | cancel | ✓ | ✓ | `or1-errors` — an abort ends the turn with an `AbortError`, no `stop`, nothing retryable |
32
+ | error mapping | ✓ | ✓ | `or1-errors` — 429/401/400/503 by status, both `Retry-After` forms in milliseconds |
33
+ | retry authority | ✓ | ✓ | `or1-retry-authority` — exactly one request per stream, on every failure class |
34
+ | **a real vendor leg** | **unrun** | **unrun** | needs a key / a subscription sign-in and an owner-budgeted run |
35
+
36
+ `unrun` means exactly that: the behaviour is proven against the recorded
37
+ dialect, and nobody has yet pointed this adapter at the vendor.
38
+
39
+ ## What it deliberately does not send
40
+
41
+ `text.verbosity`, `tool_choice`, `parallel_tool_calls`, `service_tier`,
42
+ zstd request compression, and the WebSocket transport. Each is a real
43
+ field on the wire; none of them has a kiso setting behind it, and a
44
+ hardcoded default would be this adapter inventing a policy nobody chose.
45
+
46
+ `strict` is likewise absent from tool definitions: kiso's schemas are
47
+ already closed worlds validated by the kernel, and the flag would add a
48
+ second validator with different rules.
49
+
50
+ ## Two behaviours worth knowing
51
+
52
+ - **`max_output_tokens` below 16 is refused, not clamped.** The provider
53
+ rejects it; kiso answers before the request rather than silently
54
+ raising a bound the caller asked for.
55
+ - **`usage.inputTokens` is the provider's RAW count**, which on this
56
+ dialect includes the cached prefix. The runtime's canonicalizer owns
57
+ the subtraction — doing it here too would bill the cached tokens away
58
+ twice.
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The OpenAI Responses adapter — one adapter, two targets.
3
+ *
4
+ * Plain `fetch` plus a hand-rolled SSE reader: no SDK, no new dependency.
5
+ * The Responses dialect is small enough that a vendor client would buy
6
+ * nothing here and would bring its own retry policy, which this tree
7
+ * refuses (the kernel is the sole retry authority — CX-1 F8).
8
+ *
9
+ * The TARGET is inferred from the options the factory is handed, never
10
+ * from a separate switch that could disagree with them:
11
+ * { apiKey } → the first-party Responses API (api.openai.com/v1/responses)
12
+ * { oauth } → the ChatGPT subscription backend
13
+ * (chatgpt.com/backend-api/codex/responses)
14
+ * The `oauth` thunk is called ONCE PER REQUEST, so a token that expires
15
+ * mid-session is refreshed by whoever owns the credential store — the
16
+ * adapter never caches a token and never refreshes one itself.
17
+ *
18
+ * Design source: the reference implementation's client for this backend
19
+ * (MIT) — base URL resolution, the header set, the `plan_type` /
20
+ * `resets_at` rate-limit wording, and the SSE frame split are reused as
21
+ * DESIGN; no block is copied, and the code here is kiso's.
22
+ *
23
+ * Invariants (the same the two older adapters prove):
24
+ * - `usage` always precedes the final `stop`;
25
+ * - no terminal event ⇒ `usage { known:false }` then `stop { reason:"error" }`;
26
+ * - a tool call's id is captured once and never changes mid-stream;
27
+ * - exactly ONE fetch per stream — no retry loop lives here;
28
+ * - every non-2xx becomes `mapApiError(status, message, retryAfterMs)`.
29
+ */
30
+ import type { Adapter } from "@vincemakes/kiso-core";
31
+ /** The ChatGPT backend's own token, resolved fresh for each request. */
32
+ export interface ResponsesOAuthToken {
33
+ readonly access: string;
34
+ readonly accountId: string;
35
+ }
36
+ export interface OpenAIResponsesProviderConfig {
37
+ /** First-party target: the API key. */
38
+ readonly apiKey?: string;
39
+ /** ChatGPT target: the token thunk, awaited once per request. */
40
+ readonly oauth?: () => Promise<ResponsesOAuthToken>;
41
+ /** Overrides the target's default base URL (tests and proxies). */
42
+ readonly baseUrl?: string;
43
+ /** The ChatGPT backend's prefix-cache key — the session id, so one
44
+ * session's requests share a cache lane. Sent only to that target. */
45
+ readonly promptCacheKey?: string;
46
+ /** MG-1 (A5): the adapter's replay identity. The kernel STAMPS a
47
+ * turn's continuation with the run's own scope, so the identity this
48
+ * adapter matches against on the NEXT turn must be the one the
49
+ * runtime resolved — passed in rather than re-derived here, so the
50
+ * two sides cannot drift. (They can: an API key configured against
51
+ * the ChatGPT origin resolves to "chatgpt" by origin and "openai" by
52
+ * target, and the symptom would be reasoning that silently never
53
+ * replays.) Absent — a direct SDK consumer — falls back to the
54
+ * target's own identity. */
55
+ readonly scope?: OpenAIResponsesScope;
56
+ }
57
+ /** The provider-identity half of the continuation scope; `apiId` is
58
+ * constant for this adapter and `modelId` is per-request. */
59
+ export interface OpenAIResponsesScope {
60
+ readonly providerId: string;
61
+ }
62
+ export declare function createOpenAIResponsesProvider(config?: OpenAIResponsesProviderConfig): Adapter;
package/dist/index.js ADDED
@@ -0,0 +1,629 @@
1
+ /**
2
+ * The OpenAI Responses adapter — one adapter, two targets.
3
+ *
4
+ * Plain `fetch` plus a hand-rolled SSE reader: no SDK, no new dependency.
5
+ * The Responses dialect is small enough that a vendor client would buy
6
+ * nothing here and would bring its own retry policy, which this tree
7
+ * refuses (the kernel is the sole retry authority — CX-1 F8).
8
+ *
9
+ * The TARGET is inferred from the options the factory is handed, never
10
+ * from a separate switch that could disagree with them:
11
+ * { apiKey } → the first-party Responses API (api.openai.com/v1/responses)
12
+ * { oauth } → the ChatGPT subscription backend
13
+ * (chatgpt.com/backend-api/codex/responses)
14
+ * The `oauth` thunk is called ONCE PER REQUEST, so a token that expires
15
+ * mid-session is refreshed by whoever owns the credential store — the
16
+ * adapter never caches a token and never refreshes one itself.
17
+ *
18
+ * Design source: the reference implementation's client for this backend
19
+ * (MIT) — base URL resolution, the header set, the `plan_type` /
20
+ * `resets_at` rate-limit wording, and the SSE frame split are reused as
21
+ * DESIGN; no block is copied, and the code here is kiso's.
22
+ *
23
+ * Invariants (the same the two older adapters prove):
24
+ * - `usage` always precedes the final `stop`;
25
+ * - no terminal event ⇒ `usage { known:false }` then `stop { reason:"error" }`;
26
+ * - a tool call's id is captured once and never changes mid-stream;
27
+ * - exactly ONE fetch per stream — no retry loop lives here;
28
+ * - every non-2xx becomes `mapApiError(status, message, retryAfterMs)`.
29
+ */
30
+ import { mapApiError, parseRetryAfter } from "@vincemakes/kiso-core";
31
+ const FIRST_PARTY_BASE = "https://api.openai.com/v1";
32
+ const CHATGPT_BASE = "https://chatgpt.com/backend-api";
33
+ /**
34
+ * The provider rejects `max_output_tokens` below 16 with a 400. kiso does
35
+ * NOT clamp it: a silently raised cap is a request the caller did not
36
+ * make, and the caller asked for a bound. The refusal happens before the
37
+ * request so the failure names the setting rather than the vendor's 400.
38
+ */
39
+ const MIN_OUTPUT_TOKENS = 16;
40
+ /** MG-1 (A5): this adapter's continuation entries — one whole reasoning
41
+ * output item, serialized verbatim. */
42
+ const ENTRY_KIND = "openai-responses.item";
43
+ const API_ID = "openai-responses";
44
+ /** `<base>/responses`, tolerating a base that already names the path. */
45
+ function firstPartyUrl(baseUrl) {
46
+ const normalized = baseUrl.replace(/\/+$/, "");
47
+ return normalized.endsWith("/responses") ? normalized : `${normalized}/responses`;
48
+ }
49
+ /** `<base>/codex/responses` — the vendor's own path for this backend,
50
+ * tolerating a base that already names part of it (the reference
51
+ * implementation's resolution, kept as design). */
52
+ function chatgptUrl(baseUrl) {
53
+ const normalized = baseUrl.replace(/\/+$/, "");
54
+ if (normalized.endsWith("/codex/responses"))
55
+ return normalized;
56
+ if (normalized.endsWith("/codex"))
57
+ return `${normalized}/responses`;
58
+ return `${normalized}/codex/responses`;
59
+ }
60
+ function resolveTarget(config) {
61
+ const oauth = config.oauth;
62
+ if (oauth !== undefined) {
63
+ return {
64
+ providerId: "chatgpt",
65
+ url: chatgptUrl(config.baseUrl ?? CHATGPT_BASE),
66
+ headers: async () => {
67
+ const token = await oauth();
68
+ return {
69
+ authorization: `Bearer ${token.access}`,
70
+ "chatgpt-account-id": token.accountId,
71
+ originator: "kiso",
72
+ "OpenAI-Beta": "responses=experimental",
73
+ };
74
+ },
75
+ // The backend rejects `store: true`; with nothing stored, the
76
+ // reasoning items have to come back on the next turn, which is
77
+ // what `include` asks for and what the continuation replays.
78
+ extraBody: {
79
+ store: false,
80
+ include: ["reasoning.encrypted_content"],
81
+ ...(config.promptCacheKey !== undefined ? { prompt_cache_key: config.promptCacheKey } : {}),
82
+ },
83
+ };
84
+ }
85
+ return {
86
+ providerId: "openai",
87
+ url: firstPartyUrl(config.baseUrl ?? FIRST_PARTY_BASE),
88
+ headers: async () => ({ authorization: `Bearer ${config.apiKey ?? ""}` }),
89
+ extraBody: {},
90
+ };
91
+ }
92
+ export function createOpenAIResponsesProvider(config = {}) {
93
+ const target = resolveTarget(config);
94
+ const scopeProviderId = config.scope?.providerId ?? target.providerId;
95
+ return {
96
+ async *stream(options) {
97
+ if (options.maxTokens !== undefined && options.maxTokens < MIN_OUTPUT_TOKENS) {
98
+ throw {
99
+ code: "invalid_request",
100
+ retryable: false,
101
+ message: `[${target.providerId}] maxTokens ${options.maxTokens} is below the Responses API floor of ${MIN_OUTPUT_TOKENS} — raise it; kiso never silently raises a cap you set`,
102
+ };
103
+ }
104
+ const body = buildBody(options, target, scopeProviderId);
105
+ const response = await requestOnce(target, body, options.signal);
106
+ yield* mapStream(response, options, target, scopeProviderId);
107
+ },
108
+ };
109
+ }
110
+ // ── The request ────────────────────────────────────────────────────────
111
+ function buildBody(options, target, scopeProviderId) {
112
+ return {
113
+ model: options.model,
114
+ stream: true,
115
+ // The system prompt is the Responses dialect's `instructions`.
116
+ // Absent means ABSENT — no default assistant prompt is invented.
117
+ ...(options.systemPrompt !== undefined ? { instructions: options.systemPrompt } : {}),
118
+ input: toInput(options.messages, options.model, scopeProviderId),
119
+ ...(options.tools?.length ? { tools: options.tools.map(toResponsesTool) } : {}),
120
+ // XP-1: the RESOLVED effort. The registry's `wire` string names the
121
+ // dialect parameter for a human reader and never reaches an
122
+ // adapter — the dialect path is this adapter's own knowledge, and
123
+ // the level is transported as handed. Absent adds NO key.
124
+ ...(options.reasoning?.effort !== undefined ? { reasoning: { effort: options.reasoning.effort } } : {}),
125
+ ...(options.maxTokens !== undefined ? { max_output_tokens: options.maxTokens } : {}),
126
+ ...(options.temperature !== undefined ? { temperature: options.temperature } : {}),
127
+ ...target.extraBody,
128
+ };
129
+ }
130
+ function toResponsesTool(tool) {
131
+ // No `strict`: the flag changes the PROVIDER's validation semantics,
132
+ // and kiso's schemas are already closed worlds validated by the kernel
133
+ // (PH-1a.1). Sending it would claim a second, disagreeing validator.
134
+ return { type: "function", name: tool.name, description: tool.description, parameters: tool.inputSchema };
135
+ }
136
+ function toInputContent(content) {
137
+ if (typeof content === "string")
138
+ return [{ type: "input_text", text: content }];
139
+ return content.map((block) => block.type === "text"
140
+ ? { type: "input_text", text: block.text }
141
+ : {
142
+ type: "input_image",
143
+ detail: "auto",
144
+ // A base64 block becomes a REAL data URL; a URL-sourced
145
+ // block passes the provider URL through.
146
+ image_url: block.sourceType === "base64"
147
+ ? `data:${block.mediaType ?? "image/png"};base64,${block.data ?? ""}`
148
+ : (block.url ?? ""),
149
+ });
150
+ }
151
+ /** A tool result is text on this dialect. An image is turned into an
152
+ * EXPLICIT note saying what was omitted and why — never dropped in
153
+ * silence (the same honesty the compat adapter keeps). */
154
+ function toToolResultOutput(content) {
155
+ if (typeof content === "string")
156
+ return content;
157
+ return content
158
+ .map((b) => b.type === "text"
159
+ ? b.text
160
+ : `[image omitted — Responses tool results carry text only: ${b.sourceType === "base64" ? `${b.mediaType ?? "image"} (${b.data?.length ?? 0} base64 chars)` : `url ${b.url ?? ""}`}]`)
161
+ .join("");
162
+ }
163
+ function toOutputItem(block) {
164
+ if (block.type === "text") {
165
+ // No `id`: this dialect accepts a replayed assistant message
166
+ // without one, and inventing an item id would be a fabricated
167
+ // provider fact.
168
+ return {
169
+ type: "message",
170
+ role: "assistant",
171
+ content: [{ type: "output_text", text: block.text, annotations: [] }],
172
+ status: "completed",
173
+ };
174
+ }
175
+ // No `id` here either — an item id pairs a function_call with a stored
176
+ // reasoning item, and under `store: false` there is nothing to pair
177
+ // with. `call_id` is the identity the kernel and the provider share.
178
+ return { type: "function_call", call_id: block.callId, name: block.name, arguments: JSON.stringify(block.input) };
179
+ }
180
+ /** MG-1 (A5): the stored reasoning items replay ONLY to the scope that
181
+ * produced them — provider AND api AND model. A mismatched or absent
182
+ * envelope replays nothing; an entry that no longer parses is skipped
183
+ * rather than killing the request. */
184
+ function continuationItems(msg, model, providerId) {
185
+ const c = msg.continuation;
186
+ if (c === undefined)
187
+ return [];
188
+ const s = c.scope;
189
+ if (s.providerId !== providerId || s.apiId !== API_ID || s.modelId !== model)
190
+ return [];
191
+ const out = [];
192
+ for (const e of c.entries) {
193
+ if (e.kind !== ENTRY_KIND)
194
+ continue;
195
+ try {
196
+ out.push(JSON.parse(e.data));
197
+ }
198
+ catch {
199
+ // opaque bytes that no longer parse must not kill the request
200
+ }
201
+ }
202
+ return out;
203
+ }
204
+ function toInput(messages, model, providerId) {
205
+ const out = [];
206
+ for (const msg of messages) {
207
+ if (msg.role === "user") {
208
+ out.push({ role: "user", content: toInputContent(msg.content) });
209
+ }
210
+ else if (msg.role === "assistant") {
211
+ // The reasoning items come FIRST, in emission order: the
212
+ // provider reads the turn back in the order it produced it.
213
+ out.push(...continuationItems(msg, model, providerId), ...msg.blocks.map(toOutputItem));
214
+ }
215
+ else {
216
+ out.push({ type: "function_call_output", call_id: msg.callId, output: toToolResultOutput(msg.content) });
217
+ }
218
+ }
219
+ return out;
220
+ }
221
+ /**
222
+ * EXACTLY ONE fetch. No retry loop lives here: the kernel owns the retry
223
+ * budget, and a second attempt underneath it would run outside that
224
+ * budget and outside the request trace (CX-1 F8).
225
+ */
226
+ async function requestOnce(target, body, signal) {
227
+ const headers = {
228
+ "content-type": "application/json",
229
+ accept: "text/event-stream",
230
+ ...(await target.headers()),
231
+ };
232
+ let response;
233
+ try {
234
+ response = await fetch(target.url, {
235
+ method: "POST",
236
+ headers,
237
+ body: JSON.stringify(body),
238
+ ...(signal !== undefined ? { signal: signal } : {}),
239
+ });
240
+ }
241
+ catch (err) {
242
+ throw toTransportError(err, target.providerId);
243
+ }
244
+ if (!response.ok)
245
+ throw await toHttpError(response, target.providerId);
246
+ return response;
247
+ }
248
+ async function* mapStream(response, options, target, scopeProviderId) {
249
+ const state = { slots: new Map(), entries: [], sawToolCall: false, terminal: false };
250
+ const frames = readSSE(response, options.signal, target.providerId);
251
+ try {
252
+ for (;;) {
253
+ let step;
254
+ try {
255
+ step = await frames.next();
256
+ }
257
+ catch (err) {
258
+ // A cancellation is the CALLER's act and a protocol failure
259
+ // is the provider's — both propagate unchanged. What is left
260
+ // is the connection dying mid-stream: a RETRYABLE network
261
+ // error, thrown as the two older adapters throw it, so the
262
+ // kernel's stream-cut recovery (F4: void the draft durably,
263
+ // then retry) engages. The trailing guard below is for a
264
+ // stream that ENDED cleanly without a terminal frame — a
265
+ // truncated turn the provider chose to end, not a cut.
266
+ if (isAbort(err) || options.signal?.aborted || isStructured(err))
267
+ throw err;
268
+ throw toTransportError(err, target.providerId);
269
+ }
270
+ if (step.done)
271
+ break;
272
+ // Outside the try on purpose: a mapping failure (unparseable
273
+ // tool arguments, an id that changed, an error frame) is this
274
+ // adapter's own verdict and must never be mistaken for a cut.
275
+ yield* mapFrame(step.value, state, options.model, target.providerId, scopeProviderId);
276
+ if (state.terminal)
277
+ break;
278
+ }
279
+ }
280
+ finally {
281
+ // Closes the reader and removes the abort listener on EVERY exit —
282
+ // including the consumer abandoning the iteration mid-turn.
283
+ await frames.return(undefined);
284
+ }
285
+ if (state.terminal)
286
+ return;
287
+ // The trailing guard: a stream that never reached a terminal response
288
+ // is a truncated turn. Usage is UNKNOWN (nulls, never a free turn),
289
+ // the stop is an explicit error, and nothing follows it.
290
+ yield usageEvent(undefined);
291
+ yield stopEvent("error", state, options.model, scopeProviderId);
292
+ }
293
+ function* mapFrame(frame, state, model, providerId, scopeProviderId) {
294
+ const index = frame.output_index ?? 0;
295
+ switch (frame.type) {
296
+ case "response.output_item.added": {
297
+ const item = frame.item;
298
+ if (item === undefined)
299
+ break;
300
+ if (item.type === "message") {
301
+ state.slots.set(index, { kind: "text" });
302
+ yield { seq: 0, type: "text_start" };
303
+ }
304
+ else if (item.type === "function_call") {
305
+ yield* openToolCall(state, index, item);
306
+ }
307
+ // A reasoning item needs no slot: its text arrives as flat
308
+ // `thinking` events that carry no index, and the item itself
309
+ // is captured whole at its done frame.
310
+ break;
311
+ }
312
+ case "response.output_text.delta":
313
+ case "response.refusal.delta": {
314
+ if (frame.delta === undefined)
315
+ break;
316
+ // A delta never precedes its start: an index that has no open
317
+ // text block gets one here rather than the text being dropped.
318
+ if (state.slots.get(index)?.kind !== "text") {
319
+ state.slots.set(index, { kind: "text" });
320
+ yield { seq: 0, type: "text_start" };
321
+ }
322
+ yield { seq: 0, type: "text_delta", text: frame.delta };
323
+ break;
324
+ }
325
+ case "response.reasoning_text.delta":
326
+ case "response.reasoning_summary_text.delta":
327
+ // ONE flat `thinking` event — the contract has no
328
+ // start/delta/end for reasoning, and the two reasoning
329
+ // dialects (raw text and summary) are the same stream to a
330
+ // reader.
331
+ if (frame.delta !== undefined && frame.delta !== "")
332
+ yield { seq: 0, type: "thinking", text: frame.delta };
333
+ break;
334
+ case "response.function_call_arguments.delta": {
335
+ const slot = state.slots.get(index);
336
+ // An arguments delta carries no identity of its own, so an
337
+ // index with no open call has nothing to attribute it to.
338
+ if (slot?.kind !== "tool" || frame.delta === undefined)
339
+ break;
340
+ slot.sent += frame.delta;
341
+ yield { seq: 0, type: "tool_call_input_delta", callId: slot.callId, inputJsonDelta: frame.delta };
342
+ break;
343
+ }
344
+ case "response.function_call_arguments.done": {
345
+ const slot = state.slots.get(index);
346
+ const args = frame.arguments;
347
+ if (slot?.kind !== "tool" || args === undefined)
348
+ break;
349
+ // Only the SUFFIX the deltas did not carry: the accumulated
350
+ // input must equal the arguments exactly once. A final value
351
+ // that is not an extension of what was streamed emits nothing
352
+ // — the authoritative arguments still arrive with the item's
353
+ // done frame.
354
+ if (!args.startsWith(slot.sent))
355
+ break;
356
+ const suffix = args.slice(slot.sent.length);
357
+ slot.sent = args;
358
+ if (suffix !== "")
359
+ yield { seq: 0, type: "tool_call_input_delta", callId: slot.callId, inputJsonDelta: suffix };
360
+ break;
361
+ }
362
+ case "response.output_item.done": {
363
+ const item = frame.item;
364
+ if (item === undefined)
365
+ break;
366
+ if (item.type === "message") {
367
+ state.slots.delete(index);
368
+ yield { seq: 0, type: "text_end" };
369
+ }
370
+ else if (item.type === "function_call") {
371
+ yield* closeToolCall(state, index, item, providerId);
372
+ }
373
+ else if (item.type === "reasoning") {
374
+ // MG-1 (A5): the WHOLE item, verbatim — the encrypted
375
+ // payload IS the model's state, and only an item that
376
+ // carries one is worth replaying (the first-party target
377
+ // asks for no encrypted content, so it produces none).
378
+ if (typeof item.encrypted_content === "string" && item.encrypted_content !== "") {
379
+ state.entries.push({ kind: ENTRY_KIND, required: true, data: JSON.stringify(item) });
380
+ }
381
+ }
382
+ break;
383
+ }
384
+ case "response.completed":
385
+ case "response.incomplete": {
386
+ state.terminal = true;
387
+ const response = frame.response;
388
+ // usage BEFORE stop, both read from the SAME frame: a usage
389
+ // taken from anywhere else would belong to another response.
390
+ yield usageEvent(response?.usage ?? undefined);
391
+ yield stopEvent(stopReasonOf(response, state.sawToolCall), state, model, scopeProviderId);
392
+ break;
393
+ }
394
+ case "error":
395
+ throw mapApiError(undefined, `[${providerId}] stream error: ${frame.message ?? frame.code ?? "no detail"}`);
396
+ case "response.failed":
397
+ throw mapApiError(undefined, `[${providerId}] response failed: ${failureDetail(frame.response)}`);
398
+ default:
399
+ break;
400
+ }
401
+ }
402
+ function* openToolCall(state, index, item) {
403
+ const callId = item.call_id ?? "";
404
+ const name = item.name ?? "";
405
+ state.slots.set(index, { kind: "tool", callId, name, sent: item.arguments ?? "" });
406
+ yield { seq: 0, type: "tool_call_start", callId, name };
407
+ }
408
+ function* closeToolCall(state, index, item, providerId) {
409
+ const open = state.slots.get(index);
410
+ const callId = item.call_id ?? "";
411
+ if (open?.kind !== "tool") {
412
+ // The done frame carries the whole identity, so a call whose added
413
+ // frame never arrived still gets its start before its end — an end
414
+ // without a start would be a forged event order.
415
+ yield* openToolCall(state, index, item);
416
+ }
417
+ else if (open.callId !== callId) {
418
+ // round 9's rule, one dialect over: the id is captured ONCE. A
419
+ // different id under the same index is a protocol violation, never
420
+ // a silent switch — start, deltas and end share one identity.
421
+ throw {
422
+ code: "invalid_request",
423
+ retryable: false,
424
+ message: `[${providerId}] tool call at output index ${index} changed id mid-stream: ${open.callId} → ${callId}`,
425
+ };
426
+ }
427
+ state.slots.delete(index);
428
+ state.sawToolCall = true;
429
+ const raw = item.arguments === undefined || item.arguments === "" ? "{}" : item.arguments;
430
+ let parsed;
431
+ try {
432
+ parsed = JSON.parse(raw);
433
+ }
434
+ catch {
435
+ parsed = undefined;
436
+ }
437
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
438
+ // NEVER a silent repair and never a null input passed off as an
439
+ // empty call: the model asked for something this adapter cannot
440
+ // state, so the turn fails loudly.
441
+ throw {
442
+ code: "invalid_request",
443
+ retryable: false,
444
+ message: `[${providerId}] tool call ${callId} sent arguments that are not a JSON object: ${raw.slice(0, 200)}`,
445
+ };
446
+ }
447
+ yield {
448
+ seq: 0,
449
+ type: "tool_call_end",
450
+ callId,
451
+ name: item.name ?? (open?.kind === "tool" ? open.name : ""),
452
+ input: parsed,
453
+ };
454
+ }
455
+ function usageEvent(usage) {
456
+ if (usage === undefined || usage === null) {
457
+ return { seq: 0, type: "usage", inputTokens: null, outputTokens: null, cacheRead: null, cacheWrite: null, known: false };
458
+ }
459
+ return {
460
+ seq: 0,
461
+ type: "usage",
462
+ // RAW, as the provider reports it: `input_tokens` INCLUDES the
463
+ // cached prefix on this dialect, and the runtime's canonicalizer
464
+ // owns the subtraction. Doing it here too would bill the cached
465
+ // tokens away twice.
466
+ inputTokens: usage.input_tokens ?? null,
467
+ outputTokens: usage.output_tokens ?? null,
468
+ cacheRead: usage.input_tokens_details?.cached_tokens ?? null,
469
+ // The Responses API reports no cache-creation count; null is the
470
+ // honest answer, never a zero.
471
+ cacheWrite: null,
472
+ known: true,
473
+ };
474
+ }
475
+ function stopEvent(reason, state, model, scopeProviderId) {
476
+ return {
477
+ seq: 0,
478
+ type: "stop",
479
+ reason,
480
+ // A5: the captured items ride the stop as opaque entries with a
481
+ // self-reported scope; the kernel re-stamps it (adapters are not
482
+ // trusted). `required` is unconditional here: under `store: false`
483
+ // the next request is INVALID without them.
484
+ ...(state.entries.length > 0
485
+ ? { continuation: { scope: { providerId: scopeProviderId, apiId: API_ID, modelId: model }, entries: state.entries } }
486
+ : {}),
487
+ };
488
+ }
489
+ function stopReasonOf(response, sawToolCall) {
490
+ if (response?.status === "completed")
491
+ return sawToolCall ? "tool_use" : "end_turn";
492
+ if (response?.status === "incomplete") {
493
+ return response.incomplete_details?.reason === "max_output_tokens" ? "max_tokens" : "error";
494
+ }
495
+ // failed, cancelled, a status this adapter has never seen, or none at
496
+ // all: an error, never degraded into a clean end.
497
+ return "error";
498
+ }
499
+ function failureDetail(response) {
500
+ const error = response?.error;
501
+ if (error !== undefined && error !== null)
502
+ return `${error.code ?? "unknown"}: ${error.message ?? "no message"}`;
503
+ const reason = response?.incomplete_details?.reason;
504
+ return reason !== undefined ? `incomplete: ${reason}` : "no error details in the response";
505
+ }
506
+ /**
507
+ * The SSE reader: frames are separated by a blank line, and a frame's
508
+ * `data:` lines join with newlines. `[DONE]` is a sentinel, not an event.
509
+ * The abort signal reaches BOTH the fetch (above) and this reader, and
510
+ * the listener is removed in `finally` — an adapter that leaves a
511
+ * listener on a long-lived signal leaks one per turn.
512
+ */
513
+ async function* readSSE(response, signal, providerId) {
514
+ const body = response.body;
515
+ if (body === null)
516
+ return;
517
+ const reader = body.getReader();
518
+ const decoder = new TextDecoder();
519
+ const onAbort = () => {
520
+ void reader.cancel().catch(() => { });
521
+ };
522
+ signal?.addEventListener("abort", onAbort, { once: true });
523
+ let buffer = "";
524
+ try {
525
+ for (;;) {
526
+ const { done, value } = await reader.read();
527
+ // Checked after the read, not only before: a cancel resolves a
528
+ // pending read rather than rejecting it, and a turn the caller
529
+ // stopped must not look like a clean end of stream.
530
+ if (signal?.aborted)
531
+ throw abortError();
532
+ if (done)
533
+ break;
534
+ buffer += decoder.decode(value, { stream: true });
535
+ let idx = buffer.indexOf("\n\n");
536
+ while (idx !== -1) {
537
+ const frame = buffer.slice(0, idx);
538
+ buffer = buffer.slice(idx + 2);
539
+ const data = frame
540
+ .split("\n")
541
+ .filter((l) => l.startsWith("data:"))
542
+ .map((l) => l.slice(5).trim())
543
+ .join("\n");
544
+ if (data !== "" && data !== "[DONE]")
545
+ yield parseFrame(data, providerId);
546
+ idx = buffer.indexOf("\n\n");
547
+ }
548
+ }
549
+ }
550
+ finally {
551
+ signal?.removeEventListener("abort", onAbort);
552
+ try {
553
+ await reader.cancel();
554
+ }
555
+ catch {
556
+ // already closed or errored — there is nothing left to release
557
+ }
558
+ }
559
+ }
560
+ function parseFrame(data, providerId) {
561
+ try {
562
+ return JSON.parse(data);
563
+ }
564
+ catch {
565
+ // A frame that is not JSON is a PROTOCOL failure, not a truncated
566
+ // turn: it is reported rather than folded into the trailing guard.
567
+ throw mapApiError(undefined, `[${providerId}] malformed SSE frame: ${data.slice(0, 200)}`);
568
+ }
569
+ }
570
+ function abortError() {
571
+ const err = new Error("the request was aborted");
572
+ err.name = "AbortError";
573
+ return err;
574
+ }
575
+ function isAbort(err) {
576
+ return err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError");
577
+ }
578
+ /** Already a StructuredError (this adapter's own verdict, or the kernel's
579
+ * shape from mapApiError) — it travels unchanged. */
580
+ function isStructured(err) {
581
+ return typeof err === "object" && err !== null && "code" in err && "retryable" in err;
582
+ }
583
+ // ── Errors ─────────────────────────────────────────────────────────────
584
+ /** `retry-after-ms` (the vendor's millisecond header) wins over the HTTP
585
+ * `Retry-After`; neither is ever shortened. */
586
+ function retryAfterOf(headers) {
587
+ const ms = headers.get("retry-after-ms");
588
+ if (ms !== null && /^\d+$/.test(ms.trim()))
589
+ return Number(ms.trim());
590
+ return parseRetryAfter(headers.get("retry-after"));
591
+ }
592
+ async function toHttpError(response, providerId) {
593
+ const raw = await response.text().catch(() => "");
594
+ let message = raw !== "" ? raw : response.statusText || "request failed";
595
+ let planNote = "";
596
+ try {
597
+ const parsed = JSON.parse(raw);
598
+ const err = parsed.error;
599
+ if (err !== undefined && err !== null) {
600
+ if (typeof err.message === "string" && err.message !== "")
601
+ message = err.message;
602
+ // The subscription backend's quota facts belong in the MESSAGE a
603
+ // human reads. They never become retryAfterMs: `resets_at` is
604
+ // when the plan's window rolls over, not when this request may
605
+ // be retried, and feeding it to the kernel's backoff would park
606
+ // a session for hours on the provider's say-so. The label
607
+ // already names WHICH provider said it, so the note does not.
608
+ const plan = typeof err.plan_type === "string" ? ` (${err.plan_type.toLowerCase()} plan)` : "";
609
+ const mins = typeof err.resets_at === "number" ? Math.max(0, Math.round((err.resets_at * 1000 - Date.now()) / 60000)) : undefined;
610
+ if (plan !== "" || mins !== undefined) {
611
+ planNote = ` — usage limit${plan}${mins !== undefined ? `, resets in ~${mins} min` : ""}`;
612
+ }
613
+ }
614
+ }
615
+ catch {
616
+ // not JSON: the raw body is the message, which is the honest one
617
+ }
618
+ const signIn = response.status === 401 && providerId === "chatgpt" ? " — run `kiso login chatgpt`" : "";
619
+ return mapApiError(response.status, `[${providerId}] request failed: ${message}${planNote}${signIn}`, retryAfterOf(response.headers));
620
+ }
621
+ function toTransportError(err, providerId) {
622
+ // A cancellation is the CALLER's own act: it propagates unchanged, so
623
+ // it never reaches the kernel wearing `retryable: true` and gets the
624
+ // turn the caller stopped run again.
625
+ if (isAbort(err))
626
+ return err;
627
+ const message = err instanceof Error ? err.message : String(err);
628
+ return { code: "network", retryable: true, message: `[${providerId}] request failed: ${message}` };
629
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@vincemakes/kiso-provider-openai-responses",
3
+ "version": "0.31.0",
4
+ "description": "kiso OpenAI Responses adapter — the first-party Responses API and the ChatGPT subscription backend, one adapter, plain fetch + SSE.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.build.json",
20
+ "typecheck": "tsc -p tsconfig.json",
21
+ "test": "vitest run"
22
+ },
23
+ "dependencies": {
24
+ "@vincemakes/kiso-core": "0.31.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^26.1.2",
28
+ "typescript": "^5.7.2",
29
+ "vitest": "^3.0.0"
30
+ },
31
+ "engines": {
32
+ "node": ">=22"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/vincemakes/kiso.git",
37
+ "directory": "packages/provider-openai-responses"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/vincemakes/kiso/issues"
41
+ },
42
+ "homepage": "https://github.com/vincemakes/kiso/tree/main/packages/provider-openai-responses#readme"
43
+ }