nanoharness-openai 1.0.0 → 1.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/README.md +19 -2
- package/dist/index.cjs +8 -158
- package/dist/index.d.cts +10 -2
- package/dist/index.d.ts +10 -2
- package/dist/index.js +8 -158
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# nanoharness-openai
|
|
2
2
|
|
|
3
|
-
An OpenAI-compatible chat model adapter for [nanoharness](https://www.npmjs.com/package/nanoharness). One adapter covers OpenAI and every server that speaks the Chat Completions format: Ollama, vLLM, OpenRouter, LM Studio, Groq, Together and more. It is
|
|
3
|
+
An OpenAI-compatible chat model adapter for [nanoharness](https://www.npmjs.com/package/nanoharness). One adapter covers OpenAI and every server that speaks the Chat Completions format: Ollama, vLLM, OpenRouter, LM Studio, Groq, Together and more. It is `openaiCompatible` from the core with the official URL and the global `fetch` filled in, kept separate so the core never uses the network by itself. If you would rather not install it, `openaiCompatible` from `nanoharness` does the same with the `fetch` and `baseUrl` you pass.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npm install nanoharness nanoharness-openai
|
|
@@ -25,13 +25,30 @@ const { finalText } = await run({ adapter, prompt: "Say hi in one word." });
|
|
|
25
25
|
| `apiKey` | Sent as a Bearer token. Optional for local servers. |
|
|
26
26
|
| `baseUrl` | Default `https://api.openai.com/v1`. Must be `https`, or `http` on `localhost`, and must not contain credentials. |
|
|
27
27
|
| `allowInsecureHttp` | Allows plain `http` to a non-localhost host, such as a server on your LAN or in Docker. Off by default. |
|
|
28
|
-
| `maxTokens`, `maxTokensParam` | `maxTokensParam` is `"
|
|
28
|
+
| `maxTokens`, `maxTokensParam` | `maxTokensParam` is `"max_completion_tokens"` by default for OpenAI (reasoning models reject the deprecated `"max_tokens"`), and `"max_tokens"` when you set `baseUrl`, since most compatible servers expect it. Set it to override. |
|
|
29
29
|
| `temperature` | Sent only when set. |
|
|
30
|
+
| `headers` | Extra headers on every request, such as a gateway header. They can replace the default ones. |
|
|
30
31
|
| `maxRetries` | Retries for HTTP 408, 429, 500, 502, 503 and 504, with backoff that honours `retry-after` (seconds or an HTTP date). Default `2`. |
|
|
32
|
+
| `streamUsage` | When streaming, ask for token usage in the last chunk (`stream_options.include_usage`). Turn off for servers that reject it. Default `true`. |
|
|
31
33
|
| `fetch` | A custom `fetch`, for proxies or tests. |
|
|
32
34
|
|
|
33
35
|
Tool calls work through the standard `tools` and `tool_calls` fields, so the model must support tool calling. If a model returns tool arguments that are not valid JSON (or are not a JSON object), the call is **not run**: the harness tells the model its arguments were invalid. A reply cut off by the token limit (`finish_reason: "length"`) is reported as `max_tokens`, and any tool call in it is not run either.
|
|
34
36
|
|
|
37
|
+
## Streaming and errors
|
|
38
|
+
|
|
39
|
+
Both `complete` and `stream` are supported. Pass `streaming: true` to `run`, `stream` or `defineAgent` to receive `text_delta` events as text arrives.
|
|
40
|
+
|
|
41
|
+
A failed request throws an `AdapterError` (from `nanoharness`) with the HTTP `status`, `retryable`, and `retryAfterMs` when the server sent one. The adapter retries on its own (`maxRetries`). To use jittered backoff or share a rate limit across agents instead, turn that off and wrap it, with `withRetry` outside:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { limitConcurrency, withRetry } from "nanoharness";
|
|
45
|
+
import { openai } from "nanoharness-openai";
|
|
46
|
+
|
|
47
|
+
const adapter = withRetry(limitConcurrency(openai({ model: "gpt-4.1", apiKey: process.env.OPENAI_API_KEY, maxRetries: 0 }), 4));
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Needs `nanoharness` 2.1 or later.
|
|
51
|
+
|
|
35
52
|
Requests never follow redirects, so a misconfigured `baseUrl` cannot forward your key or the conversation to another host. If you pass your own `fetch`, make sure it does the same.
|
|
36
53
|
|
|
37
54
|
Zero dependencies. Node 18 or later. MIT.
|
package/dist/index.cjs
CHANGED
|
@@ -23,166 +23,16 @@ __export(src_exports, {
|
|
|
23
23
|
openai: () => openai
|
|
24
24
|
});
|
|
25
25
|
module.exports = __toCommonJS(src_exports);
|
|
26
|
-
var
|
|
27
|
-
var STOP = {
|
|
28
|
-
stop: "end_turn",
|
|
29
|
-
tool_calls: "tool_use",
|
|
30
|
-
function_call: "tool_use",
|
|
31
|
-
length: "max_tokens"
|
|
32
|
-
};
|
|
33
|
-
function retryDelayMs(header, attempt) {
|
|
34
|
-
if (header) {
|
|
35
|
-
const seconds = Number(header);
|
|
36
|
-
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
|
|
37
|
-
const at = Date.parse(header);
|
|
38
|
-
if (!Number.isNaN(at)) return Math.max(0, at - Date.now());
|
|
39
|
-
}
|
|
40
|
-
return 500 * 2 ** attempt;
|
|
41
|
-
}
|
|
42
|
-
var ERROR_BODY_LIMIT = 1e3;
|
|
43
|
-
async function errorBody(res) {
|
|
44
|
-
if (!res.body) return "";
|
|
45
|
-
const reader = res.body.getReader();
|
|
46
|
-
const decoder = new TextDecoder();
|
|
47
|
-
let text = "";
|
|
48
|
-
try {
|
|
49
|
-
while (text.length <= ERROR_BODY_LIMIT) {
|
|
50
|
-
const { done, value } = await reader.read();
|
|
51
|
-
if (done) return text + decoder.decode();
|
|
52
|
-
text += decoder.decode(value, { stream: true });
|
|
53
|
-
}
|
|
54
|
-
} catch {
|
|
55
|
-
return text;
|
|
56
|
-
} finally {
|
|
57
|
-
reader.cancel().catch(() => {
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
return `${text.slice(0, ERROR_BODY_LIMIT)}... [truncated]`;
|
|
61
|
-
}
|
|
62
|
-
async function send(doFetch, url, init) {
|
|
63
|
-
try {
|
|
64
|
-
return await doFetch(url, init);
|
|
65
|
-
} catch (e) {
|
|
66
|
-
const cause = e.cause?.message ?? "";
|
|
67
|
-
if (!init.signal?.aborted && /redirect/i.test(cause)) {
|
|
68
|
-
throw new Error("OpenAI API: the server answered with a redirect, which is never followed so your API key and conversation stay with baseUrl", { cause: e });
|
|
69
|
-
}
|
|
70
|
-
throw e;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
function sleep(ms, signal) {
|
|
74
|
-
return new Promise((resolve, reject) => {
|
|
75
|
-
if (signal?.aborted) return reject(signal.reason);
|
|
76
|
-
const t = setTimeout(() => {
|
|
77
|
-
signal?.removeEventListener("abort", onAbort);
|
|
78
|
-
resolve();
|
|
79
|
-
}, ms);
|
|
80
|
-
const onAbort = () => {
|
|
81
|
-
clearTimeout(t);
|
|
82
|
-
reject(signal.reason);
|
|
83
|
-
};
|
|
84
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
function chatUrl(baseUrl, allowInsecureHttp) {
|
|
88
|
-
let u;
|
|
89
|
-
try {
|
|
90
|
-
u = new URL(baseUrl);
|
|
91
|
-
} catch {
|
|
92
|
-
throw new Error(`openai(): invalid baseUrl "${baseUrl}"`);
|
|
93
|
-
}
|
|
94
|
-
const local = u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]";
|
|
95
|
-
if (u.protocol !== "https:" && !(u.protocol === "http:" && (local || allowInsecureHttp))) {
|
|
96
|
-
throw new Error("openai(): baseUrl must use https (http is only allowed for localhost, or with allowInsecureHttp)");
|
|
97
|
-
}
|
|
98
|
-
if (u.username || u.password) throw new Error("openai(): baseUrl must not contain credentials");
|
|
99
|
-
return `${u.origin}${u.pathname.replace(/\/+$/, "")}/chat/completions`;
|
|
100
|
-
}
|
|
101
|
-
function toOpenAIMessages(system, messages) {
|
|
102
|
-
const out = [];
|
|
103
|
-
if (system) out.push({ role: "system", content: system });
|
|
104
|
-
for (const m of messages) {
|
|
105
|
-
if (typeof m.content === "string") {
|
|
106
|
-
out.push({ role: m.role, content: m.content });
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
const blocks = m.content;
|
|
110
|
-
const text = blocks.filter((b) => b.type === "text").map((b) => b.text).join("");
|
|
111
|
-
if (m.role === "assistant") {
|
|
112
|
-
const toolCalls = blocks.filter((b) => b.type === "tool_use").map((b) => ({ id: b.id, type: "function", function: { name: b.name, arguments: JSON.stringify(b.input) } }));
|
|
113
|
-
if (!text && toolCalls.length === 0) continue;
|
|
114
|
-
out.push({ role: "assistant", content: text || null, ...toolCalls.length ? { tool_calls: toolCalls } : {} });
|
|
115
|
-
} else {
|
|
116
|
-
for (const b of blocks) {
|
|
117
|
-
if (b.type === "tool_result") {
|
|
118
|
-
out.push({ role: "tool", tool_call_id: b.tool_use_id, content: b.is_error ? `Error: ${b.content}` : b.content });
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
if (text) out.push({ role: "user", content: text });
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
return out;
|
|
125
|
-
}
|
|
26
|
+
var import_nanoharness = require("nanoharness");
|
|
126
27
|
function openai(opts) {
|
|
127
28
|
if (!opts.model) throw new Error("openai(): model is required");
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
messages: toOpenAIMessages(req.system, req.messages),
|
|
136
|
-
...opts.maxTokens !== void 0 ? { [opts.maxTokensParam ?? "max_tokens"]: opts.maxTokens } : {},
|
|
137
|
-
...opts.temperature !== void 0 ? { temperature: opts.temperature } : {},
|
|
138
|
-
...req.tools.length ? { tools: req.tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.input_schema } })) } : {}
|
|
139
|
-
});
|
|
140
|
-
let res;
|
|
141
|
-
for (let attempt = 0; ; attempt++) {
|
|
142
|
-
res = await send(doFetch, url, {
|
|
143
|
-
method: "POST",
|
|
144
|
-
signal: req.signal,
|
|
145
|
-
// Never follow a redirect: the request carries your API key and the whole conversation.
|
|
146
|
-
redirect: "error",
|
|
147
|
-
headers: { "content-type": "application/json", ...opts.apiKey ? { authorization: `Bearer ${opts.apiKey}` } : {} },
|
|
148
|
-
body
|
|
149
|
-
});
|
|
150
|
-
if (res.ok || !RETRYABLE.has(res.status) || attempt >= maxRetries) break;
|
|
151
|
-
await res.body?.cancel().catch(() => {
|
|
152
|
-
});
|
|
153
|
-
await sleep(Math.min(retryDelayMs(res.headers.get("retry-after"), attempt), 3e4), req.signal);
|
|
154
|
-
}
|
|
155
|
-
if (!res.ok) throw new Error(`OpenAI API error ${res.status}: ${await errorBody(res)}`);
|
|
156
|
-
let data;
|
|
157
|
-
try {
|
|
158
|
-
data = await res.json();
|
|
159
|
-
} catch {
|
|
160
|
-
throw new Error("OpenAI API returned invalid JSON");
|
|
161
|
-
}
|
|
162
|
-
const choice = data.choices?.[0];
|
|
163
|
-
if (!choice?.message) throw new Error("OpenAI API returned no choices");
|
|
164
|
-
const content = [];
|
|
165
|
-
if (choice.message.content) content.push({ type: "text", text: choice.message.content });
|
|
166
|
-
for (const tc of choice.message.tool_calls ?? []) {
|
|
167
|
-
let input = {};
|
|
168
|
-
let error;
|
|
169
|
-
try {
|
|
170
|
-
const parsed = JSON.parse(tc.function.arguments || "{}");
|
|
171
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) input = parsed;
|
|
172
|
-
else error = "arguments must be a JSON object";
|
|
173
|
-
} catch {
|
|
174
|
-
error = "arguments were not valid JSON";
|
|
175
|
-
}
|
|
176
|
-
content.push({ type: "tool_use", id: tc.id, name: tc.function.name, input, ...error ? { error } : {} });
|
|
177
|
-
}
|
|
178
|
-
return {
|
|
179
|
-
content,
|
|
180
|
-
// "length" wins over tool calls: arguments cut off by the token limit are incomplete and must not run.
|
|
181
|
-
stopReason: choice.finish_reason === "length" ? "max_tokens" : content.some((b) => b.type === "tool_use") ? "tool_use" : STOP[choice.finish_reason ?? ""] ?? "other",
|
|
182
|
-
usage: data.usage && { inputTokens: data.usage.prompt_tokens, outputTokens: data.usage.completion_tokens }
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
};
|
|
29
|
+
return (0, import_nanoharness.openaiCompatible)({
|
|
30
|
+
...opts,
|
|
31
|
+
baseUrl: opts.baseUrl ?? "https://api.openai.com/v1",
|
|
32
|
+
maxTokensParam: opts.maxTokensParam ?? (opts.baseUrl ? "max_tokens" : "max_completion_tokens"),
|
|
33
|
+
fetch: opts.fetch ?? fetch,
|
|
34
|
+
name: "OpenAI API"
|
|
35
|
+
});
|
|
186
36
|
}
|
|
187
37
|
// Annotate the CommonJS export names for ESM import in node:
|
|
188
38
|
0 && (module.exports = {
|
package/dist/index.d.cts
CHANGED
|
@@ -10,14 +10,22 @@ interface OpenAIOptions {
|
|
|
10
10
|
/** Permits plain http to a non-localhost host, e.g. a server on your LAN or in Docker. Off by default. */
|
|
11
11
|
allowInsecureHttp?: boolean;
|
|
12
12
|
maxTokens?: number;
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Which field carries `maxTokens`. Default "max_completion_tokens" for OpenAI (the older "max_tokens" is deprecated
|
|
15
|
+
* and rejected by reasoning models), and "max_tokens" when `baseUrl` is set, since most compatible servers expect it.
|
|
16
|
+
*/
|
|
14
17
|
maxTokensParam?: "max_tokens" | "max_completion_tokens";
|
|
15
18
|
temperature?: number;
|
|
16
19
|
/** A custom `fetch`, for proxies or tests. Defaults to the global `fetch`. */
|
|
17
20
|
fetch?: typeof fetch;
|
|
18
|
-
/** Retries for 408, 429 and
|
|
21
|
+
/** Retries for 408, 429, 500, 502, 503 and 504 responses, with backoff. Default 2. */
|
|
19
22
|
maxRetries?: number;
|
|
23
|
+
/** When streaming, ask the server for token usage in the last chunk (`stream_options.include_usage`). Turn off for servers that reject it. Default true. */
|
|
24
|
+
streamUsage?: boolean;
|
|
25
|
+
/** Extra headers on every request, e.g. for a gateway. */
|
|
26
|
+
headers?: Record<string, string>;
|
|
20
27
|
}
|
|
28
|
+
/** `openaiCompatible` from nanoharness, with OpenAI's URL and the global `fetch` as defaults. */
|
|
21
29
|
declare function openai(opts: OpenAIOptions): Adapter;
|
|
22
30
|
|
|
23
31
|
export { openai };
|
package/dist/index.d.ts
CHANGED
|
@@ -10,14 +10,22 @@ interface OpenAIOptions {
|
|
|
10
10
|
/** Permits plain http to a non-localhost host, e.g. a server on your LAN or in Docker. Off by default. */
|
|
11
11
|
allowInsecureHttp?: boolean;
|
|
12
12
|
maxTokens?: number;
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Which field carries `maxTokens`. Default "max_completion_tokens" for OpenAI (the older "max_tokens" is deprecated
|
|
15
|
+
* and rejected by reasoning models), and "max_tokens" when `baseUrl` is set, since most compatible servers expect it.
|
|
16
|
+
*/
|
|
14
17
|
maxTokensParam?: "max_tokens" | "max_completion_tokens";
|
|
15
18
|
temperature?: number;
|
|
16
19
|
/** A custom `fetch`, for proxies or tests. Defaults to the global `fetch`. */
|
|
17
20
|
fetch?: typeof fetch;
|
|
18
|
-
/** Retries for 408, 429 and
|
|
21
|
+
/** Retries for 408, 429, 500, 502, 503 and 504 responses, with backoff. Default 2. */
|
|
19
22
|
maxRetries?: number;
|
|
23
|
+
/** When streaming, ask the server for token usage in the last chunk (`stream_options.include_usage`). Turn off for servers that reject it. Default true. */
|
|
24
|
+
streamUsage?: boolean;
|
|
25
|
+
/** Extra headers on every request, e.g. for a gateway. */
|
|
26
|
+
headers?: Record<string, string>;
|
|
20
27
|
}
|
|
28
|
+
/** `openaiCompatible` from nanoharness, with OpenAI's URL and the global `fetch` as defaults. */
|
|
21
29
|
declare function openai(opts: OpenAIOptions): Adapter;
|
|
22
30
|
|
|
23
31
|
export { openai };
|
package/dist/index.js
CHANGED
|
@@ -1,164 +1,14 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
|
|
3
|
-
var STOP = {
|
|
4
|
-
stop: "end_turn",
|
|
5
|
-
tool_calls: "tool_use",
|
|
6
|
-
function_call: "tool_use",
|
|
7
|
-
length: "max_tokens"
|
|
8
|
-
};
|
|
9
|
-
function retryDelayMs(header, attempt) {
|
|
10
|
-
if (header) {
|
|
11
|
-
const seconds = Number(header);
|
|
12
|
-
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
|
|
13
|
-
const at = Date.parse(header);
|
|
14
|
-
if (!Number.isNaN(at)) return Math.max(0, at - Date.now());
|
|
15
|
-
}
|
|
16
|
-
return 500 * 2 ** attempt;
|
|
17
|
-
}
|
|
18
|
-
var ERROR_BODY_LIMIT = 1e3;
|
|
19
|
-
async function errorBody(res) {
|
|
20
|
-
if (!res.body) return "";
|
|
21
|
-
const reader = res.body.getReader();
|
|
22
|
-
const decoder = new TextDecoder();
|
|
23
|
-
let text = "";
|
|
24
|
-
try {
|
|
25
|
-
while (text.length <= ERROR_BODY_LIMIT) {
|
|
26
|
-
const { done, value } = await reader.read();
|
|
27
|
-
if (done) return text + decoder.decode();
|
|
28
|
-
text += decoder.decode(value, { stream: true });
|
|
29
|
-
}
|
|
30
|
-
} catch {
|
|
31
|
-
return text;
|
|
32
|
-
} finally {
|
|
33
|
-
reader.cancel().catch(() => {
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
return `${text.slice(0, ERROR_BODY_LIMIT)}... [truncated]`;
|
|
37
|
-
}
|
|
38
|
-
async function send(doFetch, url, init) {
|
|
39
|
-
try {
|
|
40
|
-
return await doFetch(url, init);
|
|
41
|
-
} catch (e) {
|
|
42
|
-
const cause = e.cause?.message ?? "";
|
|
43
|
-
if (!init.signal?.aborted && /redirect/i.test(cause)) {
|
|
44
|
-
throw new Error("OpenAI API: the server answered with a redirect, which is never followed so your API key and conversation stay with baseUrl", { cause: e });
|
|
45
|
-
}
|
|
46
|
-
throw e;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
function sleep(ms, signal) {
|
|
50
|
-
return new Promise((resolve, reject) => {
|
|
51
|
-
if (signal?.aborted) return reject(signal.reason);
|
|
52
|
-
const t = setTimeout(() => {
|
|
53
|
-
signal?.removeEventListener("abort", onAbort);
|
|
54
|
-
resolve();
|
|
55
|
-
}, ms);
|
|
56
|
-
const onAbort = () => {
|
|
57
|
-
clearTimeout(t);
|
|
58
|
-
reject(signal.reason);
|
|
59
|
-
};
|
|
60
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
function chatUrl(baseUrl, allowInsecureHttp) {
|
|
64
|
-
let u;
|
|
65
|
-
try {
|
|
66
|
-
u = new URL(baseUrl);
|
|
67
|
-
} catch {
|
|
68
|
-
throw new Error(`openai(): invalid baseUrl "${baseUrl}"`);
|
|
69
|
-
}
|
|
70
|
-
const local = u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]";
|
|
71
|
-
if (u.protocol !== "https:" && !(u.protocol === "http:" && (local || allowInsecureHttp))) {
|
|
72
|
-
throw new Error("openai(): baseUrl must use https (http is only allowed for localhost, or with allowInsecureHttp)");
|
|
73
|
-
}
|
|
74
|
-
if (u.username || u.password) throw new Error("openai(): baseUrl must not contain credentials");
|
|
75
|
-
return `${u.origin}${u.pathname.replace(/\/+$/, "")}/chat/completions`;
|
|
76
|
-
}
|
|
77
|
-
function toOpenAIMessages(system, messages) {
|
|
78
|
-
const out = [];
|
|
79
|
-
if (system) out.push({ role: "system", content: system });
|
|
80
|
-
for (const m of messages) {
|
|
81
|
-
if (typeof m.content === "string") {
|
|
82
|
-
out.push({ role: m.role, content: m.content });
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
85
|
-
const blocks = m.content;
|
|
86
|
-
const text = blocks.filter((b) => b.type === "text").map((b) => b.text).join("");
|
|
87
|
-
if (m.role === "assistant") {
|
|
88
|
-
const toolCalls = blocks.filter((b) => b.type === "tool_use").map((b) => ({ id: b.id, type: "function", function: { name: b.name, arguments: JSON.stringify(b.input) } }));
|
|
89
|
-
if (!text && toolCalls.length === 0) continue;
|
|
90
|
-
out.push({ role: "assistant", content: text || null, ...toolCalls.length ? { tool_calls: toolCalls } : {} });
|
|
91
|
-
} else {
|
|
92
|
-
for (const b of blocks) {
|
|
93
|
-
if (b.type === "tool_result") {
|
|
94
|
-
out.push({ role: "tool", tool_call_id: b.tool_use_id, content: b.is_error ? `Error: ${b.content}` : b.content });
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
if (text) out.push({ role: "user", content: text });
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
return out;
|
|
101
|
-
}
|
|
2
|
+
import { openaiCompatible } from "nanoharness";
|
|
102
3
|
function openai(opts) {
|
|
103
4
|
if (!opts.model) throw new Error("openai(): model is required");
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
messages: toOpenAIMessages(req.system, req.messages),
|
|
112
|
-
...opts.maxTokens !== void 0 ? { [opts.maxTokensParam ?? "max_tokens"]: opts.maxTokens } : {},
|
|
113
|
-
...opts.temperature !== void 0 ? { temperature: opts.temperature } : {},
|
|
114
|
-
...req.tools.length ? { tools: req.tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.input_schema } })) } : {}
|
|
115
|
-
});
|
|
116
|
-
let res;
|
|
117
|
-
for (let attempt = 0; ; attempt++) {
|
|
118
|
-
res = await send(doFetch, url, {
|
|
119
|
-
method: "POST",
|
|
120
|
-
signal: req.signal,
|
|
121
|
-
// Never follow a redirect: the request carries your API key and the whole conversation.
|
|
122
|
-
redirect: "error",
|
|
123
|
-
headers: { "content-type": "application/json", ...opts.apiKey ? { authorization: `Bearer ${opts.apiKey}` } : {} },
|
|
124
|
-
body
|
|
125
|
-
});
|
|
126
|
-
if (res.ok || !RETRYABLE.has(res.status) || attempt >= maxRetries) break;
|
|
127
|
-
await res.body?.cancel().catch(() => {
|
|
128
|
-
});
|
|
129
|
-
await sleep(Math.min(retryDelayMs(res.headers.get("retry-after"), attempt), 3e4), req.signal);
|
|
130
|
-
}
|
|
131
|
-
if (!res.ok) throw new Error(`OpenAI API error ${res.status}: ${await errorBody(res)}`);
|
|
132
|
-
let data;
|
|
133
|
-
try {
|
|
134
|
-
data = await res.json();
|
|
135
|
-
} catch {
|
|
136
|
-
throw new Error("OpenAI API returned invalid JSON");
|
|
137
|
-
}
|
|
138
|
-
const choice = data.choices?.[0];
|
|
139
|
-
if (!choice?.message) throw new Error("OpenAI API returned no choices");
|
|
140
|
-
const content = [];
|
|
141
|
-
if (choice.message.content) content.push({ type: "text", text: choice.message.content });
|
|
142
|
-
for (const tc of choice.message.tool_calls ?? []) {
|
|
143
|
-
let input = {};
|
|
144
|
-
let error;
|
|
145
|
-
try {
|
|
146
|
-
const parsed = JSON.parse(tc.function.arguments || "{}");
|
|
147
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) input = parsed;
|
|
148
|
-
else error = "arguments must be a JSON object";
|
|
149
|
-
} catch {
|
|
150
|
-
error = "arguments were not valid JSON";
|
|
151
|
-
}
|
|
152
|
-
content.push({ type: "tool_use", id: tc.id, name: tc.function.name, input, ...error ? { error } : {} });
|
|
153
|
-
}
|
|
154
|
-
return {
|
|
155
|
-
content,
|
|
156
|
-
// "length" wins over tool calls: arguments cut off by the token limit are incomplete and must not run.
|
|
157
|
-
stopReason: choice.finish_reason === "length" ? "max_tokens" : content.some((b) => b.type === "tool_use") ? "tool_use" : STOP[choice.finish_reason ?? ""] ?? "other",
|
|
158
|
-
usage: data.usage && { inputTokens: data.usage.prompt_tokens, outputTokens: data.usage.completion_tokens }
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
};
|
|
5
|
+
return openaiCompatible({
|
|
6
|
+
...opts,
|
|
7
|
+
baseUrl: opts.baseUrl ?? "https://api.openai.com/v1",
|
|
8
|
+
maxTokensParam: opts.maxTokensParam ?? (opts.baseUrl ? "max_tokens" : "max_completion_tokens"),
|
|
9
|
+
fetch: opts.fetch ?? fetch,
|
|
10
|
+
name: "OpenAI API"
|
|
11
|
+
});
|
|
162
12
|
}
|
|
163
13
|
export {
|
|
164
14
|
openai
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nanoharness-openai",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "OpenAI-compatible chat model adapter for nanoharness: OpenAI, Ollama, vLLM, OpenRouter, LM Studio and more. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"provenance": true
|
|
61
61
|
},
|
|
62
62
|
"peerDependencies": {
|
|
63
|
-
"nanoharness": ">=2.
|
|
63
|
+
"nanoharness": ">=2.1.0 <3"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"nanoharness": "file:../.."
|