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.
@@ -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
- const { done, value } = await reader.read();
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(apiKey, bot, direction, fetchImpl = fetch, timeoutMs = AVATAR_IMAGE_TIMEOUT_MS) {
74
- if (!apiKey.trim())
75
- throw Object.assign(new Error("Add an OpenAI image API key first"), { status: 409 });
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("https://api.openai.com/v1/images/generations", {
136
+ response = await fetchImpl(url, {
80
137
  method: "POST",
81
- headers: {
82
- authorization: `Bearer ${apiKey.trim()}`,
83
- "content-type": "application/json",
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" : "Could not reach OpenAI image generation"), { status: 502 });
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
- let message = `OpenAI image generation failed (HTTP ${response.status})`;
114
- try {
115
- const parsed = z.object({ error: z.object({ message: z.string() }) }).safeParse(JSON.parse(text));
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("OpenAI returned an invalid image response"), { status: 502 });
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("OpenAI returned no generated image"), { status: 502 });
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("OpenAI returned invalid image data"), { status: 502 });
185
+ throw Object.assign(new Error(`${providerName} returned invalid image data`), { status: 502 });
138
186
  }
139
- const bytes = Buffer.from(encoded, "base64");
140
- if (bytes.byteLength === 0) {
141
- throw Object.assign(new Error("OpenAI returned an empty image"), { status: 502 });
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: z.discriminatedUnion("type", [
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
- ? `every ${routine.schedule.everyMinutes} minutes from ${new Date(routine.schedule.anchorAt).toISOString()}`
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
- /** OpenAI key used only by the in-process avatar image generator. */
251
- imageGen: z.object({ key: optionalText }).optional(),
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
- return join(env.HOME || env.USERPROFILE || homedir(), ".qwen");
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(EMPTY, env);
72
- return { default: catalog.options[0]?.id ?? "", options: catalog.options };
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: (model, env) => (model ? ensureQwenInjectModel(model, env) : model),
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
- spawnArgs: (_config, turn) => ["--acp", ...(turn.model ? ["-m", turn.model] : [])],
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,