openmausbot 0.1.68 → 0.1.69
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/dist/assets/{index-HqooKFTS.js → index-BmHupgVJ.js} +1 -1
- package/dist/assets/index-COp5lMnv.js +279 -0
- package/dist/assets/index-Jk7TIeyW.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +38 -2
- package/dist-server/drivers/agents-proxy.js +85 -5
- package/dist-server/index.js +1465 -611
- package/dist-server/openmausbot.js +554 -191
- package/dist-server/pair-cli.js +554 -191
- package/dist-server/server/avatar-image.js +86 -34
- package/dist-server/server/bot-package.js +60 -14
- package/dist-server/server/config.js +23 -2
- package/dist-server/server/drivers/acp/qwen.js +142 -5
- package/dist-server/server/drivers/agents-proxy.js +95 -4
- package/dist-server/server/drivers/codex-device-auth.js +102 -3
- package/dist-server/server/drivers/codex-identity.js +115 -0
- package/dist-server/server/drivers/codex.js +6 -0
- package/dist-server/server/harness/registry.js +2 -0
- package/dist-server/server/index.js +26 -5
- package/dist-server/server/package-export.js +7 -0
- package/dist-server/server/provider-auth-sessions.js +24 -0
- package/dist-server/server/routine-requests.js +270 -25
- package/dist-server/server/routines.js +185 -18
- package/dist-server/server/steer-queue.js +28 -0
- package/dist-server/shared/image-generation.js +28 -0
- package/dist-server/vps-container-mcp.js +38 -2
- package/package.json +1 -1
- package/dist/assets/index-CbaejjQA.js +0 -278
- package/dist/assets/index-aJ5EWZOQ.css +0 -1
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { normalizeImageGenerationUrl } from "../shared/image-generation.js";
|
|
3
|
+
import { decodeGeneratedImage } from "./generated-image.js";
|
|
2
4
|
export const AVATAR_DIRECTION_MAX_CHARS = 400;
|
|
3
5
|
export const AVATAR_IMAGE_TIMEOUT_MS = 120_000;
|
|
4
6
|
const MAX_UPSTREAM_RESPONSE_BYTES = 15 * 1024 * 1024;
|
|
@@ -6,8 +8,34 @@ export const avatarGenerationRequestSchema = z.object({
|
|
|
6
8
|
prompt: z.string().trim().max(AVATAR_DIRECTION_MAX_CHARS).default(""),
|
|
7
9
|
});
|
|
8
10
|
const generatedImageResponseSchema = z.object({
|
|
9
|
-
data: z.array(z.object({ b64_json: z.string().min(1) })).min(1),
|
|
11
|
+
data: z.array(z.object({ b64_json: z.string().min(1).optional(), url: z.string().optional() })).min(1),
|
|
10
12
|
});
|
|
13
|
+
/** Public settings describe availability without returning provider secrets. */
|
|
14
|
+
export function avatarImageStatus(cfg) {
|
|
15
|
+
const provider = cfg.imageGen?.provider ?? "openai";
|
|
16
|
+
const openaiConfigured = Boolean(cfg.imageGen?.key?.trim());
|
|
17
|
+
const xaiConfigured = Boolean(cfg.xai?.key?.trim());
|
|
18
|
+
const customKeyConfigured = Boolean(cfg.imageGen?.customApiKey?.trim());
|
|
19
|
+
const customModel = cfg.imageGen?.customModel?.trim() ?? "";
|
|
20
|
+
let customUrl = "";
|
|
21
|
+
try {
|
|
22
|
+
if (cfg.imageGen?.customUrl?.trim())
|
|
23
|
+
customUrl = normalizeImageGenerationUrl(cfg.imageGen.customUrl);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// A manually edited invalid URL must not become an outbound request.
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
provider,
|
|
30
|
+
configured: provider === "openai" ? openaiConfigured : provider === "xai" ? xaiConfigured : Boolean(customUrl && customModel),
|
|
31
|
+
model: provider === "openai" ? "gpt-image-2" : provider === "xai" ? "grok-imagine-image-2.0" : customModel,
|
|
32
|
+
customUrl,
|
|
33
|
+
customModel,
|
|
34
|
+
openaiConfigured,
|
|
35
|
+
xaiConfigured,
|
|
36
|
+
customKeyConfigured,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
11
39
|
/** Copy the mutable avatar fields before an asynchronous generation starts. */
|
|
12
40
|
export function snapshotAvatarGenerationState(bot) {
|
|
13
41
|
return { avatarUrl: bot.avatarUrl, avatarCrop: bot.avatarCrop };
|
|
@@ -53,7 +81,14 @@ async function boundedResponseText(response) {
|
|
|
53
81
|
let received = 0;
|
|
54
82
|
try {
|
|
55
83
|
for (;;) {
|
|
56
|
-
|
|
84
|
+
let chunk;
|
|
85
|
+
try {
|
|
86
|
+
chunk = await reader.read();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
throw Object.assign(new Error("Could not read the generated avatar response"), { status: 502 });
|
|
90
|
+
}
|
|
91
|
+
const { done, value } = chunk;
|
|
57
92
|
if (done)
|
|
58
93
|
break;
|
|
59
94
|
received += value.byteLength;
|
|
@@ -70,31 +105,45 @@ async function boundedResponseText(response) {
|
|
|
70
105
|
reader.releaseLock();
|
|
71
106
|
}
|
|
72
107
|
}
|
|
73
|
-
export async function generateAvatarImage(
|
|
74
|
-
|
|
75
|
-
|
|
108
|
+
export async function generateAvatarImage(cfg, bot, direction, fetchImpl = fetch, timeoutMs = AVATAR_IMAGE_TIMEOUT_MS) {
|
|
109
|
+
const settings = avatarImageStatus(cfg);
|
|
110
|
+
const { provider, model } = settings;
|
|
111
|
+
if (!settings.configured) {
|
|
112
|
+
const message = provider === "openai" ? "Add an OpenAI image API key first"
|
|
113
|
+
: provider === "xai" ? "Add an xAI API key first"
|
|
114
|
+
: "Set a valid custom image API URL and model first";
|
|
115
|
+
throw Object.assign(new Error(message), { status: 409 });
|
|
116
|
+
}
|
|
117
|
+
const apiKey = (provider === "openai" ? cfg.imageGen?.key
|
|
118
|
+
: provider === "xai" ? cfg.xai?.key : cfg.imageGen?.customApiKey)?.trim();
|
|
119
|
+
const providerName = provider === "openai" ? "OpenAI" : provider === "xai" ? "xAI" : "Custom";
|
|
120
|
+
const url = provider === "openai" ? "https://api.openai.com/v1/images/generations"
|
|
121
|
+
: provider === "xai" ? "https://api.x.ai/v1/images/generations"
|
|
122
|
+
: `${settings.customUrl}/images/generations`;
|
|
123
|
+
const headers = { "content-type": "application/json" };
|
|
124
|
+
if (apiKey)
|
|
125
|
+
headers.authorization = `Bearer ${apiKey}`;
|
|
126
|
+
const body = {
|
|
127
|
+
model,
|
|
128
|
+
prompt: avatarGenerationPrompt(bot, direction),
|
|
129
|
+
...(provider === "openai" ? { size: "1024x1024", quality: "low", output_format: "webp" }
|
|
130
|
+
: provider === "xai" ? { response_format: "b64_json", aspect_ratio: "1:1" }
|
|
131
|
+
: { response_format: "b64_json", size: "1024x1024", n: 1 }),
|
|
132
|
+
};
|
|
76
133
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
77
134
|
let response;
|
|
78
135
|
try {
|
|
79
|
-
response = await fetchImpl(
|
|
136
|
+
response = await fetchImpl(url, {
|
|
80
137
|
method: "POST",
|
|
81
|
-
headers
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
},
|
|
85
|
-
body: JSON.stringify({
|
|
86
|
-
model: "gpt-image-2",
|
|
87
|
-
prompt: avatarGenerationPrompt(bot, direction),
|
|
88
|
-
size: "1024x1024",
|
|
89
|
-
quality: "low",
|
|
90
|
-
output_format: "webp",
|
|
91
|
-
}),
|
|
138
|
+
headers,
|
|
139
|
+
body: JSON.stringify(body),
|
|
140
|
+
redirect: "error",
|
|
92
141
|
signal: timeoutSignal,
|
|
93
142
|
});
|
|
94
143
|
}
|
|
95
144
|
catch (error) {
|
|
96
145
|
const timedOut = timeoutSignal.aborted || (error instanceof Error && error.name === "TimeoutError");
|
|
97
|
-
throw Object.assign(new Error(timedOut ? "Avatar generation timed out" :
|
|
146
|
+
throw Object.assign(new Error(timedOut ? "Avatar generation timed out" : `Could not reach ${providerName} image generation`), { status: 502 });
|
|
98
147
|
}
|
|
99
148
|
let text;
|
|
100
149
|
try {
|
|
@@ -110,15 +159,9 @@ export async function generateAvatarImage(apiKey, bot, direction, fetchImpl = fe
|
|
|
110
159
|
throw error;
|
|
111
160
|
}
|
|
112
161
|
if (!response.ok) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (parsed.success)
|
|
117
|
-
message = parsed.data.error.message.slice(0, 500);
|
|
118
|
-
}
|
|
119
|
-
catch {
|
|
120
|
-
// Keep the bounded status-only message for malformed upstream errors.
|
|
121
|
-
}
|
|
162
|
+
// Upstream errors can repeat authorization headers, signed URLs, or other
|
|
163
|
+
// secrets. Return only the provider and HTTP status, never its raw body.
|
|
164
|
+
const message = `${providerName} image generation failed (HTTP ${response.status})`;
|
|
122
165
|
throw Object.assign(new Error(message), { status: response.status === 401 ? 401 : 502 });
|
|
123
166
|
}
|
|
124
167
|
let parsedJson;
|
|
@@ -126,19 +169,28 @@ export async function generateAvatarImage(apiKey, bot, direction, fetchImpl = fe
|
|
|
126
169
|
parsedJson = JSON.parse(text);
|
|
127
170
|
}
|
|
128
171
|
catch {
|
|
129
|
-
throw Object.assign(new Error(
|
|
172
|
+
throw Object.assign(new Error(`${providerName} returned an invalid image response`), { status: 502 });
|
|
130
173
|
}
|
|
131
174
|
const parsed = generatedImageResponseSchema.safeParse(parsedJson);
|
|
132
175
|
if (!parsed.success) {
|
|
133
|
-
throw Object.assign(new Error(
|
|
176
|
+
throw Object.assign(new Error(`${providerName} returned no generated image`), { status: 502 });
|
|
134
177
|
}
|
|
135
178
|
const encoded = parsed.data.data[0].b64_json;
|
|
179
|
+
if (!encoded) {
|
|
180
|
+
// Do not fetch arbitrary URLs returned by a provider. Compatible gateways
|
|
181
|
+
// must support the requested inline base64 response format.
|
|
182
|
+
throw Object.assign(new Error(`${providerName} image generation must return base64 image data (b64_json)`), { status: 502 });
|
|
183
|
+
}
|
|
136
184
|
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length % 4 !== 0) {
|
|
137
|
-
throw Object.assign(new Error(
|
|
185
|
+
throw Object.assign(new Error(`${providerName} returned invalid image data`), { status: 502 });
|
|
138
186
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
187
|
+
try {
|
|
188
|
+
const image = decodeGeneratedImage(encoded);
|
|
189
|
+
if (image.mime === "image/gif")
|
|
190
|
+
throw new Error("unsupported avatar format");
|
|
191
|
+
return { bytes: image.bytes, mime: image.mime };
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
throw Object.assign(new Error(`${providerName} returned invalid or oversized image data; expected PNG, JPEG, or WebP`), { status: 502 });
|
|
142
195
|
}
|
|
143
|
-
return { bytes, mime: "image/webp" };
|
|
144
196
|
}
|
|
@@ -27,6 +27,52 @@ const key = requiredText(64).regex(/^[a-z0-9][a-z0-9_-]*$/, {
|
|
|
27
27
|
message: "may only contain lowercase letters, numbers, - and _",
|
|
28
28
|
});
|
|
29
29
|
const MAX_DATE_MS = 8_640_000_000_000_000;
|
|
30
|
+
const CLOCK_TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
31
|
+
const intervalWeekdays = z.array(z.number().int().min(0).max(6)).min(1).max(7).refine((weekdays) => new Set(weekdays).size === weekdays.length, "must contain unique weekdays");
|
|
32
|
+
const intervalWindow = z.object({
|
|
33
|
+
start: z.string().regex(CLOCK_TIME, { message: "must use HH:MM" }),
|
|
34
|
+
end: z.string().regex(CLOCK_TIME, { message: "must use HH:MM" }),
|
|
35
|
+
}).strict().refine(({ start, end }) => start < end, {
|
|
36
|
+
message: "must end later on the same day",
|
|
37
|
+
});
|
|
38
|
+
const packageRoutineScheduleSchema = z.discriminatedUnion("type", [
|
|
39
|
+
z.object({ type: z.literal("once"), at: z.number().int() }),
|
|
40
|
+
z.object({
|
|
41
|
+
type: z.literal("daily"),
|
|
42
|
+
time: requiredText(5).regex(CLOCK_TIME, { message: "must use HH:MM" }),
|
|
43
|
+
weekdays: z.array(z.number().int().min(0).max(6)).min(1).max(7),
|
|
44
|
+
}),
|
|
45
|
+
z.object({
|
|
46
|
+
type: z.literal("interval"),
|
|
47
|
+
everyMinutes: z.number().int().min(5).max(1_440),
|
|
48
|
+
anchorAt: z.number().int().nonnegative().max(MAX_DATE_MS),
|
|
49
|
+
weekdays: intervalWeekdays.optional(),
|
|
50
|
+
window: intervalWindow.optional(),
|
|
51
|
+
endsAt: z.number().int().nonnegative().max(MAX_DATE_MS).optional(),
|
|
52
|
+
}),
|
|
53
|
+
]).superRefine((schedule, context) => {
|
|
54
|
+
if (schedule.type !== "interval")
|
|
55
|
+
return;
|
|
56
|
+
if (schedule.window) {
|
|
57
|
+
const [startHour, startMinute] = schedule.window.start.split(":").map(Number);
|
|
58
|
+
const [endHour, endMinute] = schedule.window.end.split(":").map(Number);
|
|
59
|
+
const windowMinutes = endHour * 60 + endMinute - (startHour * 60 + startMinute);
|
|
60
|
+
if (windowMinutes < schedule.everyMinutes) {
|
|
61
|
+
context.addIssue({
|
|
62
|
+
code: "custom",
|
|
63
|
+
message: "must be at least as long as the interval cadence",
|
|
64
|
+
path: ["window"],
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (schedule.endsAt !== undefined && schedule.endsAt < schedule.anchorAt) {
|
|
69
|
+
context.addIssue({
|
|
70
|
+
code: "custom",
|
|
71
|
+
message: "must not be before the interval anchor",
|
|
72
|
+
path: ["endsAt"],
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
});
|
|
30
76
|
const packageSchema = z.object({
|
|
31
77
|
format: z.literal(BOT_PACKAGE_FORMAT, { error: "This is not an OpenMaus package" }),
|
|
32
78
|
version: z.literal(BOT_PACKAGE_VERSION, { error: "Package version is not supported" }),
|
|
@@ -86,19 +132,7 @@ const packageSchema = z.object({
|
|
|
86
132
|
agent: key,
|
|
87
133
|
prompt: requiredText(20_000),
|
|
88
134
|
runOn: z.enum(["maus", "cloud"]),
|
|
89
|
-
schedule:
|
|
90
|
-
z.object({ type: z.literal("once"), at: z.number().int() }),
|
|
91
|
-
z.object({
|
|
92
|
-
type: z.literal("daily"),
|
|
93
|
-
time: requiredText(5).regex(/^([01]\d|2[0-3]):[0-5]\d$/, { message: "must use HH:MM" }),
|
|
94
|
-
weekdays: z.array(z.number().int().min(0).max(6)).min(1).max(7),
|
|
95
|
-
}),
|
|
96
|
-
z.object({
|
|
97
|
-
type: z.literal("interval"),
|
|
98
|
-
everyMinutes: z.number().int().min(5).max(1_440),
|
|
99
|
-
anchorAt: z.number().int().nonnegative().max(MAX_DATE_MS),
|
|
100
|
-
}),
|
|
101
|
-
]),
|
|
135
|
+
schedule: packageRoutineScheduleSchema,
|
|
102
136
|
durationMinutes: z.number().int().min(5).max(240),
|
|
103
137
|
timeoutMinutes: z.number().int().min(5).max(240).optional(),
|
|
104
138
|
enabledAfterInstall: z.literal(false),
|
|
@@ -200,6 +234,18 @@ export function parseBotPackage(value) {
|
|
|
200
234
|
return parsed.data;
|
|
201
235
|
}
|
|
202
236
|
const list = (values) => values.map((value) => `- ${value}`).join("\n");
|
|
237
|
+
const WEEKDAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
238
|
+
function intervalScheduleText(schedule) {
|
|
239
|
+
const restrictions = [
|
|
240
|
+
schedule.weekdays?.length
|
|
241
|
+
? `on ${schedule.weekdays.map((day) => WEEKDAY_NAMES[day]).join(", ")}`
|
|
242
|
+
: null,
|
|
243
|
+
schedule.window ? `during ${schedule.window.start}–${schedule.window.end}` : null,
|
|
244
|
+
schedule.endsAt !== undefined ? `until ${new Date(schedule.endsAt).toISOString()}` : null,
|
|
245
|
+
].filter((part) => part !== null);
|
|
246
|
+
const cadence = `every ${schedule.everyMinutes} minutes from ${new Date(schedule.anchorAt).toISOString()}`;
|
|
247
|
+
return restrictions.length ? `${cadence}; ${restrictions.join("; ")}` : cadence;
|
|
248
|
+
}
|
|
203
249
|
/** Render the public artifact. The frontmatter enables deterministic imports;
|
|
204
250
|
* the body is deliberately complete enough for any Chief-of-Staff agent to
|
|
205
251
|
* run without OpenMausBot or another proprietary parser. */
|
|
@@ -226,7 +272,7 @@ export function renderBotPackageMarkdown(document) {
|
|
|
226
272
|
`**Schedule:** ${routine.schedule.type === "daily"
|
|
227
273
|
? `${routine.schedule.time} on weekdays ${routine.schedule.weekdays.join(", ")}`
|
|
228
274
|
: routine.schedule.type === "interval"
|
|
229
|
-
?
|
|
275
|
+
? intervalScheduleText(routine.schedule)
|
|
230
276
|
: `once at ${routine.schedule.at}`} `,
|
|
231
277
|
`**Run limit:** ${routine.timeoutMinutes === undefined ? "none" : `${routine.timeoutMinutes} minutes`} `,
|
|
232
278
|
"**Initial state:** paused — the user must enable it",
|
|
@@ -5,6 +5,7 @@ import { readFileSync, mkdirSync, existsSync, renameSync } from "node:fs";
|
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { z } from "zod";
|
|
8
|
+
import { normalizeImageGenerationUrl } from "../shared/image-generation.js";
|
|
8
9
|
import { writeFileAtomic } from "./atomic.js";
|
|
9
10
|
import { EFFORT_LEVELS } from "./contracts.js";
|
|
10
11
|
import { parseStoredMcpServer } from "./mcp-registry.js";
|
|
@@ -247,8 +248,24 @@ const appConfigSchema = z.object({
|
|
|
247
248
|
* engine: "elevenlabs" (default; needs a key) or "system" (the Mac's
|
|
248
249
|
* built-in voices, no key). */
|
|
249
250
|
tts: z.object({ key: optionalText, voice: optionalText, provider: z.enum(["elevenlabs", "system"]).optional() }).optional(),
|
|
250
|
-
/**
|
|
251
|
-
imageGen: z.object({
|
|
251
|
+
/** Avatar provider credentials stay separate; choosing a router never reuses a cloud key. */
|
|
252
|
+
imageGen: z.object({
|
|
253
|
+
provider: z.enum(["openai", "xai", "custom"]).optional(),
|
|
254
|
+
key: optionalText,
|
|
255
|
+
customApiKey: optionalText,
|
|
256
|
+
customUrl: z.string().trim().max(2048).transform((value, ctx) => {
|
|
257
|
+
if (!value)
|
|
258
|
+
return "";
|
|
259
|
+
try {
|
|
260
|
+
return normalizeImageGenerationUrl(value);
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
ctx.addIssue({ code: "custom", message: error.message });
|
|
264
|
+
return z.NEVER;
|
|
265
|
+
}
|
|
266
|
+
}).optional(),
|
|
267
|
+
customModel: z.string().trim().max(200).refine((value) => !["\r", "\n", "\0"].some((character) => value.includes(character)), "Use a model ID without control characters").optional(),
|
|
268
|
+
}).optional(),
|
|
252
269
|
/** Non-secret profile details shown in the sidebar. */
|
|
253
270
|
profile: z.object({ name: optionalText, email: optionalText }).optional(),
|
|
254
271
|
/** UI language override (BCP-47, lowercase). Empty/absent = follow the
|
|
@@ -433,6 +450,8 @@ export function loadConfig() {
|
|
|
433
450
|
cfg.imageGen = { ...cfg.imageGen };
|
|
434
451
|
if (process.env.OMB_OPENAI_IMAGE_KEY !== undefined)
|
|
435
452
|
cfg.imageGen.key = process.env.OMB_OPENAI_IMAGE_KEY;
|
|
453
|
+
if (process.env.OMB_CUSTOM_IMAGE_KEY !== undefined)
|
|
454
|
+
cfg.imageGen.customApiKey = process.env.OMB_CUSTOM_IMAGE_KEY;
|
|
436
455
|
// The sign-in allow-list: env is how a headless box or a container is
|
|
437
456
|
// bootstrapped before anyone can reach Settings.
|
|
438
457
|
const splitEmails = (value) => value.split(/[,\s]+/).map((entry) => entry.trim().toLowerCase()).filter(Boolean);
|
|
@@ -461,6 +480,7 @@ export function syncCredentialEnv(patch) {
|
|
|
461
480
|
[patch.opencodeGo?.apiKey, "OPENCODE_API_KEY"],
|
|
462
481
|
[patch.tts?.key, "OMB_TTS_KEY"],
|
|
463
482
|
[patch.imageGen?.key, "OMB_OPENAI_IMAGE_KEY"],
|
|
483
|
+
[patch.imageGen?.customApiKey, "OMB_CUSTOM_IMAGE_KEY"],
|
|
464
484
|
];
|
|
465
485
|
for (const [value, name] of secrets) {
|
|
466
486
|
if (value === undefined)
|
|
@@ -499,6 +519,7 @@ export const WORKSPACE_CREDENTIAL_ENV = [
|
|
|
499
519
|
"OPENCODE_API_KEY",
|
|
500
520
|
"OMB_TTS_KEY",
|
|
501
521
|
"OMB_OPENAI_IMAGE_KEY",
|
|
522
|
+
"OMB_CUSTOM_IMAGE_KEY",
|
|
502
523
|
"COMPOSIO_API_KEY",
|
|
503
524
|
"OMB_COMPOSIO_BROKER_TOKEN",
|
|
504
525
|
// Harness-private filesystem hints are not credentials themselves, but
|
|
@@ -2,13 +2,109 @@
|
|
|
2
2
|
// the official pane has no Qwen Cloud catalog; live local hosts land in
|
|
3
3
|
// Custom and are written into ~/.qwen/settings.json modelProviders.
|
|
4
4
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
7
|
import { join } from "node:path";
|
|
7
8
|
import { decodeInjectId, hostApiKey, localHost, mergeLocalInject } from "../local-inject.js";
|
|
8
9
|
import { createAcpDriver } from "./core.js";
|
|
9
10
|
const EMPTY = { default: "", options: [] };
|
|
10
11
|
function qwenHome(env) {
|
|
11
|
-
|
|
12
|
+
const home = process.platform === "win32"
|
|
13
|
+
? env.USERPROFILE || env.HOME || homedir()
|
|
14
|
+
: env.HOME || env.USERPROFILE || homedir();
|
|
15
|
+
return join(home, ".qwen");
|
|
16
|
+
}
|
|
17
|
+
function isRecord(value) {
|
|
18
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
function qwenModelLabel(id, name) {
|
|
21
|
+
if (!name || name.toLocaleLowerCase().endsWith(id.toLocaleLowerCase()))
|
|
22
|
+
return id;
|
|
23
|
+
return `${id} — ${name}`;
|
|
24
|
+
}
|
|
25
|
+
const PROTOCOLS = new Set(["openai", "anthropic", "gemini", "vertex-ai"]);
|
|
26
|
+
function readQwenRoutes(env) {
|
|
27
|
+
let settings;
|
|
28
|
+
try {
|
|
29
|
+
settings = JSON.parse(readFileSync(join(qwenHome(env), "settings.json"), "utf8"));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
if (!isRecord(settings) || !isRecord(settings.modelProviders))
|
|
35
|
+
return [];
|
|
36
|
+
const routes = [];
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
const mappings = isRecord(settings.providerProtocol) ? settings.providerProtocol : {};
|
|
39
|
+
for (const [provider, rows] of Object.entries(settings.modelProviders)) {
|
|
40
|
+
const protocol = Object.hasOwn(mappings, provider) ? mappings[provider] : provider;
|
|
41
|
+
if (typeof protocol !== "string" || !PROTOCOLS.has(protocol) || !Array.isArray(rows))
|
|
42
|
+
continue;
|
|
43
|
+
for (const row of rows) {
|
|
44
|
+
if (!isRecord(row) || typeof row.id !== "string")
|
|
45
|
+
continue;
|
|
46
|
+
const model = row.id;
|
|
47
|
+
if (!model.trim())
|
|
48
|
+
continue;
|
|
49
|
+
if (row.baseUrl !== undefined && typeof row.baseUrl !== "string")
|
|
50
|
+
continue;
|
|
51
|
+
const baseUrl = typeof row.baseUrl === "string" && row.baseUrl ? row.baseUrl : undefined;
|
|
52
|
+
if (baseUrl) {
|
|
53
|
+
try {
|
|
54
|
+
new URL(baseUrl);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new Error("A Qwen model has an invalid endpoint. Fix its baseUrl in Qwen settings and refresh models.");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const identity = JSON.stringify([protocol, model, baseUrl ?? null]);
|
|
61
|
+
if (seen.has(identity))
|
|
62
|
+
continue;
|
|
63
|
+
seen.add(identity);
|
|
64
|
+
if (row.imageOnly === true || row.voiceOnly === true || row.fastOnly === true)
|
|
65
|
+
continue;
|
|
66
|
+
routes.push({ id: `${model}(${protocol})`, model, provider, protocol, baseUrl,
|
|
67
|
+
label: typeof row.name === "string" && row.name ? row.name : model,
|
|
68
|
+
envKey: typeof row.envKey === "string" ? row.envKey : undefined });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Qwen's ACP route identity, from QwenLM/qwen-code acpModelUtils.ts
|
|
72
|
+
// (Copyright 2025 Qwen Team, Apache-2.0). Never publish endpoints or env keys.
|
|
73
|
+
const counts = new Map();
|
|
74
|
+
for (const route of routes)
|
|
75
|
+
counts.set(route.id, (counts.get(route.id) ?? 0) + 1);
|
|
76
|
+
for (const route of routes) {
|
|
77
|
+
if (counts.get(route.id) === 1)
|
|
78
|
+
continue;
|
|
79
|
+
const endpoint = route.baseUrl ?? (route.protocol === "openai" ? "https://api.openai.com/v1" : "");
|
|
80
|
+
let publicEndpoint = null;
|
|
81
|
+
if (endpoint) {
|
|
82
|
+
try {
|
|
83
|
+
const url = new URL(endpoint);
|
|
84
|
+
url.username = "";
|
|
85
|
+
url.password = "";
|
|
86
|
+
url.search = "";
|
|
87
|
+
url.hash = "";
|
|
88
|
+
publicEndpoint = url.href;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw new Error("A Qwen model has an invalid endpoint. Fix its baseUrl in Qwen settings and refresh models.");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const identity = [route.id, route.label, route.envKey ?? null, route.baseUrl === undefined, publicEndpoint];
|
|
95
|
+
route.id = `qwen-route:v1:${createHash("sha256").update(JSON.stringify(identity)).digest("base64url").slice(0, 16)}`;
|
|
96
|
+
}
|
|
97
|
+
if (new Set(routes.map((route) => route.id)).size !== routes.length) {
|
|
98
|
+
throw new Error("Qwen has indistinguishable model routes. Give them distinct names, envKey values, or public endpoints in Qwen settings.");
|
|
99
|
+
}
|
|
100
|
+
return routes;
|
|
101
|
+
}
|
|
102
|
+
/** Public metadata only. Use Qwen's provider-qualified ACP selectors, not raw model IDs. */
|
|
103
|
+
export function readQwenModelCatalog(env = process.env) {
|
|
104
|
+
const options = readQwenRoutes(env).map(({ id, model, label, provider }) => ({
|
|
105
|
+
id, label: qwenModelLabel(model, label), custom: true, provider,
|
|
106
|
+
}));
|
|
107
|
+
return { default: options[0]?.id ?? "", options };
|
|
12
108
|
}
|
|
13
109
|
function envKeyFor(hostId) {
|
|
14
110
|
return `OPENMAUSBOT_QWEN_${hostId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
|
|
@@ -68,8 +164,46 @@ export function ensureQwenInjectModel(modelId, env = process.env) {
|
|
|
68
164
|
return inject.model;
|
|
69
165
|
}
|
|
70
166
|
async function resolveModels(env) {
|
|
71
|
-
const catalog = await mergeLocalInject(
|
|
72
|
-
|
|
167
|
+
const catalog = await mergeLocalInject(readQwenModelCatalog(env), env);
|
|
168
|
+
const routes = readQwenRoutes(env);
|
|
169
|
+
const replacements = new Map();
|
|
170
|
+
for (const option of catalog.options) {
|
|
171
|
+
const injected = decodeInjectId(option.id);
|
|
172
|
+
if (!injected)
|
|
173
|
+
continue;
|
|
174
|
+
const endpoint = localHost(injected.host)?.baseUrl;
|
|
175
|
+
for (const route of routes) {
|
|
176
|
+
if (route.protocol === "openai" && route.model === injected.model && route.baseUrl === endpoint) {
|
|
177
|
+
replacements.set(route.id, option.id);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const options = catalog.options.filter((option) => !replacements.has(option.id));
|
|
182
|
+
const preferred = replacements.get(catalog.default) ?? catalog.default;
|
|
183
|
+
return { default: options.some((option) => option.id === preferred) ? preferred : options[0]?.id ?? "", options };
|
|
184
|
+
}
|
|
185
|
+
export function resolveQwenTurnModel(model, env) {
|
|
186
|
+
if (!model)
|
|
187
|
+
return model;
|
|
188
|
+
const injected = decodeInjectId(model);
|
|
189
|
+
if (injected)
|
|
190
|
+
ensureQwenInjectModel(model, env);
|
|
191
|
+
const routes = readQwenRoutes(env);
|
|
192
|
+
if (injected) {
|
|
193
|
+
const route = routes.find((row) => row.protocol === "openai" && row.model === injected.model
|
|
194
|
+
&& row.baseUrl === localHost(injected.host)?.baseUrl);
|
|
195
|
+
if (!route)
|
|
196
|
+
throw new Error("Qwen could not configure this local model. Refresh models and select it again.");
|
|
197
|
+
return route.id;
|
|
198
|
+
}
|
|
199
|
+
if (routes.some((route) => route.id === model))
|
|
200
|
+
return model;
|
|
201
|
+
const matches = routes.filter((route) => route.model === model);
|
|
202
|
+
if (matches.length > 1)
|
|
203
|
+
throw new Error("This Qwen model has multiple providers. Refresh models and select the intended provider again.");
|
|
204
|
+
if (!matches.length)
|
|
205
|
+
throw new Error("This Qwen model is no longer configured. Refresh models and select it again.");
|
|
206
|
+
return matches[0].id;
|
|
73
207
|
}
|
|
74
208
|
const support = {
|
|
75
209
|
driverKind: "qwenAgent",
|
|
@@ -77,7 +211,7 @@ const support = {
|
|
|
77
211
|
access: "custom",
|
|
78
212
|
models: EMPTY,
|
|
79
213
|
resolveModels,
|
|
80
|
-
resolveTurnModel:
|
|
214
|
+
resolveTurnModel: resolveQwenTurnModel,
|
|
81
215
|
defaultCli: "qwen",
|
|
82
216
|
nativeSource: "qwen.acp",
|
|
83
217
|
loginNote: "Qwen Code CLI is not installed",
|
|
@@ -89,7 +223,10 @@ const support = {
|
|
|
89
223
|
},
|
|
90
224
|
docsUrl: "https://qwenlm.github.io/qwen-code-docs/en/users/overview/",
|
|
91
225
|
},
|
|
92
|
-
|
|
226
|
+
// A raw -m only changes the model within the saved provider. ACP switches
|
|
227
|
+
// the complete route and confirms it before any prompt leaves OMB.
|
|
228
|
+
spawnArgs: () => ["--acp"],
|
|
229
|
+
selectModel: { configId: "model" },
|
|
93
230
|
pickAuthMethod: () => null,
|
|
94
231
|
authFailure: "continue",
|
|
95
232
|
isAuthenticated: () => true,
|