@love-moon/conductor-cli 0.2.38 → 0.2.40
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/bin/conductor-config.js +16 -0
- package/bin/conductor-fire.js +532 -155
- package/bin/conductor-serve-ai.js +145 -0
- package/bin/conductor.js +5 -1
- package/package.json +6 -6
- package/src/ai-manager-handlers.js +51 -47
- package/src/daemon.js +346 -125
- package/src/fire/resume.js +498 -107
- package/src/handoff-log-mask.js +64 -0
- package/src/runtime-backends.js +111 -18
- package/src/serve-ai/adapter.js +383 -0
- package/src/serve-ai/config.js +133 -0
- package/src/serve-ai/errors.js +28 -0
- package/src/serve-ai/image-handler.js +92 -0
- package/src/serve-ai/index.js +529 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Small helpers for masking handoff-share URLs in logs / error messages /
|
|
2
|
+
// task status summaries. Extracted into a dependency-free module so they
|
|
3
|
+
// can be unit-tested without loading the rest of the daemon (which would
|
|
4
|
+
// otherwise require @love-moon/conductor-sdk and its build artifacts).
|
|
5
|
+
|
|
6
|
+
// The handoff URL embeds a bearer-style share token in its path. We mask it
|
|
7
|
+
// before writing to logs so daemon log files / `ps`-visible output don't leak
|
|
8
|
+
// a 24h read-grant for the entire transcript. The full URL still goes into
|
|
9
|
+
// the spawned CLI's argv (necessarily — that's how the AI fetches it), but
|
|
10
|
+
// the persistent log surface is safe.
|
|
11
|
+
//
|
|
12
|
+
// The character class `[^/?#\s]+` stops the token at query-string, fragment,
|
|
13
|
+
// or whitespace boundaries, so URLs like `.../share/<tok>/plain?x=1` or
|
|
14
|
+
// `.../share/<tok>/plain#foo` are still masked instead of leaking verbatim.
|
|
15
|
+
// The trailing portion (query/fragment/path tail) is preserved after masking.
|
|
16
|
+
export function maskHandoffUrlForLogs(value) {
|
|
17
|
+
if (typeof value !== "string" || !value) {
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
return value.replace(/\/share\/([^/?#\s]+)/g, (_, token) => {
|
|
21
|
+
const tail = token.length > 4 ? token.slice(-4) : token;
|
|
22
|
+
return `/share/<masked:…${tail}>`;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Scrub any handoff URL embedded in an error message before surfacing it via
|
|
27
|
+
// logs or task status summaries. Belt-and-suspenders for the case where an
|
|
28
|
+
// internal error stringifies the outbox payload.
|
|
29
|
+
//
|
|
30
|
+
// Metadata (`name`, `code`, `cause`, any own props the caller attached) is
|
|
31
|
+
// preserved on the clone so downstream consumers that switch on those fields
|
|
32
|
+
// (retry policies, status mappers) keep working after scrubbing. The
|
|
33
|
+
// prototype chain is also preserved so `instanceof` checks survive.
|
|
34
|
+
//
|
|
35
|
+
// Stack frames in some runtimes embed argument strings in the source
|
|
36
|
+
// snippet; the stack is passed through the same masker so a URL that
|
|
37
|
+
// happens to appear in a frame cannot leak through the log that prints
|
|
38
|
+
// the stack.
|
|
39
|
+
export function maskErrorForLogs(error) {
|
|
40
|
+
if (!error) {
|
|
41
|
+
return error;
|
|
42
|
+
}
|
|
43
|
+
const message = typeof error === "string" ? error : error.message;
|
|
44
|
+
if (typeof message !== "string") {
|
|
45
|
+
return error;
|
|
46
|
+
}
|
|
47
|
+
const masked = maskHandoffUrlForLogs(message);
|
|
48
|
+
if (masked === message) {
|
|
49
|
+
return error;
|
|
50
|
+
}
|
|
51
|
+
if (typeof error === "string") {
|
|
52
|
+
return masked;
|
|
53
|
+
}
|
|
54
|
+
const proto = Object.getPrototypeOf(error) || Error.prototype;
|
|
55
|
+
const clone = Object.create(proto);
|
|
56
|
+
Object.assign(clone, error);
|
|
57
|
+
clone.message = masked;
|
|
58
|
+
clone.name = error.name || clone.name || "Error";
|
|
59
|
+
clone.stack =
|
|
60
|
+
typeof error.stack === "string"
|
|
61
|
+
? maskHandoffUrlForLogs(error.stack)
|
|
62
|
+
: error.stack;
|
|
63
|
+
return clone;
|
|
64
|
+
}
|
package/src/runtime-backends.js
CHANGED
|
@@ -4,8 +4,10 @@ import { pathToFileURL } from "node:url";
|
|
|
4
4
|
|
|
5
5
|
import yaml from "js-yaml";
|
|
6
6
|
|
|
7
|
-
const BUILT_IN_RUNTIME_BACKENDS = ["codex", "claude", "kimi", "opencode"];
|
|
7
|
+
const BUILT_IN_RUNTIME_BACKENDS = ["codex", "claude", "kimi", "opencode", "copilot"];
|
|
8
8
|
const BUILT_IN_RUNTIME_BACKEND_SET = new Set(BUILT_IN_RUNTIME_BACKENDS);
|
|
9
|
+
const COMMAND_OPTIONAL_BUILT_IN_RUNTIME_BACKENDS = ["copilot"];
|
|
10
|
+
const COMMAND_OPTIONAL_BUILT_IN_RUNTIME_BACKEND_SET = new Set(COMMAND_OPTIONAL_BUILT_IN_RUNTIME_BACKENDS);
|
|
9
11
|
const LEGACY_RUNTIME_BACKEND_ALIASES = new Set([
|
|
10
12
|
"code",
|
|
11
13
|
"claude-code",
|
|
@@ -28,7 +30,7 @@ function appendProviderModulePaths(parts, value) {
|
|
|
28
30
|
if (!raw) {
|
|
29
31
|
return;
|
|
30
32
|
}
|
|
31
|
-
for (const item of raw
|
|
33
|
+
for (const item of splitProviderModulePathString(raw)) {
|
|
32
34
|
const normalized = item.trim();
|
|
33
35
|
if (normalized) {
|
|
34
36
|
parts.push(normalized);
|
|
@@ -36,6 +38,49 @@ function appendProviderModulePaths(parts, value) {
|
|
|
36
38
|
}
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
function looksLikeProviderModulePath(value) {
|
|
42
|
+
const normalized = String(value || "").trim();
|
|
43
|
+
if (!normalized) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
return (
|
|
47
|
+
normalized.startsWith("/") ||
|
|
48
|
+
normalized.startsWith("./") ||
|
|
49
|
+
normalized.startsWith("../") ||
|
|
50
|
+
normalized.startsWith("~/") ||
|
|
51
|
+
normalized.startsWith("file:") ||
|
|
52
|
+
normalized.includes("/") ||
|
|
53
|
+
normalized.includes("\\") ||
|
|
54
|
+
/\.[cm]?[jt]sx?$/i.test(normalized) ||
|
|
55
|
+
/^[A-Za-z]:[\\/]/.test(normalized)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function splitProviderModulePathString(raw) {
|
|
60
|
+
const normalized = String(raw || "").trim();
|
|
61
|
+
if (!normalized) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const platformParts = normalized
|
|
66
|
+
.split(path.delimiter)
|
|
67
|
+
.map((item) => item.trim())
|
|
68
|
+
.filter(Boolean);
|
|
69
|
+
if (platformParts.length > 1 || !normalized.includes(",")) {
|
|
70
|
+
return platformParts;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const commaParts = normalized
|
|
74
|
+
.split(",")
|
|
75
|
+
.map((item) => item.trim())
|
|
76
|
+
.filter(Boolean);
|
|
77
|
+
if (commaParts.length > 1 && commaParts.every(looksLikeProviderModulePath)) {
|
|
78
|
+
return commaParts;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return platformParts;
|
|
82
|
+
}
|
|
83
|
+
|
|
39
84
|
function listProviderModulePaths(providerPathEnv) {
|
|
40
85
|
const parts = [];
|
|
41
86
|
appendProviderModulePaths(parts, providerPathEnv);
|
|
@@ -53,7 +98,7 @@ function stripExecutableSuffix(name) {
|
|
|
53
98
|
.replace(/\.(cmd|bat|exe)$/i, "");
|
|
54
99
|
}
|
|
55
100
|
|
|
56
|
-
function parseCommandParts(commandLine) {
|
|
101
|
+
export function parseCommandParts(commandLine) {
|
|
57
102
|
const input = String(commandLine || "").trim();
|
|
58
103
|
if (!input) {
|
|
59
104
|
return { command: "", args: [], parts: [] };
|
|
@@ -62,29 +107,39 @@ function parseCommandParts(commandLine) {
|
|
|
62
107
|
const parts = [];
|
|
63
108
|
let current = "";
|
|
64
109
|
let quote = "";
|
|
65
|
-
let escaping = false;
|
|
66
110
|
let tokenStarted = false;
|
|
67
111
|
|
|
68
|
-
for (
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
tokenStarted = true;
|
|
72
|
-
escaping = false;
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
112
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
113
|
+
const char = input[index];
|
|
114
|
+
const nextChar = input[index + 1];
|
|
75
115
|
|
|
76
|
-
if (
|
|
77
|
-
|
|
116
|
+
if (quote === "'") {
|
|
117
|
+
if (char === "'") {
|
|
118
|
+
quote = "";
|
|
119
|
+
} else {
|
|
120
|
+
current += char;
|
|
121
|
+
}
|
|
78
122
|
tokenStarted = true;
|
|
79
123
|
continue;
|
|
80
124
|
}
|
|
81
125
|
|
|
82
|
-
if (quote) {
|
|
83
|
-
if (char ===
|
|
126
|
+
if (quote === "\"") {
|
|
127
|
+
if (char === "\"") {
|
|
84
128
|
quote = "";
|
|
85
|
-
|
|
86
|
-
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (char === "\\") {
|
|
132
|
+
if (nextChar === "\"" || nextChar === "\\") {
|
|
133
|
+
current += nextChar;
|
|
134
|
+
tokenStarted = true;
|
|
135
|
+
index += 1;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
current += "\\";
|
|
139
|
+
tokenStarted = true;
|
|
140
|
+
continue;
|
|
87
141
|
}
|
|
142
|
+
current += char;
|
|
88
143
|
tokenStarted = true;
|
|
89
144
|
continue;
|
|
90
145
|
}
|
|
@@ -95,6 +150,18 @@ function parseCommandParts(commandLine) {
|
|
|
95
150
|
continue;
|
|
96
151
|
}
|
|
97
152
|
|
|
153
|
+
if (char === "\\") {
|
|
154
|
+
if (nextChar && (/\s/.test(nextChar) || nextChar === "\"" || nextChar === "'" || nextChar === "\\")) {
|
|
155
|
+
current += nextChar;
|
|
156
|
+
tokenStarted = true;
|
|
157
|
+
index += 1;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
current += "\\";
|
|
161
|
+
tokenStarted = true;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
98
165
|
if (/\s/.test(char)) {
|
|
99
166
|
if (tokenStarted) {
|
|
100
167
|
parts.push(current);
|
|
@@ -108,6 +175,10 @@ function parseCommandParts(commandLine) {
|
|
|
108
175
|
tokenStarted = true;
|
|
109
176
|
}
|
|
110
177
|
|
|
178
|
+
if (quote) {
|
|
179
|
+
throw new Error(`Invalid command line: unterminated ${quote === "\"" ? "double" : "single"} quote`);
|
|
180
|
+
}
|
|
181
|
+
|
|
111
182
|
if (tokenStarted) {
|
|
112
183
|
parts.push(current);
|
|
113
184
|
}
|
|
@@ -221,6 +292,10 @@ export function isBuiltInRuntimeBackend(backend) {
|
|
|
221
292
|
return BUILT_IN_RUNTIME_BACKEND_SET.has(normalizeRuntimeBackendName(backend));
|
|
222
293
|
}
|
|
223
294
|
|
|
295
|
+
export function isCommandOptionalBuiltInRuntimeBackend(backend) {
|
|
296
|
+
return COMMAND_OPTIONAL_BUILT_IN_RUNTIME_BACKEND_SET.has(normalizeRuntimeBackendName(backend));
|
|
297
|
+
}
|
|
298
|
+
|
|
224
299
|
function readConfigEnvValue(configFilePath, key) {
|
|
225
300
|
const targetPath =
|
|
226
301
|
typeof configFilePath === "string" && configFilePath.trim()
|
|
@@ -480,6 +555,14 @@ export async function resolveConfiguredRuntimeBackend(backend, allowCliList, opt
|
|
|
480
555
|
};
|
|
481
556
|
}
|
|
482
557
|
|
|
558
|
+
if (!hasConfiguredEntry && isCommandOptionalBuiltInRuntimeBackend(resolvedBackend)) {
|
|
559
|
+
return {
|
|
560
|
+
requestedBackend: normalizedBackend,
|
|
561
|
+
runtimeBackend: resolvedBackend,
|
|
562
|
+
commandLine: "",
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
483
566
|
if (!hasConfiguredEntry && !isBuiltInRuntimeBackend(resolvedBackend) && await isRuntimeSupportedBackend(resolvedBackend, options)) {
|
|
484
567
|
return {
|
|
485
568
|
requestedBackend: normalizedBackend,
|
|
@@ -543,12 +626,22 @@ export async function listAdvertisedBackends(allowCliList, options = {}) {
|
|
|
543
626
|
const externalBackends = discoveredExternalBackends.filter(
|
|
544
627
|
(backend) => !shadowedExternalBackends.has(backend) && !explicitlyConfiguredBackends.has(backend),
|
|
545
628
|
);
|
|
546
|
-
const supportedBackends = [...new Set([...advertisedConfiguredBackends, ...externalBackends])];
|
|
547
629
|
|
|
548
630
|
for (const backend of externalBackends) {
|
|
549
631
|
runtimeBackendMap[backend] = backend;
|
|
550
632
|
}
|
|
551
633
|
|
|
634
|
+
const commandOptionalBuiltIns = BUILT_IN_RUNTIME_BACKENDS.filter(
|
|
635
|
+
(backend) => isCommandOptionalBuiltInRuntimeBackend(backend) && !runtimeBackendMap[backend],
|
|
636
|
+
);
|
|
637
|
+
for (const backend of commandOptionalBuiltIns) {
|
|
638
|
+
runtimeBackendMap[backend] = backend;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const supportedBackends = [
|
|
642
|
+
...new Set([...advertisedConfiguredBackends, ...externalBackends, ...commandOptionalBuiltIns]),
|
|
643
|
+
];
|
|
644
|
+
|
|
552
645
|
return {
|
|
553
646
|
configuredBackends: advertisedConfiguredBackends,
|
|
554
647
|
externalBackends,
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
function normalizeRole(role) {
|
|
4
|
+
const normalized = String(role || "").trim().toLowerCase();
|
|
5
|
+
return normalized || "user";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function extractImageUrlFromPart(part) {
|
|
9
|
+
if (!part || typeof part !== "object") {
|
|
10
|
+
return "";
|
|
11
|
+
}
|
|
12
|
+
if (part.type === "image_url") {
|
|
13
|
+
if (typeof part.image_url === "string") {
|
|
14
|
+
return part.image_url.trim();
|
|
15
|
+
}
|
|
16
|
+
if (part.image_url && typeof part.image_url === "object" && typeof part.image_url.url === "string") {
|
|
17
|
+
return part.image_url.url.trim();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (part.type === "input_image") {
|
|
21
|
+
if (typeof part.image_url === "string") {
|
|
22
|
+
return part.image_url.trim();
|
|
23
|
+
}
|
|
24
|
+
if (typeof part.url === "string") {
|
|
25
|
+
return part.url.trim();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function extractTextFromPart(part) {
|
|
32
|
+
if (!part || typeof part !== "object") {
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
if (part.type === "text" && typeof part.text === "string") {
|
|
36
|
+
return part.text;
|
|
37
|
+
}
|
|
38
|
+
if (part.type === "input_text" && typeof part.text === "string") {
|
|
39
|
+
return part.text;
|
|
40
|
+
}
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function extractMessageParts(content) {
|
|
45
|
+
if (typeof content === "string") {
|
|
46
|
+
return {
|
|
47
|
+
text: content.trim(),
|
|
48
|
+
imageUrls: [],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!Array.isArray(content)) {
|
|
53
|
+
return {
|
|
54
|
+
text: "",
|
|
55
|
+
imageUrls: [],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const texts = [];
|
|
60
|
+
const imageUrls = [];
|
|
61
|
+
for (const part of content) {
|
|
62
|
+
const text = extractTextFromPart(part);
|
|
63
|
+
if (text) {
|
|
64
|
+
texts.push(text);
|
|
65
|
+
}
|
|
66
|
+
const imageUrl = extractImageUrlFromPart(part);
|
|
67
|
+
if (imageUrl) {
|
|
68
|
+
imageUrls.push(imageUrl);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
text: texts.join("").trim(),
|
|
74
|
+
imageUrls,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function serializeMessageForHistory(message) {
|
|
79
|
+
const role = normalizeRole(message?.role);
|
|
80
|
+
const { text, imageUrls } = extractMessageParts(message?.content);
|
|
81
|
+
const historyRole = role === "assistant" ? "assistant" : "user";
|
|
82
|
+
const segments = [];
|
|
83
|
+
|
|
84
|
+
if (role === "system") {
|
|
85
|
+
segments.push("[System]");
|
|
86
|
+
} else if (role === "tool") {
|
|
87
|
+
segments.push("[Tool]");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (text) {
|
|
91
|
+
segments.push(text);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (imageUrls.length > 0) {
|
|
95
|
+
segments.push(
|
|
96
|
+
imageUrls.length === 1
|
|
97
|
+
? "[Attached image omitted from prior turn]"
|
|
98
|
+
: `[${imageUrls.length} attached images omitted from prior turn]`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const content = segments.filter(Boolean).join("\n\n").trim();
|
|
103
|
+
return content ? { role: historyRole, content } : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function buildChatTurn(messages) {
|
|
107
|
+
const normalizedMessages = Array.isArray(messages) ? messages : [];
|
|
108
|
+
if (normalizedMessages.length === 0) {
|
|
109
|
+
throw new Error("messages must be a non-empty array");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const history = [];
|
|
113
|
+
for (const message of normalizedMessages.slice(0, -1)) {
|
|
114
|
+
const entry = serializeMessageForHistory(message);
|
|
115
|
+
if (entry) {
|
|
116
|
+
history.push(entry);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const lastMessage = normalizedMessages.at(-1);
|
|
121
|
+
const lastRole = normalizeRole(lastMessage?.role);
|
|
122
|
+
const { text, imageUrls } = extractMessageParts(lastMessage?.content);
|
|
123
|
+
|
|
124
|
+
let promptText = text;
|
|
125
|
+
if (lastRole === "system") {
|
|
126
|
+
promptText = promptText ? `[System]\n\n${promptText}` : "[System]";
|
|
127
|
+
} else if (lastRole === "assistant") {
|
|
128
|
+
promptText = promptText ? `[Assistant]\n\n${promptText}` : "[Assistant]";
|
|
129
|
+
} else if (lastRole === "tool") {
|
|
130
|
+
promptText = promptText ? `[Tool]\n\n${promptText}` : "[Tool]";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!promptText && imageUrls.length > 0) {
|
|
134
|
+
promptText = "Analyze the attached image.";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!promptText) {
|
|
138
|
+
throw new Error("last message must include text or image content");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
promptText,
|
|
143
|
+
imageUrls,
|
|
144
|
+
initialHistory: history,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function normalizeResponseFormat(responseFormat) {
|
|
149
|
+
if (!responseFormat || responseFormat === "text") {
|
|
150
|
+
return {
|
|
151
|
+
type: "text",
|
|
152
|
+
jsonSchema: null,
|
|
153
|
+
outputFormat: null,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (typeof responseFormat !== "object") {
|
|
158
|
+
throw new Error("response_format must be an object");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const type = String(responseFormat.type || "").trim().toLowerCase();
|
|
162
|
+
if (!type || type === "text") {
|
|
163
|
+
return {
|
|
164
|
+
type: "text",
|
|
165
|
+
jsonSchema: null,
|
|
166
|
+
outputFormat: null,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (type === "json_object") {
|
|
171
|
+
return {
|
|
172
|
+
type,
|
|
173
|
+
jsonSchema: {
|
|
174
|
+
type: "object",
|
|
175
|
+
},
|
|
176
|
+
outputFormat: {
|
|
177
|
+
type: "json_object",
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (type === "json_schema") {
|
|
183
|
+
const jsonSchema =
|
|
184
|
+
responseFormat?.json_schema?.schema && typeof responseFormat.json_schema.schema === "object"
|
|
185
|
+
? responseFormat.json_schema.schema
|
|
186
|
+
: responseFormat?.schema && typeof responseFormat.schema === "object"
|
|
187
|
+
? responseFormat.schema
|
|
188
|
+
: null;
|
|
189
|
+
if (!jsonSchema) {
|
|
190
|
+
throw new Error("response_format.json_schema.schema is required");
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
type,
|
|
194
|
+
jsonSchema,
|
|
195
|
+
outputFormat: {
|
|
196
|
+
type: "json_schema",
|
|
197
|
+
schema: jsonSchema,
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
throw new Error(`unsupported response_format.type: ${type}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function tryParseJson(text) {
|
|
206
|
+
try {
|
|
207
|
+
return {
|
|
208
|
+
ok: true,
|
|
209
|
+
value: JSON.parse(text),
|
|
210
|
+
};
|
|
211
|
+
} catch {
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
value: null,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function unwrapJsonCodeFence(text) {
|
|
220
|
+
const normalized = String(text || "").trim();
|
|
221
|
+
const match = normalized.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
222
|
+
return match ? match[1].trim() : normalized;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function findBalancedJsonSubstring(text) {
|
|
226
|
+
const source = String(text || "");
|
|
227
|
+
for (let start = 0; start < source.length; start += 1) {
|
|
228
|
+
const opener = source[start];
|
|
229
|
+
if (opener !== "{" && opener !== "[") {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const stack = [opener === "{" ? "}" : "]"];
|
|
234
|
+
let inString = false;
|
|
235
|
+
let escaped = false;
|
|
236
|
+
|
|
237
|
+
for (let index = start + 1; index < source.length; index += 1) {
|
|
238
|
+
const char = source[index];
|
|
239
|
+
if (inString) {
|
|
240
|
+
if (escaped) {
|
|
241
|
+
escaped = false;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (char === "\\") {
|
|
245
|
+
escaped = true;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (char === "\"") {
|
|
249
|
+
inString = false;
|
|
250
|
+
}
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (char === "\"") {
|
|
255
|
+
inString = true;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (char === "{") {
|
|
259
|
+
stack.push("}");
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (char === "[") {
|
|
263
|
+
stack.push("]");
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if ((char === "}" || char === "]") && stack.at(-1) === char) {
|
|
267
|
+
stack.pop();
|
|
268
|
+
if (stack.length === 0) {
|
|
269
|
+
const candidate = source.slice(start, index + 1).trim();
|
|
270
|
+
const parsed = tryParseJson(candidate);
|
|
271
|
+
if (parsed.ok) {
|
|
272
|
+
return parsed.value;
|
|
273
|
+
}
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function extractStructuredOutputValue(result) {
|
|
283
|
+
if (!result || typeof result !== "object") {
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
if (
|
|
287
|
+
result.metadata &&
|
|
288
|
+
typeof result.metadata === "object" &&
|
|
289
|
+
Object.prototype.hasOwnProperty.call(result.metadata, "structuredOutput")
|
|
290
|
+
) {
|
|
291
|
+
return result.metadata.structuredOutput;
|
|
292
|
+
}
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function parseStructuredOutputText(text) {
|
|
297
|
+
const normalized = String(text || "").trim();
|
|
298
|
+
if (!normalized) {
|
|
299
|
+
throw new Error("structured output is empty");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const direct = tryParseJson(normalized);
|
|
303
|
+
if (direct.ok) {
|
|
304
|
+
return direct.value;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const unfenced = unwrapJsonCodeFence(normalized);
|
|
308
|
+
if (unfenced !== normalized) {
|
|
309
|
+
const fenced = tryParseJson(unfenced);
|
|
310
|
+
if (fenced.ok) {
|
|
311
|
+
return fenced.value;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const extracted = findBalancedJsonSubstring(unfenced);
|
|
316
|
+
if (extracted !== null) {
|
|
317
|
+
return extracted;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
throw new Error("structured output is not valid JSON");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function normalizeUsage(usage) {
|
|
324
|
+
const promptTokens =
|
|
325
|
+
Number(usage?.prompt_tokens ?? usage?.input_tokens ?? usage?.inputTokens ?? usage?.input ?? 0) || 0;
|
|
326
|
+
const completionTokens =
|
|
327
|
+
Number(usage?.completion_tokens ?? usage?.output_tokens ?? usage?.outputTokens ?? usage?.output ?? 0) || 0;
|
|
328
|
+
const totalTokens =
|
|
329
|
+
Number(usage?.total_tokens ?? usage?.totalTokens ?? promptTokens + completionTokens) || 0;
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
prompt_tokens: promptTokens,
|
|
333
|
+
completion_tokens: completionTokens,
|
|
334
|
+
total_tokens: totalTokens,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function assertStructuredOutputText(text, responseFormat) {
|
|
339
|
+
if (!responseFormat || responseFormat.type === "text") {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
JSON.parse(String(text || ""));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function normalizeStructuredOutputResult(result, responseFormat) {
|
|
346
|
+
if (!responseFormat || responseFormat.type === "text") {
|
|
347
|
+
return result;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const metadataStructuredOutput = extractStructuredOutputValue(result);
|
|
351
|
+
if (metadataStructuredOutput !== undefined) {
|
|
352
|
+
return {
|
|
353
|
+
...(result && typeof result === "object" ? result : {}),
|
|
354
|
+
text: JSON.stringify(metadataStructuredOutput),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
...(result && typeof result === "object" ? result : {}),
|
|
360
|
+
text: JSON.stringify(parseStructuredOutputText(result?.text)),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function toOpenAiChatCompletion(result, { model }) {
|
|
365
|
+
const text = typeof result?.text === "string" ? result.text : "";
|
|
366
|
+
return {
|
|
367
|
+
id: `chatcmpl-${randomUUID()}`,
|
|
368
|
+
object: "chat.completion",
|
|
369
|
+
created: Math.floor(Date.now() / 1000),
|
|
370
|
+
model,
|
|
371
|
+
choices: [
|
|
372
|
+
{
|
|
373
|
+
index: 0,
|
|
374
|
+
message: {
|
|
375
|
+
role: "assistant",
|
|
376
|
+
content: text,
|
|
377
|
+
},
|
|
378
|
+
finish_reason: "stop",
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
usage: normalizeUsage(result?.usage),
|
|
382
|
+
};
|
|
383
|
+
}
|