@love-moon/conductor-cli 0.2.39 → 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 +258 -153
- 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 +321 -121
- package/src/fire/resume.js +498 -107
- package/src/handoff-log-mask.js +64 -0
- package/src/runtime-backends.js +67 -17
- 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",
|
|
@@ -96,7 +98,7 @@ function stripExecutableSuffix(name) {
|
|
|
96
98
|
.replace(/\.(cmd|bat|exe)$/i, "");
|
|
97
99
|
}
|
|
98
100
|
|
|
99
|
-
function parseCommandParts(commandLine) {
|
|
101
|
+
export function parseCommandParts(commandLine) {
|
|
100
102
|
const input = String(commandLine || "").trim();
|
|
101
103
|
if (!input) {
|
|
102
104
|
return { command: "", args: [], parts: [] };
|
|
@@ -105,29 +107,39 @@ function parseCommandParts(commandLine) {
|
|
|
105
107
|
const parts = [];
|
|
106
108
|
let current = "";
|
|
107
109
|
let quote = "";
|
|
108
|
-
let escaping = false;
|
|
109
110
|
let tokenStarted = false;
|
|
110
111
|
|
|
111
|
-
for (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
tokenStarted = true;
|
|
115
|
-
escaping = false;
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
112
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
113
|
+
const char = input[index];
|
|
114
|
+
const nextChar = input[index + 1];
|
|
118
115
|
|
|
119
|
-
if (
|
|
120
|
-
|
|
116
|
+
if (quote === "'") {
|
|
117
|
+
if (char === "'") {
|
|
118
|
+
quote = "";
|
|
119
|
+
} else {
|
|
120
|
+
current += char;
|
|
121
|
+
}
|
|
121
122
|
tokenStarted = true;
|
|
122
123
|
continue;
|
|
123
124
|
}
|
|
124
125
|
|
|
125
|
-
if (quote) {
|
|
126
|
-
if (char ===
|
|
126
|
+
if (quote === "\"") {
|
|
127
|
+
if (char === "\"") {
|
|
127
128
|
quote = "";
|
|
128
|
-
|
|
129
|
-
current += char;
|
|
129
|
+
continue;
|
|
130
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;
|
|
141
|
+
}
|
|
142
|
+
current += char;
|
|
131
143
|
tokenStarted = true;
|
|
132
144
|
continue;
|
|
133
145
|
}
|
|
@@ -138,6 +150,18 @@ function parseCommandParts(commandLine) {
|
|
|
138
150
|
continue;
|
|
139
151
|
}
|
|
140
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
|
+
|
|
141
165
|
if (/\s/.test(char)) {
|
|
142
166
|
if (tokenStarted) {
|
|
143
167
|
parts.push(current);
|
|
@@ -151,6 +175,10 @@ function parseCommandParts(commandLine) {
|
|
|
151
175
|
tokenStarted = true;
|
|
152
176
|
}
|
|
153
177
|
|
|
178
|
+
if (quote) {
|
|
179
|
+
throw new Error(`Invalid command line: unterminated ${quote === "\"" ? "double" : "single"} quote`);
|
|
180
|
+
}
|
|
181
|
+
|
|
154
182
|
if (tokenStarted) {
|
|
155
183
|
parts.push(current);
|
|
156
184
|
}
|
|
@@ -264,6 +292,10 @@ export function isBuiltInRuntimeBackend(backend) {
|
|
|
264
292
|
return BUILT_IN_RUNTIME_BACKEND_SET.has(normalizeRuntimeBackendName(backend));
|
|
265
293
|
}
|
|
266
294
|
|
|
295
|
+
export function isCommandOptionalBuiltInRuntimeBackend(backend) {
|
|
296
|
+
return COMMAND_OPTIONAL_BUILT_IN_RUNTIME_BACKEND_SET.has(normalizeRuntimeBackendName(backend));
|
|
297
|
+
}
|
|
298
|
+
|
|
267
299
|
function readConfigEnvValue(configFilePath, key) {
|
|
268
300
|
const targetPath =
|
|
269
301
|
typeof configFilePath === "string" && configFilePath.trim()
|
|
@@ -523,6 +555,14 @@ export async function resolveConfiguredRuntimeBackend(backend, allowCliList, opt
|
|
|
523
555
|
};
|
|
524
556
|
}
|
|
525
557
|
|
|
558
|
+
if (!hasConfiguredEntry && isCommandOptionalBuiltInRuntimeBackend(resolvedBackend)) {
|
|
559
|
+
return {
|
|
560
|
+
requestedBackend: normalizedBackend,
|
|
561
|
+
runtimeBackend: resolvedBackend,
|
|
562
|
+
commandLine: "",
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
526
566
|
if (!hasConfiguredEntry && !isBuiltInRuntimeBackend(resolvedBackend) && await isRuntimeSupportedBackend(resolvedBackend, options)) {
|
|
527
567
|
return {
|
|
528
568
|
requestedBackend: normalizedBackend,
|
|
@@ -586,12 +626,22 @@ export async function listAdvertisedBackends(allowCliList, options = {}) {
|
|
|
586
626
|
const externalBackends = discoveredExternalBackends.filter(
|
|
587
627
|
(backend) => !shadowedExternalBackends.has(backend) && !explicitlyConfiguredBackends.has(backend),
|
|
588
628
|
);
|
|
589
|
-
const supportedBackends = [...new Set([...advertisedConfiguredBackends, ...externalBackends])];
|
|
590
629
|
|
|
591
630
|
for (const backend of externalBackends) {
|
|
592
631
|
runtimeBackendMap[backend] = backend;
|
|
593
632
|
}
|
|
594
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
|
+
|
|
595
645
|
return {
|
|
596
646
|
configuredBackends: advertisedConfiguredBackends,
|
|
597
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
|
+
}
|