purra-openai 0.5.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 +52 -0
- package/README.zh-CN.md +49 -0
- package/dist/chat.d.ts +19 -0
- package/dist/chat.js +151 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +156 -0
- package/package.json +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lybrands
|
|
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,52 @@
|
|
|
1
|
+
# purra-openai · TypeScript
|
|
2
|
+
|
|
3
|
+
English | [简体中文](README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
OpenAI model gateways for PurrA using the official SDK. Supports text, function
|
|
6
|
+
tools, streaming, cancellation, and token usage. Requires Node.js 22+ (ESM).
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
Follow [source installation](../../README.md#source-installation), selecting
|
|
11
|
+
`integrations/openai/typescript`.
|
|
12
|
+
|
|
13
|
+
## Configure
|
|
14
|
+
|
|
15
|
+
Supply the selected model ID and its verified capability snapshot:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { Agent, type ModelCapabilitySnapshot } from "purra";
|
|
19
|
+
import { OpenAIResponsesGateway } from "purra-openai";
|
|
20
|
+
|
|
21
|
+
function createAgent(model: string, capabilities: ModelCapabilitySnapshot) {
|
|
22
|
+
const gateway = new OpenAIResponsesGateway({
|
|
23
|
+
model,
|
|
24
|
+
capabilities,
|
|
25
|
+
timeoutMs: 60_000,
|
|
26
|
+
});
|
|
27
|
+
return new Agent({ model: gateway });
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Pass an SDK client through `client` to configure credentials or transport.
|
|
32
|
+
Without one, the gateway creates a client using the SDK's environment configuration.
|
|
33
|
+
|
|
34
|
+
| Gateway | API | Model-specific options |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| `OpenAIResponsesGateway` | Responses | `reasoning` |
|
|
37
|
+
| `OpenAIChatCompletionsGateway` | Chat Completions | `reasoningEffort`, `temperature`, `topP` |
|
|
38
|
+
|
|
39
|
+
Both gateways accept `model`, `capabilities`, `client`, and `timeoutMs`.
|
|
40
|
+
To use Chat Completions, import `OpenAIChatCompletionsGateway` and construct it
|
|
41
|
+
with those options. Select parameters supported by the chosen model.
|
|
42
|
+
|
|
43
|
+
## Behavior
|
|
44
|
+
|
|
45
|
+
Core resolves the total-generation limit; the gateway applies it to the selected API.
|
|
46
|
+
SDK retries are disabled so Core controls retry and budget accounting.
|
|
47
|
+
Server-side response storage is disabled.
|
|
48
|
+
|
|
49
|
+
Responses reasoning continuation is kept in private message data; preserve it
|
|
50
|
+
with the conversation when resuming. Tool choice is automatic when tools are
|
|
51
|
+
available. Images, audio, and hosted tools are not supported.
|
|
52
|
+
Other vendors require application adapters.
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# purra-openai · TypeScript
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 简体中文
|
|
4
|
+
|
|
5
|
+
使用官方 SDK 将 OpenAI 接入 PurrA,支持文本、函数工具、流式输出、取消和 Token 用量。
|
|
6
|
+
要求 Node.js 22+(ESM)。
|
|
7
|
+
|
|
8
|
+
## 安装
|
|
9
|
+
|
|
10
|
+
按照[源码安装说明](../../README.zh-CN.md#从源码安装)操作,选择
|
|
11
|
+
`integrations/openai/typescript`。
|
|
12
|
+
|
|
13
|
+
## 配置
|
|
14
|
+
|
|
15
|
+
传入所选模型 ID 和已核实的模型能力快照:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { Agent, type ModelCapabilitySnapshot } from "purra";
|
|
19
|
+
import { OpenAIResponsesGateway } from "purra-openai";
|
|
20
|
+
|
|
21
|
+
function createAgent(model: string, capabilities: ModelCapabilitySnapshot) {
|
|
22
|
+
const gateway = new OpenAIResponsesGateway({
|
|
23
|
+
model,
|
|
24
|
+
capabilities,
|
|
25
|
+
timeoutMs: 60_000,
|
|
26
|
+
});
|
|
27
|
+
return new Agent({ model: gateway });
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
通过 `client` 传入 SDK 客户端,可自定义凭据与传输配置。
|
|
32
|
+
省略时,网关使用 SDK 的环境配置创建客户端。
|
|
33
|
+
|
|
34
|
+
| 网关 | API | 模型参数 |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| `OpenAIResponsesGateway` | Responses | `reasoning` |
|
|
37
|
+
| `OpenAIChatCompletionsGateway` | Chat Completions | `reasoningEffort`、`temperature`、`topP` |
|
|
38
|
+
|
|
39
|
+
两个网关均接受 `model`、`capabilities`、`client` 和 `timeoutMs`。
|
|
40
|
+
使用 Chat Completions 时,导入 `OpenAIChatCompletionsGateway` 并使用这些参数构造。
|
|
41
|
+
模型参数应按所选模型支持的能力设置。
|
|
42
|
+
|
|
43
|
+
## 行为
|
|
44
|
+
|
|
45
|
+
Core 计算输出上限,网关将其应用到对应 API。SDK 自动重试已关闭,重试和预算核算由 Core 控制。
|
|
46
|
+
服务端响应存储已关闭。
|
|
47
|
+
|
|
48
|
+
Responses 的推理续接信息保存在私有消息数据中,恢复时应与对话一同保留。
|
|
49
|
+
有工具可用时采用自动工具选择。不支持图片、音频及服务端托管工具,其他厂商由应用提供适配。
|
package/dist/chat.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import type { ChatCompletionCreateParamsNonStreaming } from "openai/resources/chat/completions";
|
|
3
|
+
import type { ModelCapabilitySnapshot, ModelGateway, ModelRequest, ModelStream, ModelTurn } from "purra";
|
|
4
|
+
export interface OpenAIChatCompletionsOptions {
|
|
5
|
+
readonly client?: OpenAI;
|
|
6
|
+
readonly model: string;
|
|
7
|
+
readonly capabilities: ModelCapabilitySnapshot;
|
|
8
|
+
readonly timeoutMs?: number;
|
|
9
|
+
readonly reasoningEffort?: ChatCompletionCreateParamsNonStreaming["reasoning_effort"];
|
|
10
|
+
readonly temperature?: number;
|
|
11
|
+
readonly topP?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare class OpenAIChatCompletionsGateway implements ModelGateway {
|
|
14
|
+
#private;
|
|
15
|
+
readonly capabilities: ModelCapabilitySnapshot;
|
|
16
|
+
constructor(options: OpenAIChatCompletionsOptions);
|
|
17
|
+
invoke(request: ModelRequest, signal?: AbortSignal): Promise<ModelTurn>;
|
|
18
|
+
stream(request: ModelRequest, signal?: AbortSignal): Promise<ModelStream>;
|
|
19
|
+
}
|
package/dist/chat.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import { AgentCanceledError, AgentError } from "purra";
|
|
3
|
+
function text(value) {
|
|
4
|
+
if (value === null)
|
|
5
|
+
return "";
|
|
6
|
+
if (typeof value !== "string")
|
|
7
|
+
throw new TypeError("OpenAI Chat Completions accepts text content only");
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
function messages(input) {
|
|
11
|
+
return input.map(m => {
|
|
12
|
+
if (m.toolCalls?.length && m.role !== "assistant")
|
|
13
|
+
throw new TypeError("Only assistant messages may carry tool calls");
|
|
14
|
+
const content = text(m.content);
|
|
15
|
+
if (m.role === "tool") {
|
|
16
|
+
if (!m.toolCallId)
|
|
17
|
+
throw new TypeError("Tool output requires a call id");
|
|
18
|
+
return { role: "tool", content, tool_call_id: m.toolCallId };
|
|
19
|
+
}
|
|
20
|
+
if (m.role === "assistant")
|
|
21
|
+
return { role: "assistant", content,
|
|
22
|
+
...(m.toolCalls?.length ? { tool_calls: m.toolCalls.map(c => ({ id: c.id, type: "function",
|
|
23
|
+
function: { name: c.name, arguments: JSON.stringify(c.arguments) } })) } : {}),
|
|
24
|
+
};
|
|
25
|
+
return { role: m.role, content };
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function usage(u) {
|
|
29
|
+
if (u?.prompt_tokens == null || u.completion_tokens == null)
|
|
30
|
+
return undefined;
|
|
31
|
+
const reasoningTokens = u.completion_tokens_details?.reasoning_tokens;
|
|
32
|
+
return { inputTokens: u.prompt_tokens, generationTokens: u.completion_tokens, totalTokens: u.total_tokens,
|
|
33
|
+
cachedInputTokens: u.prompt_tokens_details?.cached_tokens ?? 0,
|
|
34
|
+
...(reasoningTokens === undefined ? {} : { reasoningTokens }) };
|
|
35
|
+
}
|
|
36
|
+
function finish(reason) {
|
|
37
|
+
if (reason === "stop" || reason === "tool_calls")
|
|
38
|
+
return reason;
|
|
39
|
+
if (reason === "length")
|
|
40
|
+
return "length";
|
|
41
|
+
if (reason === "content_filter")
|
|
42
|
+
return "filtered";
|
|
43
|
+
throw new AgentError("invalid_model_response", "Unknown OpenAI finish reason");
|
|
44
|
+
}
|
|
45
|
+
function failed(error, signal) {
|
|
46
|
+
if (signal?.aborted)
|
|
47
|
+
throw new AgentCanceledError();
|
|
48
|
+
if (error instanceof OpenAI.APIError)
|
|
49
|
+
throw new AgentError(error.status === undefined ? "openai_transport_error" : `openai_http_${error.status}`, "OpenAI request failed");
|
|
50
|
+
if (error instanceof AgentError)
|
|
51
|
+
throw error;
|
|
52
|
+
throw new AgentError("invalid_model_response", "Invalid OpenAI Chat Completions response");
|
|
53
|
+
}
|
|
54
|
+
export class OpenAIChatCompletionsGateway {
|
|
55
|
+
capabilities;
|
|
56
|
+
#client;
|
|
57
|
+
#options;
|
|
58
|
+
constructor(options) {
|
|
59
|
+
if (!options.model.trim())
|
|
60
|
+
throw new TypeError("Model name is required");
|
|
61
|
+
if (!Number.isFinite(options.timeoutMs ?? 60000) || (options.timeoutMs ?? 60000) <= 0)
|
|
62
|
+
throw new TypeError("Timeout must be positive and finite");
|
|
63
|
+
this.capabilities = options.capabilities;
|
|
64
|
+
this.#options = { ...options };
|
|
65
|
+
this.#client = (options.client ?? new OpenAI()).withOptions({ maxRetries: 0, timeout: options.timeoutMs ?? 60000 });
|
|
66
|
+
}
|
|
67
|
+
#request(request) {
|
|
68
|
+
if (!request.outputBudget)
|
|
69
|
+
throw new TypeError("OpenAI gateway requires a resolved generation budget");
|
|
70
|
+
const o = this.#options;
|
|
71
|
+
return { model: o.model, messages: messages(request.messages), store: false, max_completion_tokens: request.outputBudget.maxGenerationTokens,
|
|
72
|
+
...(o.reasoningEffort === undefined ? {} : { reasoning_effort: o.reasoningEffort }),
|
|
73
|
+
...(o.temperature === undefined ? {} : { temperature: o.temperature }),
|
|
74
|
+
...(o.topP === undefined ? {} : { top_p: o.topP }),
|
|
75
|
+
...(request.tools.length ? { tools: request.tools.map(t => ({ type: "function",
|
|
76
|
+
function: { name: t.name, description: t.description, parameters: JSON.parse(JSON.stringify(t.inputSchema)), strict: false } })), tool_choice: "auto" } : {}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
async invoke(request, signal) {
|
|
80
|
+
const params = this.#request(request);
|
|
81
|
+
if (signal?.aborted)
|
|
82
|
+
throw new AgentCanceledError();
|
|
83
|
+
try {
|
|
84
|
+
const response = await this.#client.chat.completions.create(params, { signal });
|
|
85
|
+
const c = response.choices[0];
|
|
86
|
+
if (response.choices.length !== 1 || c?.index !== 0 || c.message.role !== "assistant")
|
|
87
|
+
throw new Error("Invalid completion");
|
|
88
|
+
const tokenUsage = usage(response.usage);
|
|
89
|
+
return { message: { role: "assistant", content: text(c.message.content), toolCalls: (c.message.tool_calls ?? []).map(call => {
|
|
90
|
+
if (call.type !== "function")
|
|
91
|
+
throw new Error("Unsupported tool type");
|
|
92
|
+
return { id: call.id, name: call.function.name, arguments: JSON.parse(call.function.arguments) };
|
|
93
|
+
}) }, finishReason: c.message.refusal ? "filtered" : finish(c.finish_reason),
|
|
94
|
+
appliedGenerationLimit: request.outputBudget.maxGenerationTokens, ...(tokenUsage === undefined ? {} : { usage: tokenUsage }) };
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
return failed(error, signal);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async stream(request, signal) {
|
|
101
|
+
const params = this.#request(request);
|
|
102
|
+
const client = this.#client;
|
|
103
|
+
let consumed = false;
|
|
104
|
+
async function* chunks() {
|
|
105
|
+
if (consumed)
|
|
106
|
+
throw new AgentError("invalid_model_response", "OpenAI stream has already been consumed");
|
|
107
|
+
consumed = true;
|
|
108
|
+
if (signal?.aborted)
|
|
109
|
+
throw new AgentCanceledError();
|
|
110
|
+
let stream;
|
|
111
|
+
let terminal;
|
|
112
|
+
let tokenUsage;
|
|
113
|
+
let refused = false;
|
|
114
|
+
try {
|
|
115
|
+
stream = await client.chat.completions.create({ ...params, stream: true, stream_options: { include_usage: true } }, { signal });
|
|
116
|
+
for await (const chunk of stream) {
|
|
117
|
+
if (chunk.usage != null)
|
|
118
|
+
tokenUsage = usage(chunk.usage);
|
|
119
|
+
if (!chunk.choices.length) {
|
|
120
|
+
yield { type: "activity", kind: "transport" };
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const c = chunk.choices[0];
|
|
124
|
+
if (chunk.choices.length !== 1 || c?.index !== 0 || terminal !== undefined)
|
|
125
|
+
throw new Error("Invalid completion sequence");
|
|
126
|
+
refused ||= Boolean(c.delta.refusal);
|
|
127
|
+
const content = text(c.delta.content ?? null);
|
|
128
|
+
const calls = (c.delta.tool_calls ?? []).map(call => ({ index: call.index,
|
|
129
|
+
...(call.id === undefined ? {} : { id: call.id }), ...(call.type === undefined ? {} : { type: call.type }),
|
|
130
|
+
...(call.function?.name === undefined ? {} : { name: call.function.name }), argumentsFragment: call.function?.arguments ?? "" }));
|
|
131
|
+
if (content || calls.length)
|
|
132
|
+
yield { contentDelta: content, toolCallDeltas: calls };
|
|
133
|
+
else
|
|
134
|
+
yield { type: "activity", kind: "transport" };
|
|
135
|
+
if (c.finish_reason !== null)
|
|
136
|
+
terminal = finish(c.finish_reason);
|
|
137
|
+
}
|
|
138
|
+
if (terminal === undefined)
|
|
139
|
+
throw new AgentError("upstream_stream_interrupted", "OpenAI stream ended without a finish reason");
|
|
140
|
+
yield { finishReason: refused ? "filtered" : terminal, ...(tokenUsage === undefined ? {} : { usage: tokenUsage }) };
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
failed(error, signal);
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
stream?.controller.abort();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { appliedGenerationLimit: request.outputBudget.maxGenerationTokens, activitySupport: "transport", [Symbol.asyncIterator]: chunks };
|
|
150
|
+
}
|
|
151
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
export { OpenAIChatCompletionsGateway } from "./chat.js";
|
|
3
|
+
export type { OpenAIChatCompletionsOptions } from "./chat.js";
|
|
4
|
+
import type { ResponseCreateParamsNonStreaming } from "openai/resources/responses/responses";
|
|
5
|
+
import type { ModelCapabilitySnapshot, ModelGateway, ModelRequest, ModelStream, ModelTurn } from "purra";
|
|
6
|
+
export interface OpenAIResponsesOptions {
|
|
7
|
+
readonly client?: OpenAI;
|
|
8
|
+
readonly model: string;
|
|
9
|
+
/** Host-selected model facts; the adapter never guesses model token limits. */
|
|
10
|
+
readonly capabilities: ModelCapabilitySnapshot;
|
|
11
|
+
readonly timeoutMs?: number;
|
|
12
|
+
readonly reasoning?: ResponseCreateParamsNonStreaming["reasoning"];
|
|
13
|
+
}
|
|
14
|
+
/** Official SDK transport with one HTTP attempt per managed invocation. */
|
|
15
|
+
export declare class OpenAIResponsesGateway implements ModelGateway {
|
|
16
|
+
#private;
|
|
17
|
+
readonly capabilities: ModelCapabilitySnapshot;
|
|
18
|
+
constructor(options: OpenAIResponsesOptions);
|
|
19
|
+
invoke(request: ModelRequest, signal?: AbortSignal): Promise<ModelTurn>;
|
|
20
|
+
stream(request: ModelRequest, signal?: AbortSignal): Promise<ModelStream>;
|
|
21
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
export { OpenAIChatCompletionsGateway } from "./chat.js";
|
|
3
|
+
import { AgentCanceledError, AgentError } from "purra";
|
|
4
|
+
const replayKey = "openai_reasoning_items";
|
|
5
|
+
function text(value) {
|
|
6
|
+
if (value === null)
|
|
7
|
+
return "";
|
|
8
|
+
if (typeof value !== "string")
|
|
9
|
+
throw new TypeError("OpenAI adapter currently accepts text messages only");
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
function input(messages) {
|
|
13
|
+
const rows = [];
|
|
14
|
+
for (const m of messages) {
|
|
15
|
+
if (m.toolCalls?.length && m.role !== "assistant")
|
|
16
|
+
throw new TypeError("Only assistant messages may carry function calls");
|
|
17
|
+
if (m.role === "assistant") {
|
|
18
|
+
const replay = m.providerData?.[replayKey] ?? [];
|
|
19
|
+
if (!Array.isArray(replay) || replay.some(r => !r || typeof r !== "object" || Array.isArray(r) || r.type !== "reasoning"))
|
|
20
|
+
throw new TypeError("Invalid OpenAI reasoning continuation");
|
|
21
|
+
rows.push(...JSON.parse(JSON.stringify(replay)));
|
|
22
|
+
}
|
|
23
|
+
const content = text(m.content);
|
|
24
|
+
if (m.role === "tool") {
|
|
25
|
+
if (!m.toolCallId)
|
|
26
|
+
throw new TypeError("Tool output requires a call id");
|
|
27
|
+
rows.push({ type: "function_call_output", call_id: m.toolCallId, output: content });
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (content || !m.toolCalls?.length)
|
|
31
|
+
rows.push({ role: m.role, content });
|
|
32
|
+
for (const call of m.toolCalls ?? [])
|
|
33
|
+
rows.push({ type: "function_call", call_id: call.id, name: call.name, arguments: JSON.stringify(call.arguments) });
|
|
34
|
+
}
|
|
35
|
+
return rows;
|
|
36
|
+
}
|
|
37
|
+
function usage(response) {
|
|
38
|
+
const u = response.usage;
|
|
39
|
+
return u == null ? undefined : { inputTokens: u.input_tokens, generationTokens: u.output_tokens, totalTokens: u.total_tokens,
|
|
40
|
+
cachedInputTokens: u.input_tokens_details.cached_tokens, reasoningTokens: u.output_tokens_details.reasoning_tokens };
|
|
41
|
+
}
|
|
42
|
+
function finish(response) {
|
|
43
|
+
if (response.status === "incomplete")
|
|
44
|
+
return response.incomplete_details?.reason === "max_output_tokens" ? "length" : response.incomplete_details?.reason === "content_filter" ? "filtered" : "other";
|
|
45
|
+
if (response.status !== "completed")
|
|
46
|
+
throw new AgentError("openai_response_failed", "OpenAI response failed");
|
|
47
|
+
if (response.output.some(i => i.type === "function_call"))
|
|
48
|
+
return "tool_calls";
|
|
49
|
+
if (response.output.some(i => i.type === "message" && i.content.some(p => p.type === "refusal")))
|
|
50
|
+
return "filtered";
|
|
51
|
+
return "stop";
|
|
52
|
+
}
|
|
53
|
+
function attributes(response) {
|
|
54
|
+
const items = response.output.filter(i => i.type === "reasoning");
|
|
55
|
+
return items.length ? { [replayKey]: JSON.parse(JSON.stringify(items)) } : {};
|
|
56
|
+
}
|
|
57
|
+
function failed(error, signal) {
|
|
58
|
+
if (signal?.aborted)
|
|
59
|
+
throw new AgentCanceledError();
|
|
60
|
+
if (error instanceof OpenAI.APIError)
|
|
61
|
+
throw new AgentError(error.status === undefined ? "openai_transport_error" : `openai_http_${error.status}`, "OpenAI request failed");
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
/** Official SDK transport with one HTTP attempt per managed invocation. */
|
|
65
|
+
export class OpenAIResponsesGateway {
|
|
66
|
+
capabilities;
|
|
67
|
+
#client;
|
|
68
|
+
#model;
|
|
69
|
+
#reasoning;
|
|
70
|
+
constructor(options) {
|
|
71
|
+
if (!options.model.trim())
|
|
72
|
+
throw new TypeError("Model name is required");
|
|
73
|
+
if (!Number.isFinite(options.timeoutMs ?? 60000) || (options.timeoutMs ?? 60000) <= 0)
|
|
74
|
+
throw new TypeError("Timeout must be positive");
|
|
75
|
+
this.capabilities = options.capabilities;
|
|
76
|
+
this.#model = options.model;
|
|
77
|
+
this.#reasoning = options.reasoning;
|
|
78
|
+
this.#client = (options.client ?? new OpenAI()).withOptions({ maxRetries: 0, timeout: options.timeoutMs ?? 60000 });
|
|
79
|
+
}
|
|
80
|
+
#request(request) {
|
|
81
|
+
if (!request.outputBudget)
|
|
82
|
+
throw new TypeError("OpenAI gateway requires a resolved generation budget");
|
|
83
|
+
return { model: this.#model, input: input(request.messages), store: false, include: ["reasoning.encrypted_content"],
|
|
84
|
+
max_output_tokens: request.outputBudget.maxGenerationTokens,
|
|
85
|
+
tools: request.tools.map(t => ({ type: "function", name: t.name, description: t.description,
|
|
86
|
+
parameters: JSON.parse(JSON.stringify(t.inputSchema)), strict: false })),
|
|
87
|
+
tool_choice: request.tools.length ? "auto" : "none",
|
|
88
|
+
...(this.#reasoning === undefined ? {} : { reasoning: this.#reasoning }), };
|
|
89
|
+
}
|
|
90
|
+
async invoke(request, signal) {
|
|
91
|
+
const params = this.#request(request);
|
|
92
|
+
try {
|
|
93
|
+
const response = await this.#client.responses.create(params, { signal });
|
|
94
|
+
const tokenUsage = usage(response);
|
|
95
|
+
return { message: { role: "assistant", content: response.output_text, providerData: attributes(response),
|
|
96
|
+
toolCalls: response.output.filter(i => i.type === "function_call").map(i => ({ id: i.call_id, name: i.name, arguments: JSON.parse(i.arguments) })) },
|
|
97
|
+
finishReason: finish(response), appliedGenerationLimit: request.outputBudget.maxGenerationTokens,
|
|
98
|
+
...(tokenUsage === undefined ? {} : { usage: tokenUsage }), };
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
return failed(error, signal);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async stream(request, signal) {
|
|
105
|
+
const params = this.#request(request);
|
|
106
|
+
const client = this.#client;
|
|
107
|
+
let consumed = false;
|
|
108
|
+
async function* chunks() {
|
|
109
|
+
if (consumed)
|
|
110
|
+
throw new AgentError("invalid_model_response", "OpenAI stream has already been consumed");
|
|
111
|
+
consumed = true;
|
|
112
|
+
let stream;
|
|
113
|
+
let terminal = false;
|
|
114
|
+
try {
|
|
115
|
+
stream = await client.responses.create({ ...params, stream: true }, { signal });
|
|
116
|
+
for await (const event of stream) {
|
|
117
|
+
const item = project(event);
|
|
118
|
+
if (item)
|
|
119
|
+
yield item;
|
|
120
|
+
if (event.type === "response.completed" || event.type === "response.incomplete" || event.type === "response.failed") {
|
|
121
|
+
terminal = true;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (!terminal)
|
|
126
|
+
throw new AgentError("upstream_stream_interrupted", "OpenAI stream ended without a terminal response");
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
failed(error, signal);
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
stream?.controller.abort();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { appliedGenerationLimit: request.outputBudget.maxGenerationTokens, activitySupport: "working", [Symbol.asyncIterator]: chunks };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function project(event) {
|
|
139
|
+
if (event.type === "response.output_text.delta")
|
|
140
|
+
return { contentDelta: event.delta };
|
|
141
|
+
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary_text.delta")
|
|
142
|
+
return { type: "activity", kind: "working" };
|
|
143
|
+
if (event.type === "response.output_item.added" && event.item.type === "function_call")
|
|
144
|
+
return {
|
|
145
|
+
toolCallDeltas: [{ index: event.output_index, id: event.item.call_id, name: event.item.name, type: "function", argumentsFragment: event.item.arguments }],
|
|
146
|
+
};
|
|
147
|
+
if (event.type === "response.function_call_arguments.delta")
|
|
148
|
+
return { toolCallDeltas: [{ index: event.output_index, argumentsFragment: event.delta }] };
|
|
149
|
+
if (event.type === "response.completed" || event.type === "response.incomplete" || event.type === "response.failed") {
|
|
150
|
+
const tokenUsage = usage(event.response);
|
|
151
|
+
return { finishReason: finish(event.response), providerData: attributes(event.response), ...(tokenUsage === undefined ? {} : { usage: tokenUsage }) };
|
|
152
|
+
}
|
|
153
|
+
if (event.type === "error")
|
|
154
|
+
throw new AgentError("openai_stream_error", "OpenAI stream failed");
|
|
155
|
+
return { type: "activity", kind: "transport" };
|
|
156
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "purra-openai",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "Official OpenAI Responses and Chat Completions SDK adapter for PurrA",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md",
|
|
10
|
+
"README.zh-CN.md",
|
|
11
|
+
"LICENSE"
|
|
12
|
+
],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"openai": "7.9.0"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"purra": "0.5.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"purra": "file:../../../typescript",
|
|
27
|
+
"typescript": "7.0.2"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc -p tsconfig.json",
|
|
31
|
+
"test": "node --test test/*.test.mjs",
|
|
32
|
+
"check": "npm run build && npm test",
|
|
33
|
+
"prepack": "npm run build"
|
|
34
|
+
}
|
|
35
|
+
}
|