@vincemakes/kiso-provider-openai 0.1.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,9 @@
1
+ # @vincemakes/kiso-provider-openai
2
+
3
+ The OpenAI-compatible adapter on the official SDK (OpenAI, GLM, Kimi,
4
+ DeepSeek, OpenRouter via base_url): reasoning dialects digested into
5
+ thinking, real streaming usage, exhaustive finish-reason mapping,
6
+ connection/timeout/5xx classification.
7
+
8
+ Requires Node >= 22. See the repository README for the framework
9
+ overview.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * OpenAI-compat adapter — built on the official SDK, covering OpenAI and the
3
+ * compat family (GLM, Kimi, DeepSeek, OpenRouter, ...) via base_url swap.
4
+ *
5
+ * Dialect digestion happens HERE, never in the union (ADR-0003):
6
+ * - reasoning_content / reasoning deltas (DeepSeek, GLM, Qwen) → `thinking`;
7
+ * - streaming tool calls: arguments arrive as fragmented deltas, keyed by
8
+ * index; the first delta of an index carries the call id + name. The
9
+ * adapter accumulates each call and emits `tool_call_end` with the parsed
10
+ * JSON (or null — never a silent repair) at the end of the stream.
11
+ *
12
+ * Invariants: at least one `usage` precedes the final `stop` when the
13
+ * provider reports usage (not all compat providers do); `stop` reason maps
14
+ * from finish_reason.
15
+ */
16
+ import OpenAI from "openai";
17
+ import type { ChatCompletionChunk } from "openai/resources/chat/completions";
18
+ import type { Adapter } from "@vincemakes/kiso-core";
19
+ /** Config accepted by the high-level factory (七: the provider owns its SDK). */
20
+ export interface OpenAICompatProviderConfig {
21
+ readonly apiKey?: string;
22
+ readonly baseUrl?: string;
23
+ }
24
+ /**
25
+ * High-level factory (七): builds the adapter FROM CONFIG, owning the SDK
26
+ * inside this package. Consumers (and the runtime's lazy provider path)
27
+ * import ONLY @vincemakes/kiso-provider-openai — the SDK stays a private dependency
28
+ * of this package, so nested installs resolve it next to here, never
29
+ * through a hoisted root.
30
+ */
31
+ export declare function createOpenAICompatProvider(config?: OpenAICompatProviderConfig): Adapter;
32
+ export declare function createOpenAICompatAdapter(client: OpenAI): Adapter;
33
+ export type { ChatCompletionChunk };
package/dist/index.js ADDED
@@ -0,0 +1,348 @@
1
+ /**
2
+ * OpenAI-compat adapter — built on the official SDK, covering OpenAI and the
3
+ * compat family (GLM, Kimi, DeepSeek, OpenRouter, ...) via base_url swap.
4
+ *
5
+ * Dialect digestion happens HERE, never in the union (ADR-0003):
6
+ * - reasoning_content / reasoning deltas (DeepSeek, GLM, Qwen) → `thinking`;
7
+ * - streaming tool calls: arguments arrive as fragmented deltas, keyed by
8
+ * index; the first delta of an index carries the call id + name. The
9
+ * adapter accumulates each call and emits `tool_call_end` with the parsed
10
+ * JSON (or null — never a silent repair) at the end of the stream.
11
+ *
12
+ * Invariants: at least one `usage` precedes the final `stop` when the
13
+ * provider reports usage (not all compat providers do); `stop` reason maps
14
+ * from finish_reason.
15
+ */
16
+ import OpenAI from "openai";
17
+ import { mapApiError } from "@vincemakes/kiso-core";
18
+ /**
19
+ * High-level factory (七): builds the adapter FROM CONFIG, owning the SDK
20
+ * inside this package. Consumers (and the runtime's lazy provider path)
21
+ * import ONLY @vincemakes/kiso-provider-openai — the SDK stays a private dependency
22
+ * of this package, so nested installs resolve it next to here, never
23
+ * through a hoisted root.
24
+ */
25
+ export function createOpenAICompatProvider(config = {}) {
26
+ const client = new OpenAI({
27
+ ...(config.apiKey !== undefined ? { apiKey: config.apiKey } : {}),
28
+ ...(config.baseUrl !== undefined ? { baseURL: config.baseUrl } : {}),
29
+ });
30
+ return createOpenAICompatAdapter(client);
31
+ }
32
+ export function createOpenAICompatAdapter(client) {
33
+ return {
34
+ async *stream(options) {
35
+ // Area 6: the stream CREATION is inside the error normalization —
36
+ // a 429/5xx/connection failure before the first byte is a mapped,
37
+ // retryable StructuredError, so the loop's pre-stream retry works.
38
+ let stream;
39
+ try {
40
+ stream = await client.chat.completions.create({
41
+ model: options.model,
42
+ messages: toOpenAIMessages(options.messages, options.systemPrompt),
43
+ stream: true,
44
+ // D5: request real streaming usage — without this the
45
+ // provider never sends a usage chunk and we would
46
+ // report known:false forever.
47
+ stream_options: { include_usage: true },
48
+ ...(options.tools?.length ? { tools: options.tools.map(toOpenAITool) } : {}),
49
+ ...(options.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {}),
50
+ ...(options.temperature !== undefined ? { temperature: options.temperature } : {}),
51
+ },
52
+ // Phase B: cancellation reaches the SDK; the system prompt is a
53
+ // first-class message, not a dropped option.
54
+ options.signal !== undefined ? { signal: options.signal } : undefined);
55
+ }
56
+ catch (err) {
57
+ throw toOpenAIError(err);
58
+ }
59
+ const pending = new Map();
60
+ let finishReason = null;
61
+ let usageSent = false;
62
+ let stopReason = "end_turn";
63
+ // P1-10: the FIRST finish reason is FINAL — later content and
64
+ // finish reasons are ignored (a provider that emits text after a
65
+ // content_filter must not be reported as a clean end_turn). The
66
+ // usage chunk is still accepted after the finish: some compat
67
+ // providers send it late.
68
+ let finishSeen = false;
69
+ try {
70
+ for await (const chunk of stream) {
71
+ if (finishSeen) {
72
+ if (chunk.usage) {
73
+ usageSent = true;
74
+ const details = chunk.usage.prompt_tokens_details;
75
+ yield {
76
+ seq: 0,
77
+ type: "usage",
78
+ inputTokens: chunk.usage.prompt_tokens ?? null,
79
+ outputTokens: chunk.usage.completion_tokens ?? null,
80
+ cacheRead: details?.cached_tokens ?? null,
81
+ cacheWrite: null,
82
+ known: true,
83
+ };
84
+ }
85
+ continue; // content and finish reasons after the first finish: ignored
86
+ }
87
+ // Reasoning dialect → thinking (digested here, not in the union).
88
+ const reasoning = chunk.choices?.[0]?.delta
89
+ ?.reasoning_content;
90
+ if (reasoning) {
91
+ yield { seq: 0, type: "thinking", text: reasoning };
92
+ }
93
+ const delta = chunk.choices?.[0]?.delta;
94
+ if (delta?.content) {
95
+ yield { seq: 0, type: "text_delta", text: delta.content };
96
+ }
97
+ for (const tc of delta?.tool_calls ?? []) {
98
+ const buffered = pending.get(tc.index);
99
+ if (!buffered) {
100
+ // 六: no fallback id is adopted yet — the id must
101
+ // arrive from the provider to become the identity.
102
+ pending.set(tc.index, {
103
+ index: tc.index,
104
+ id: "",
105
+ name: tc.function?.name ?? "",
106
+ json: "",
107
+ emittedStart: false,
108
+ pendingDeltas: [],
109
+ });
110
+ }
111
+ const call = pending.get(tc.index);
112
+ // Some compat providers stream the name/id in a LATER
113
+ // delta; update on every delta so tool_call_end never
114
+ // carries an empty name (review finding 10).
115
+ if (tc.function?.name)
116
+ call.name = tc.function.name;
117
+ if (tc.id) {
118
+ // 九: the FIRST non-empty id is the call's identity,
119
+ // forever. A DIFFERENT id later is a protocol
120
+ // violation — a structured error, never a silent
121
+ // switch (start/delta/end must share one identity).
122
+ if (call.id !== "" && tc.id !== call.id) {
123
+ throw {
124
+ code: "invalid_request",
125
+ retryable: false,
126
+ message: `tool call ${call.index} changed id mid-stream: ${call.id} → ${tc.id}`,
127
+ };
128
+ }
129
+ call.id = tc.id;
130
+ // The identity is now known: emit the start, then
131
+ // flush the argument deltas that arrived before it
132
+ // under the SAME id (六: start → delta → end all
133
+ // share one identity, never the fallback).
134
+ if (!call.emittedStart) {
135
+ call.emittedStart = true;
136
+ yield {
137
+ seq: 0,
138
+ type: "tool_call_start",
139
+ callId: call.id,
140
+ name: call.name,
141
+ };
142
+ for (const d of call.pendingDeltas) {
143
+ yield {
144
+ seq: 0,
145
+ type: "tool_call_input_delta",
146
+ callId: call.id,
147
+ inputJsonDelta: d,
148
+ };
149
+ }
150
+ call.pendingDeltas = [];
151
+ }
152
+ }
153
+ if (tc.function?.arguments) {
154
+ call.json += tc.function.arguments;
155
+ if (call.emittedStart) {
156
+ yield {
157
+ seq: 0,
158
+ type: "tool_call_input_delta",
159
+ callId: call.id,
160
+ inputJsonDelta: tc.function.arguments,
161
+ };
162
+ }
163
+ else {
164
+ call.pendingDeltas.push(tc.function.arguments);
165
+ }
166
+ }
167
+ }
168
+ if (chunk.usage) {
169
+ usageSent = true;
170
+ // 六: REAL cached-token data is read from the provider's
171
+ // prompt_tokens_details — an absent value is null, NEVER
172
+ // faked as a zero-cache turn. OpenAI does not report a
173
+ // cache write; null is the honest answer.
174
+ const details = chunk.usage.prompt_tokens_details;
175
+ yield {
176
+ seq: 0,
177
+ type: "usage",
178
+ inputTokens: chunk.usage.prompt_tokens ?? null,
179
+ outputTokens: chunk.usage.completion_tokens ?? null,
180
+ cacheRead: details?.cached_tokens ?? null,
181
+ cacheWrite: null,
182
+ known: true,
183
+ };
184
+ }
185
+ const fr = chunk.choices?.[0]?.finish_reason;
186
+ if (fr) {
187
+ // P1-10: the FIRST finish is the terminal one.
188
+ finishReason = fr;
189
+ stopReason = mapFinishReason(fr);
190
+ finishSeen = true;
191
+ }
192
+ }
193
+ }
194
+ catch (err) {
195
+ throw toOpenAIError(err);
196
+ }
197
+ // Close out any streamed tool calls.
198
+ for (const call of [...pending.values()].sort((a, b) => a.index - b.index)) {
199
+ let input = null;
200
+ try {
201
+ input = call.json ? JSON.parse(call.json) : {};
202
+ }
203
+ catch {
204
+ input = null; // never a silent repair
205
+ }
206
+ // 六: a call whose id NEVER arrived adopts the index fallback
207
+ // here — no start/delta was emitted under any other identity,
208
+ // so this end is the call's first and only identity.
209
+ yield {
210
+ seq: 0,
211
+ type: "tool_call_end",
212
+ callId: call.id || `call_${call.index}`,
213
+ name: call.name,
214
+ input,
215
+ };
216
+ }
217
+ if (!usageSent) {
218
+ // Area 6: no usage reported is expressed as UNKNOWN — nulls
219
+ // and known:false — never faked as a zero-cost turn.
220
+ yield { seq: 0, type: "usage", inputTokens: null, outputTokens: null, cacheRead: null, cacheWrite: null, known: false };
221
+ }
222
+ // Area 6 hardening (review finding 4): a stream that ended with
223
+ // NO finish_reason is a TRUNCATED turn — the stop is an explicit
224
+ // error, never a default end_turn/completed.
225
+ yield { seq: 0, type: "stop", reason: finishReason === null ? "error" : stopReason };
226
+ },
227
+ };
228
+ }
229
+ // ── Mapping helpers ────────────────────────────────────────────────────
230
+ /**
231
+ * Exhaustive over the SDK's CLOSED finish_reason union (Area 6): a new SDK
232
+ * enum is a compile error here; `content_filter` and `function_call` are
233
+ * explicit non-completions, never degraded into `end_turn`.
234
+ */
235
+ function mapFinishReason(reason) {
236
+ switch (reason) {
237
+ case "stop":
238
+ return "end_turn";
239
+ case "length":
240
+ return "max_tokens";
241
+ case "tool_calls":
242
+ return "tool_use";
243
+ case "function_call":
244
+ return "function_call";
245
+ case "content_filter":
246
+ return "content_filter";
247
+ case null:
248
+ // D3: a chunk with NO finish reason is not a stop — the caller
249
+ // decides; the trailing stop is only emitted when one was seen.
250
+ return "error";
251
+ default: {
252
+ // D3: an unknown finish reason is an error, never completed.
253
+ return "error";
254
+ }
255
+ }
256
+ }
257
+ function toOpenAIContent(content) {
258
+ if (typeof content === "string") {
259
+ return [{ type: "text", text: content }];
260
+ }
261
+ return content.map((block) => block.type === "text"
262
+ ? { type: "text", text: block.text }
263
+ : {
264
+ type: "image_url",
265
+ // 六: a base64 block becomes a REAL data URL —
266
+ // `data:<media>;base64,<data>` — never an empty string URL.
267
+ // URL-sourced blocks pass the provider URL through.
268
+ image_url: {
269
+ url: block.sourceType === "base64"
270
+ ? `data:${block.mediaType ?? "image/png"};base64,${block.data ?? ""}`
271
+ : (block.url ?? ""),
272
+ },
273
+ });
274
+ }
275
+ /**
276
+ * 六: OpenAI tool results accept TEXT ONLY — an image block is converted to
277
+ * an explicit, honest text note (what kind of image was omitted and why),
278
+ * never silently dropped.
279
+ */
280
+ function toOpenAIToolResultContent(content) {
281
+ if (typeof content === "string")
282
+ return content;
283
+ return content
284
+ .map((b) => b.type === "text"
285
+ ? b.text
286
+ : `[image omitted — OpenAI tool results accept text only: ${b.sourceType === "base64" ? `${b.mediaType ?? "image"} (${b.data?.length ?? 0} base64 chars)` : `url ${b.url ?? ""}`}]`)
287
+ .join("");
288
+ }
289
+ function toOpenAIMessages(messages, systemPrompt) {
290
+ const out = [];
291
+ // The OpenAI API takes the system prompt as a system-role message; compat
292
+ // providers (GLM/Kimi/DeepSeek/OpenRouter) follow the same shape. It was
293
+ // silently dropped before Phase B.
294
+ if (systemPrompt !== undefined) {
295
+ out.push({ role: "system", content: systemPrompt });
296
+ }
297
+ for (const msg of messages) {
298
+ if (msg.role === "user") {
299
+ out.push({ role: "user", content: toOpenAIContent(msg.content) });
300
+ }
301
+ else if (msg.role === "assistant") {
302
+ out.push({
303
+ role: "assistant",
304
+ content: msg.blocks.filter((b) => b.type === "text").map((b) => b.text).join(""),
305
+ tool_calls: msg.blocks
306
+ .filter((b) => b.type === "tool_use")
307
+ .map((b) => {
308
+ const t = b;
309
+ return {
310
+ id: t.callId,
311
+ type: "function",
312
+ function: { name: t.name, arguments: JSON.stringify(t.input) },
313
+ };
314
+ }),
315
+ });
316
+ }
317
+ else {
318
+ // tool messages accept text only — images are converted to an
319
+ // EXPLICIT text note (toOpenAIToolResultContent), never dropped
320
+ // (六).
321
+ out.push({
322
+ role: "tool",
323
+ tool_call_id: msg.callId,
324
+ content: toOpenAIToolResultContent(msg.content),
325
+ });
326
+ }
327
+ }
328
+ return out;
329
+ }
330
+ function toOpenAITool(tool) {
331
+ return {
332
+ type: "function",
333
+ function: { name: tool.name, description: tool.description, parameters: tool.inputSchema },
334
+ };
335
+ }
336
+ function toOpenAIError(err) {
337
+ // D4: connection-level failures are recognized, not lumped into unknown.
338
+ if (err instanceof OpenAI.APIConnectionTimeoutError) {
339
+ return { code: "timeout", retryable: true, message: err.message };
340
+ }
341
+ if (err instanceof OpenAI.APIConnectionError) {
342
+ return { code: "network", retryable: true, message: err.message };
343
+ }
344
+ if (err instanceof OpenAI.APIError) {
345
+ return mapApiError(err.status, err.message);
346
+ }
347
+ return err;
348
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@vincemakes/kiso-provider-openai",
3
+ "version": "0.1.0",
4
+ "description": "kiso OpenAI-compatible adapter \u2014 OpenAI + the compat family (GLM, Kimi, DeepSeek, OpenRouter) via base_url swap, reasoning dialects digested.",
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.1.0",
25
+ "openai": "^7.3.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^26.1.2",
29
+ "typescript": "^5.7.2",
30
+ "vitest": "^3.0.0"
31
+ },
32
+ "engines": {
33
+ "node": ">=22"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/vincemakes/kiso.git",
38
+ "directory": "packages/provider-openai"
39
+ },
40
+ "bugs": {
41
+ "url": "https://github.com/vincemakes/kiso/issues"
42
+ },
43
+ "homepage": "https://github.com/vincemakes/kiso/tree/main/packages/provider-openai#readme"
44
+ }