@haikit/openai 0.0.0-stage → 0.21.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 +21 -0
- package/README.md +32 -2
- package/dist/adapter.d.ts +31 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +35 -0
- package/dist/adapter.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/mapping.d.ts +20 -0
- package/dist/mapping.d.ts.map +1 -0
- package/dist/mapping.js +169 -0
- package/dist/mapping.js.map +1 -0
- package/package.json +51 -4
- package/src/adapter.ts +68 -0
- package/src/index.ts +14 -0
- package/src/mapping.ts +166 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hai 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
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @haikit/openai
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Model adapter for OpenAI models, over the Responses API.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { openai } from "@haikit/openai";
|
|
7
|
+
createHai({ model: openai({ model: "gpt-6-luna", effort: "medium" }), … });
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
With no `model`, it uses `gpt-6-luna`; pass any other model ID to change it.
|
|
11
|
+
`effort` defaults to `medium`; pass `effort: null` for a model that doesn't
|
|
12
|
+
reason, such as `gpt-4.1`, and no reasoning settings are sent. The key comes
|
|
13
|
+
from `OPENAI_API_KEY` unless you pass `apiKey`.
|
|
14
|
+
|
|
15
|
+
Note it imports only `@haikit/core`, never `@haikit/server`. Adapters implement an
|
|
16
|
+
interface the contract layer declares — that is what keeps the runtime
|
|
17
|
+
swappable, and what lets an app ship its own adapter without forking anything
|
|
18
|
+
(see `examples/*/src/server/scripted.ts`).
|
|
19
|
+
|
|
20
|
+
HaiKIT keeps the history in the Anthropic Messages shape, so this adapter
|
|
21
|
+
translates it into Responses API input on every call, and the reply back. It
|
|
22
|
+
sends the whole history each time with `store: false` rather than chaining
|
|
23
|
+
`previous_response_id`: the conversation lives in your store, not OpenAI's.
|
|
24
|
+
Reasoning items come back encrypted and are kept in the history, so the next
|
|
25
|
+
call can send them back.
|
|
26
|
+
|
|
27
|
+
A tool keeps strict mode only when OpenAI's strict mode accepts its schema:
|
|
28
|
+
every object lists all its properties in `required` and sets
|
|
29
|
+
`additionalProperties: false`. Any other tool is sent with `strict: false`.
|
|
30
|
+
|
|
31
|
+
A conversation begun with this adapter can't be continued with
|
|
32
|
+
`@haikit/anthropic`, or the other way round: each provider's reasoning blocks
|
|
33
|
+
mean nothing to the other.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ModelAdapter } from "@haikit/core";
|
|
2
|
+
/**
|
|
3
|
+
* OpenAI model adapter, over the Responses API.
|
|
4
|
+
*
|
|
5
|
+
* Note it imports only hai-core, never hai-server. Adapters implement an
|
|
6
|
+
* interface the contract layer declares; that is what keeps the runtime
|
|
7
|
+
* swappable, and what lets an app ship its own adapter without forking the
|
|
8
|
+
* framework.
|
|
9
|
+
*
|
|
10
|
+
* It sends the whole history on every call with `store: false`, rather than
|
|
11
|
+
* chaining `previous_response_id`: the conversation lives in the app's store,
|
|
12
|
+
* which HaiKIT itself writes into between calls, and one conversation's calls
|
|
13
|
+
* can come from different servers.
|
|
14
|
+
*/
|
|
15
|
+
export interface OpenAIOptions {
|
|
16
|
+
/** Any OpenAI model ID. Defaults to `gpt-6-luna`. */
|
|
17
|
+
model?: string;
|
|
18
|
+
maxTokens?: number;
|
|
19
|
+
/**
|
|
20
|
+
* Defaults to `medium`. `null` sends no reasoning settings at all, for a
|
|
21
|
+
* model that doesn't reason and so refuses them.
|
|
22
|
+
*/
|
|
23
|
+
effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | null;
|
|
24
|
+
apiKey?: string;
|
|
25
|
+
baseURL?: string;
|
|
26
|
+
/** Sent as `prompt_cache_key`, to route calls that share a prefix together. */
|
|
27
|
+
promptCacheKey?: string;
|
|
28
|
+
}
|
|
29
|
+
/** The adapter, given a way to make the client: tests pass a fake one. */
|
|
30
|
+
export declare function createAdapter(options: OpenAIOptions, makeClient: () => Promise<any>): ModelAdapter;
|
|
31
|
+
//# sourceMappingURL=adapter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAA+B,MAAM,cAAc,CAAC;AAG9E;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,aAAa;IAC5B,qDAAqD;IACrD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,GAAG,KAAK,GAAG,IAAI,CAAC;IACjF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+EAA+E;IAC/E,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,0EAA0E;AAC1E,wBAAgB,aAAa,CAAC,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,OAAO,CAAC,GAAG,CAAC,GAAG,YAAY,CAmClG"}
|
package/dist/adapter.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { fromOutput, toInput, toTools } from "./mapping.js";
|
|
2
|
+
/** The adapter, given a way to make the client: tests pass a fake one. */
|
|
3
|
+
export function createAdapter(options, makeClient) {
|
|
4
|
+
const model = options.model ?? "gpt-6-luna";
|
|
5
|
+
const effort = options.effort === undefined ? "medium" : options.effort;
|
|
6
|
+
let client = null;
|
|
7
|
+
return {
|
|
8
|
+
id: model,
|
|
9
|
+
async generate(request) {
|
|
10
|
+
if (!client)
|
|
11
|
+
client = await makeClient();
|
|
12
|
+
const stream = client.responses.stream({
|
|
13
|
+
model,
|
|
14
|
+
// Stable prefix: OpenAI caches it on its own, so nothing volatile may
|
|
15
|
+
// go in here.
|
|
16
|
+
instructions: request.system,
|
|
17
|
+
tools: toTools(request.tools),
|
|
18
|
+
input: toInput(request.messages),
|
|
19
|
+
max_output_tokens: options.maxTokens ?? 16000,
|
|
20
|
+
store: false,
|
|
21
|
+
// Encrypted reasoning comes back only when asked for, and a model that
|
|
22
|
+
// doesn't reason refuses both.
|
|
23
|
+
...(effort === null ? {} : { reasoning: { effort }, include: ["reasoning.encrypted_content"] }),
|
|
24
|
+
...(options.promptCacheKey ? { prompt_cache_key: options.promptCacheKey } : {}),
|
|
25
|
+
});
|
|
26
|
+
for await (const event of stream) {
|
|
27
|
+
if (event.type === "response.output_text.delta" || event.type === "response.refusal.delta") {
|
|
28
|
+
request.onTextDelta(event.delta);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return fromOutput(await stream.finalResponse());
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=adapter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"adapter.js","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AA8B5D,0EAA0E;AAC1E,MAAM,UAAU,aAAa,CAAC,OAAsB,EAAE,UAA8B;IAClF,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,YAAY,CAAC;IAC5C,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC;IACxE,IAAI,MAAM,GAAQ,IAAI,CAAC;IAEvB,OAAO;QACL,EAAE,EAAE,KAAK;QAET,KAAK,CAAC,QAAQ,CAAC,OAAqB;YAClC,IAAI,CAAC,MAAM;gBAAE,MAAM,GAAG,MAAM,UAAU,EAAE,CAAC;YAEzC,MAAM,MAAM,GAAG,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC;gBACrC,KAAK;gBACL,sEAAsE;gBACtE,cAAc;gBACd,YAAY,EAAE,OAAO,CAAC,MAAM;gBAC5B,KAAK,EAAE,OAAO,CAAC,OAAO,CAAC,KAAK,CAAC;gBAC7B,KAAK,EAAE,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC;gBAChC,iBAAiB,EAAE,OAAO,CAAC,SAAS,IAAI,KAAK;gBAC7C,KAAK,EAAE,KAAK;gBACZ,uEAAuE;gBACvE,+BAA+B;gBAC/B,GAAG,CAAC,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,CAAC,6BAA6B,CAAC,EAAE,CAAC;gBAC/F,GAAG,CAAC,OAAO,CAAC,cAAc,CAAC,CAAC,CAAC,EAAE,gBAAgB,EAAE,OAAO,CAAC,cAAc,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aAChF,CAAC,CAAC;YAEH,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;gBACjC,IAAI,KAAK,CAAC,IAAI,KAAK,4BAA4B,IAAI,KAAK,CAAC,IAAI,KAAK,wBAAwB,EAAE,CAAC;oBAC3F,OAAO,CAAC,WAAW,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;gBACnC,CAAC;YACH,CAAC;YAED,OAAO,UAAU,CAAC,MAAM,MAAM,CAAC,aAAa,EAAE,CAAC,CAAC;QAClD,CAAC;KACF,CAAC;AACJ,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,EAAiB,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AAEjE,YAAY,EAAE,aAAa,EAAE,CAAC;AAE9B,wBAAgB,MAAM,CAAC,OAAO,GAAE,aAAkB,GAAG,YAAY,CAQhE"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createAdapter } from "./adapter.js";
|
|
2
|
+
export function openai(options = {}) {
|
|
3
|
+
return createAdapter(options, async () => {
|
|
4
|
+
const { default: OpenAI } = await import("openai");
|
|
5
|
+
return new OpenAI({
|
|
6
|
+
...(options.apiKey ? { apiKey: options.apiKey } : {}),
|
|
7
|
+
...(options.baseURL ? { baseURL: options.baseURL } : {}),
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAsB,MAAM,cAAc,CAAC;AAIjE,MAAM,UAAU,MAAM,CAAC,UAAyB,EAAE;IAChD,OAAO,aAAa,CAAC,OAAO,EAAE,KAAK,IAAI,EAAE;QACvC,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,MAAM,MAAM,CAAC,QAAQ,CAAC,CAAC;QACnD,OAAO,IAAI,MAAM,CAAC;YAChB,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACrD,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SACzD,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Message, ModelRequest, ModelResponse } from "@haikit/core";
|
|
2
|
+
/**
|
|
3
|
+
* The history is kept in the Anthropic Messages shape, so this adapter's work
|
|
4
|
+
* is translation: the history into Responses API input items on the way out,
|
|
5
|
+
* and the response's output items back into content blocks on the way in.
|
|
6
|
+
* Everything here is pure, so it can be tested without the network.
|
|
7
|
+
*/
|
|
8
|
+
/** A reasoning item kept in the history so the next call can send it back. */
|
|
9
|
+
export declare const REASONING_BLOCK = "openai_reasoning";
|
|
10
|
+
export declare function toInput(messages: readonly Message[]): any[];
|
|
11
|
+
/**
|
|
12
|
+
* Whether OpenAI's strict mode accepts this schema: every object lists all of
|
|
13
|
+
* its properties in `required` and sets `additionalProperties: false`, all the
|
|
14
|
+
* way down. Sending `strict: true` on a schema that doesn't would fail the
|
|
15
|
+
* whole turn, so such a tool goes without strict mode instead.
|
|
16
|
+
*/
|
|
17
|
+
export declare function isStrictCompatible(schema: unknown): boolean;
|
|
18
|
+
export declare function toTools(tools: ModelRequest["tools"]): any[];
|
|
19
|
+
export declare function fromOutput(response: any): ModelResponse;
|
|
20
|
+
//# sourceMappingURL=mapping.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mapping.d.ts","sourceRoot":"","sources":["../src/mapping.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAEzE;;;;;GAKG;AAEH,8EAA8E;AAC9E,eAAO,MAAM,eAAe,qBAAqB,CAAC;AAkBlD,wBAAgB,OAAO,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,GAAG,GAAG,EAAE,CA2C3D;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CAoB3D;AAED,wBAAgB,OAAO,CAAC,KAAK,EAAE,YAAY,CAAC,OAAO,CAAC,GAAG,GAAG,EAAE,CAS3D;AAYD,wBAAgB,UAAU,CAAC,QAAQ,EAAE,GAAG,GAAG,aAAa,CAiCvD"}
|
package/dist/mapping.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The history is kept in the Anthropic Messages shape, so this adapter's work
|
|
3
|
+
* is translation: the history into Responses API input items on the way out,
|
|
4
|
+
* and the response's output items back into content blocks on the way in.
|
|
5
|
+
* Everything here is pure, so it can be tested without the network.
|
|
6
|
+
*/
|
|
7
|
+
/** A reasoning item kept in the history so the next call can send it back. */
|
|
8
|
+
export const REASONING_BLOCK = "openai_reasoning";
|
|
9
|
+
function refuse(what) {
|
|
10
|
+
throw new Error(`[haikit] openai: cannot send ${what} to OpenAI`);
|
|
11
|
+
}
|
|
12
|
+
/** A `tool_result`'s content, as the single string `function_call_output` takes. */
|
|
13
|
+
function resultText(content) {
|
|
14
|
+
if (content === undefined || content === null)
|
|
15
|
+
return "";
|
|
16
|
+
if (typeof content === "string")
|
|
17
|
+
return content;
|
|
18
|
+
if (Array.isArray(content)) {
|
|
19
|
+
return content
|
|
20
|
+
.map((b) => (b?.type === "text" && typeof b.text === "string" ? b.text : refuse(`a "${b?.type}" block in a tool_result`)))
|
|
21
|
+
.join("\n");
|
|
22
|
+
}
|
|
23
|
+
return refuse("a tool_result whose content is not a string or an array");
|
|
24
|
+
}
|
|
25
|
+
export function toInput(messages) {
|
|
26
|
+
const input = [];
|
|
27
|
+
for (const message of messages) {
|
|
28
|
+
const { role, content } = message;
|
|
29
|
+
if (typeof content === "string") {
|
|
30
|
+
input.push({ role, content });
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (!Array.isArray(content))
|
|
34
|
+
refuse(`a ${role} message whose content is not a string or an array`);
|
|
35
|
+
if (role === "user") {
|
|
36
|
+
// A function call's output must follow the call, so the results go
|
|
37
|
+
// first: the runtime puts notices' text after them in the same message.
|
|
38
|
+
const texts = [];
|
|
39
|
+
for (const block of content) {
|
|
40
|
+
if (block?.type === "tool_result") {
|
|
41
|
+
input.push({ type: "function_call_output", call_id: block.tool_use_id, output: resultText(block.content) });
|
|
42
|
+
}
|
|
43
|
+
else if (block?.type === "text") {
|
|
44
|
+
texts.push({ type: "input_text", text: block.text });
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
refuse(`a "${block?.type}" block in a user message`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (texts.length > 0)
|
|
51
|
+
input.push({ role: "user", content: texts });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
for (const block of content) {
|
|
55
|
+
if (block?.type === "text") {
|
|
56
|
+
if (block.text)
|
|
57
|
+
input.push({ role: "assistant", content: block.text });
|
|
58
|
+
}
|
|
59
|
+
else if (block?.type === "tool_use") {
|
|
60
|
+
input.push({ type: "function_call", call_id: block.id, name: block.name, arguments: JSON.stringify(block.input ?? {}) });
|
|
61
|
+
}
|
|
62
|
+
else if (block?.type === REASONING_BLOCK) {
|
|
63
|
+
input.push({ type: "reasoning", id: block.id, summary: block.summary ?? [], encrypted_content: block.encrypted_content });
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
refuse(`a "${block?.type}" block in an assistant message`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return input;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Whether OpenAI's strict mode accepts this schema: every object lists all of
|
|
74
|
+
* its properties in `required` and sets `additionalProperties: false`, all the
|
|
75
|
+
* way down. Sending `strict: true` on a schema that doesn't would fail the
|
|
76
|
+
* whole turn, so such a tool goes without strict mode instead.
|
|
77
|
+
*/
|
|
78
|
+
export function isStrictCompatible(schema) {
|
|
79
|
+
if (schema === null || typeof schema !== "object")
|
|
80
|
+
return true;
|
|
81
|
+
if (Array.isArray(schema))
|
|
82
|
+
return schema.every(isStrictCompatible);
|
|
83
|
+
const s = schema;
|
|
84
|
+
const isObject = s.type === "object" || (Array.isArray(s.type) && s.type.includes("object")) || "properties" in s;
|
|
85
|
+
if (isObject) {
|
|
86
|
+
if (s.additionalProperties !== false)
|
|
87
|
+
return false;
|
|
88
|
+
const keys = Object.keys(s.properties ?? {});
|
|
89
|
+
const required = Array.isArray(s.required) ? s.required : [];
|
|
90
|
+
if (!keys.every((k) => required.includes(k)))
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
for (const key of ["properties", "$defs", "definitions"]) {
|
|
94
|
+
if (s[key] && typeof s[key] === "object" && !Object.values(s[key]).every(isStrictCompatible))
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
for (const key of ["items", "anyOf", "oneOf", "allOf"]) {
|
|
98
|
+
if (s[key] !== undefined && !isStrictCompatible(s[key]))
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
export function toTools(tools) {
|
|
104
|
+
return tools.map((t) => ({
|
|
105
|
+
type: "function",
|
|
106
|
+
name: t.name,
|
|
107
|
+
description: t.description,
|
|
108
|
+
parameters: t.input_schema,
|
|
109
|
+
// Sent either way, so the API's own default never decides.
|
|
110
|
+
strict: Boolean(t.strict) && isStrictCompatible(t.input_schema),
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
/** A call's arguments as an object, or `null` when they aren't a JSON object. */
|
|
114
|
+
function parseArguments(text) {
|
|
115
|
+
try {
|
|
116
|
+
const value = JSON.parse(String(text));
|
|
117
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export function fromOutput(response) {
|
|
124
|
+
// The SDK's stream resolves a failed response rather than throwing, and
|
|
125
|
+
// returning it would end the turn as if the model had nothing to say.
|
|
126
|
+
if (response?.status === "failed" || response?.status === "cancelled") {
|
|
127
|
+
const why = response.error?.message ? `: ${response.error.message}` : "";
|
|
128
|
+
throw new Error(`[haikit] openai: the response ${response.status}${why}`);
|
|
129
|
+
}
|
|
130
|
+
// The runtime never runs the calls of an incomplete reply, so a call cut
|
|
131
|
+
// off part way can stand with `{}`. In a complete one it would be run.
|
|
132
|
+
const incomplete = response?.status === "incomplete";
|
|
133
|
+
const content = [];
|
|
134
|
+
for (const item of response?.output ?? []) {
|
|
135
|
+
if (item?.type === "message") {
|
|
136
|
+
for (const part of item.content ?? []) {
|
|
137
|
+
if (part?.type === "output_text")
|
|
138
|
+
content.push({ type: "text", text: part.text });
|
|
139
|
+
else if (part?.type === "refusal")
|
|
140
|
+
content.push({ type: "text", text: part.refusal });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
else if (item?.type === "function_call") {
|
|
144
|
+
const input = parseArguments(item.arguments);
|
|
145
|
+
if (input === null && !incomplete) {
|
|
146
|
+
throw new Error(`[haikit] openai: the model called ${item.name} with arguments that aren't a JSON object`);
|
|
147
|
+
}
|
|
148
|
+
content.push({ type: "tool_use", id: item.call_id, name: item.name, input: input ?? {} });
|
|
149
|
+
}
|
|
150
|
+
else if (item?.type === "reasoning" && item.encrypted_content) {
|
|
151
|
+
// With `store: false`, OpenAI keeps nothing, so a reasoning item can only
|
|
152
|
+
// be sent back with its encrypted content. One without it is dropped.
|
|
153
|
+
content.push({ type: REASONING_BLOCK, id: item.id, summary: item.summary ?? [], encrypted_content: item.encrypted_content });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { content, stop_reason: stopReason(response, content) };
|
|
157
|
+
}
|
|
158
|
+
function stopReason(response, content) {
|
|
159
|
+
if (response?.status === "incomplete") {
|
|
160
|
+
const reason = response.incomplete_details?.reason;
|
|
161
|
+
if (reason === "max_output_tokens")
|
|
162
|
+
return "max_tokens";
|
|
163
|
+
if (reason === "content_filter")
|
|
164
|
+
return "refusal";
|
|
165
|
+
return reason ?? "incomplete";
|
|
166
|
+
}
|
|
167
|
+
return content.some((b) => b.type === "tool_use") ? "tool_use" : "end_turn";
|
|
168
|
+
}
|
|
169
|
+
//# sourceMappingURL=mapping.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mapping.js","sourceRoot":"","sources":["../src/mapping.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haikit/openai",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.21.0",
|
|
4
|
+
"description": "OpenAI model adapter for haikit. Imports only @haikit/core, never the server runtime.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"haikit",
|
|
7
|
+
"openai",
|
|
8
|
+
"gpt",
|
|
9
|
+
"llm",
|
|
10
|
+
"agent",
|
|
11
|
+
"agentic-ui",
|
|
12
|
+
"tool-use",
|
|
13
|
+
"ai"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"src"
|
|
28
|
+
],
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=22"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"prepack": "npm --prefix ../.. run build"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@haikit/core": "0.21.0",
|
|
38
|
+
"openai": "^7.28.0"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"homepage": "https://github.com/wfoxd/haikit/tree/main/packages/openai#readme",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/wfoxd/haikit/issues"
|
|
47
|
+
},
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/wfoxd/haikit.git",
|
|
51
|
+
"directory": "packages/openai"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ModelAdapter, ModelRequest, ModelResponse } from "@haikit/core";
|
|
2
|
+
import { fromOutput, toInput, toTools } from "./mapping.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* OpenAI model adapter, over the Responses API.
|
|
6
|
+
*
|
|
7
|
+
* Note it imports only hai-core, never hai-server. Adapters implement an
|
|
8
|
+
* interface the contract layer declares; that is what keeps the runtime
|
|
9
|
+
* swappable, and what lets an app ship its own adapter without forking the
|
|
10
|
+
* framework.
|
|
11
|
+
*
|
|
12
|
+
* It sends the whole history on every call with `store: false`, rather than
|
|
13
|
+
* chaining `previous_response_id`: the conversation lives in the app's store,
|
|
14
|
+
* which HaiKIT itself writes into between calls, and one conversation's calls
|
|
15
|
+
* can come from different servers.
|
|
16
|
+
*/
|
|
17
|
+
export interface OpenAIOptions {
|
|
18
|
+
/** Any OpenAI model ID. Defaults to `gpt-6-luna`. */
|
|
19
|
+
model?: string;
|
|
20
|
+
maxTokens?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Defaults to `medium`. `null` sends no reasoning settings at all, for a
|
|
23
|
+
* model that doesn't reason and so refuses them.
|
|
24
|
+
*/
|
|
25
|
+
effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | null;
|
|
26
|
+
apiKey?: string;
|
|
27
|
+
baseURL?: string;
|
|
28
|
+
/** Sent as `prompt_cache_key`, to route calls that share a prefix together. */
|
|
29
|
+
promptCacheKey?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The adapter, given a way to make the client: tests pass a fake one. */
|
|
33
|
+
export function createAdapter(options: OpenAIOptions, makeClient: () => Promise<any>): ModelAdapter {
|
|
34
|
+
const model = options.model ?? "gpt-6-luna";
|
|
35
|
+
const effort = options.effort === undefined ? "medium" : options.effort;
|
|
36
|
+
let client: any = null;
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
id: model,
|
|
40
|
+
|
|
41
|
+
async generate(request: ModelRequest): Promise<ModelResponse> {
|
|
42
|
+
if (!client) client = await makeClient();
|
|
43
|
+
|
|
44
|
+
const stream = client.responses.stream({
|
|
45
|
+
model,
|
|
46
|
+
// Stable prefix: OpenAI caches it on its own, so nothing volatile may
|
|
47
|
+
// go in here.
|
|
48
|
+
instructions: request.system,
|
|
49
|
+
tools: toTools(request.tools),
|
|
50
|
+
input: toInput(request.messages),
|
|
51
|
+
max_output_tokens: options.maxTokens ?? 16000,
|
|
52
|
+
store: false,
|
|
53
|
+
// Encrypted reasoning comes back only when asked for, and a model that
|
|
54
|
+
// doesn't reason refuses both.
|
|
55
|
+
...(effort === null ? {} : { reasoning: { effort }, include: ["reasoning.encrypted_content"] }),
|
|
56
|
+
...(options.promptCacheKey ? { prompt_cache_key: options.promptCacheKey } : {}),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
for await (const event of stream) {
|
|
60
|
+
if (event.type === "response.output_text.delta" || event.type === "response.refusal.delta") {
|
|
61
|
+
request.onTextDelta(event.delta);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return fromOutput(await stream.finalResponse());
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ModelAdapter } from "@haikit/core";
|
|
2
|
+
import { createAdapter, type OpenAIOptions } from "./adapter.js";
|
|
3
|
+
|
|
4
|
+
export type { OpenAIOptions };
|
|
5
|
+
|
|
6
|
+
export function openai(options: OpenAIOptions = {}): ModelAdapter {
|
|
7
|
+
return createAdapter(options, async () => {
|
|
8
|
+
const { default: OpenAI } = await import("openai");
|
|
9
|
+
return new OpenAI({
|
|
10
|
+
...(options.apiKey ? { apiKey: options.apiKey } : {}),
|
|
11
|
+
...(options.baseURL ? { baseURL: options.baseURL } : {}),
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
}
|
package/src/mapping.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { Message, ModelRequest, ModelResponse } from "@haikit/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The history is kept in the Anthropic Messages shape, so this adapter's work
|
|
5
|
+
* is translation: the history into Responses API input items on the way out,
|
|
6
|
+
* and the response's output items back into content blocks on the way in.
|
|
7
|
+
* Everything here is pure, so it can be tested without the network.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** A reasoning item kept in the history so the next call can send it back. */
|
|
11
|
+
export const REASONING_BLOCK = "openai_reasoning";
|
|
12
|
+
|
|
13
|
+
function refuse(what: string): never {
|
|
14
|
+
throw new Error(`[haikit] openai: cannot send ${what} to OpenAI`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A `tool_result`'s content, as the single string `function_call_output` takes. */
|
|
18
|
+
function resultText(content: unknown): string {
|
|
19
|
+
if (content === undefined || content === null) return "";
|
|
20
|
+
if (typeof content === "string") return content;
|
|
21
|
+
if (Array.isArray(content)) {
|
|
22
|
+
return content
|
|
23
|
+
.map((b: any) => (b?.type === "text" && typeof b.text === "string" ? b.text : refuse(`a "${b?.type}" block in a tool_result`)))
|
|
24
|
+
.join("\n");
|
|
25
|
+
}
|
|
26
|
+
return refuse("a tool_result whose content is not a string or an array");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function toInput(messages: readonly Message[]): any[] {
|
|
30
|
+
const input: any[] = [];
|
|
31
|
+
|
|
32
|
+
for (const message of messages) {
|
|
33
|
+
const { role, content } = message;
|
|
34
|
+
|
|
35
|
+
if (typeof content === "string") {
|
|
36
|
+
input.push({ role, content });
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (!Array.isArray(content)) refuse(`a ${role} message whose content is not a string or an array`);
|
|
40
|
+
|
|
41
|
+
if (role === "user") {
|
|
42
|
+
// A function call's output must follow the call, so the results go
|
|
43
|
+
// first: the runtime puts notices' text after them in the same message.
|
|
44
|
+
const texts: any[] = [];
|
|
45
|
+
for (const block of content) {
|
|
46
|
+
if (block?.type === "tool_result") {
|
|
47
|
+
input.push({ type: "function_call_output", call_id: block.tool_use_id, output: resultText(block.content) });
|
|
48
|
+
} else if (block?.type === "text") {
|
|
49
|
+
texts.push({ type: "input_text", text: block.text });
|
|
50
|
+
} else {
|
|
51
|
+
refuse(`a "${block?.type}" block in a user message`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (texts.length > 0) input.push({ role: "user", content: texts });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const block of content) {
|
|
59
|
+
if (block?.type === "text") {
|
|
60
|
+
if (block.text) input.push({ role: "assistant", content: block.text });
|
|
61
|
+
} else if (block?.type === "tool_use") {
|
|
62
|
+
input.push({ type: "function_call", call_id: block.id, name: block.name, arguments: JSON.stringify(block.input ?? {}) });
|
|
63
|
+
} else if (block?.type === REASONING_BLOCK) {
|
|
64
|
+
input.push({ type: "reasoning", id: block.id, summary: block.summary ?? [], encrypted_content: block.encrypted_content });
|
|
65
|
+
} else {
|
|
66
|
+
refuse(`a "${block?.type}" block in an assistant message`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return input;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Whether OpenAI's strict mode accepts this schema: every object lists all of
|
|
76
|
+
* its properties in `required` and sets `additionalProperties: false`, all the
|
|
77
|
+
* way down. Sending `strict: true` on a schema that doesn't would fail the
|
|
78
|
+
* whole turn, so such a tool goes without strict mode instead.
|
|
79
|
+
*/
|
|
80
|
+
export function isStrictCompatible(schema: unknown): boolean {
|
|
81
|
+
if (schema === null || typeof schema !== "object") return true;
|
|
82
|
+
if (Array.isArray(schema)) return schema.every(isStrictCompatible);
|
|
83
|
+
const s = schema as Record<string, any>;
|
|
84
|
+
|
|
85
|
+
const isObject = s.type === "object" || (Array.isArray(s.type) && s.type.includes("object")) || "properties" in s;
|
|
86
|
+
if (isObject) {
|
|
87
|
+
if (s.additionalProperties !== false) return false;
|
|
88
|
+
const keys = Object.keys(s.properties ?? {});
|
|
89
|
+
const required: unknown[] = Array.isArray(s.required) ? s.required : [];
|
|
90
|
+
if (!keys.every((k) => required.includes(k))) return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const key of ["properties", "$defs", "definitions"]) {
|
|
94
|
+
if (s[key] && typeof s[key] === "object" && !Object.values(s[key]).every(isStrictCompatible)) return false;
|
|
95
|
+
}
|
|
96
|
+
for (const key of ["items", "anyOf", "oneOf", "allOf"]) {
|
|
97
|
+
if (s[key] !== undefined && !isStrictCompatible(s[key])) return false;
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function toTools(tools: ModelRequest["tools"]): any[] {
|
|
103
|
+
return tools.map((t) => ({
|
|
104
|
+
type: "function",
|
|
105
|
+
name: t.name,
|
|
106
|
+
description: t.description,
|
|
107
|
+
parameters: t.input_schema,
|
|
108
|
+
// Sent either way, so the API's own default never decides.
|
|
109
|
+
strict: Boolean(t.strict) && isStrictCompatible(t.input_schema),
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A call's arguments as an object, or `null` when they aren't a JSON object. */
|
|
114
|
+
function parseArguments(text: unknown): Record<string, unknown> | null {
|
|
115
|
+
try {
|
|
116
|
+
const value = JSON.parse(String(text));
|
|
117
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function fromOutput(response: any): ModelResponse {
|
|
124
|
+
// The SDK's stream resolves a failed response rather than throwing, and
|
|
125
|
+
// returning it would end the turn as if the model had nothing to say.
|
|
126
|
+
if (response?.status === "failed" || response?.status === "cancelled") {
|
|
127
|
+
const why = response.error?.message ? `: ${response.error.message}` : "";
|
|
128
|
+
throw new Error(`[haikit] openai: the response ${response.status}${why}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The runtime never runs the calls of an incomplete reply, so a call cut
|
|
132
|
+
// off part way can stand with `{}`. In a complete one it would be run.
|
|
133
|
+
const incomplete = response?.status === "incomplete";
|
|
134
|
+
const content: any[] = [];
|
|
135
|
+
|
|
136
|
+
for (const item of response?.output ?? []) {
|
|
137
|
+
if (item?.type === "message") {
|
|
138
|
+
for (const part of item.content ?? []) {
|
|
139
|
+
if (part?.type === "output_text") content.push({ type: "text", text: part.text });
|
|
140
|
+
else if (part?.type === "refusal") content.push({ type: "text", text: part.refusal });
|
|
141
|
+
}
|
|
142
|
+
} else if (item?.type === "function_call") {
|
|
143
|
+
const input = parseArguments(item.arguments);
|
|
144
|
+
if (input === null && !incomplete) {
|
|
145
|
+
throw new Error(`[haikit] openai: the model called ${item.name} with arguments that aren't a JSON object`);
|
|
146
|
+
}
|
|
147
|
+
content.push({ type: "tool_use", id: item.call_id, name: item.name, input: input ?? {} });
|
|
148
|
+
} else if (item?.type === "reasoning" && item.encrypted_content) {
|
|
149
|
+
// With `store: false`, OpenAI keeps nothing, so a reasoning item can only
|
|
150
|
+
// be sent back with its encrypted content. One without it is dropped.
|
|
151
|
+
content.push({ type: REASONING_BLOCK, id: item.id, summary: item.summary ?? [], encrypted_content: item.encrypted_content });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { content, stop_reason: stopReason(response, content) };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function stopReason(response: any, content: any[]): string {
|
|
159
|
+
if (response?.status === "incomplete") {
|
|
160
|
+
const reason = response.incomplete_details?.reason;
|
|
161
|
+
if (reason === "max_output_tokens") return "max_tokens";
|
|
162
|
+
if (reason === "content_filter") return "refusal";
|
|
163
|
+
return reason ?? "incomplete";
|
|
164
|
+
}
|
|
165
|
+
return content.some((b) => b.type === "tool_use") ? "tool_use" : "end_turn";
|
|
166
|
+
}
|