proxitor 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -197,6 +197,10 @@ Common flags: `--port`, `--host`, `--config <path>`, `--openrouter-key <key>` /
197
197
 
198
198
  **Anthropic returns `400` about mixed TTLs when `cacheControlTtl: 1h`.** Set `rewriteBlockTtl: auto` (or `always`) to normalize the client's block-level `cache_control` breakpoints to the same TTL — see the [configuration reference](./docs/configuration.md#prompt-caching).
199
199
 
200
+ **OpenRouter returns `400 invalid_prompt | Invalid Responses API request` on `/v1/responses`.** Some clients send Responses `input` items without the `type` field OpenRouter requires. `normalizeResponses: true` (the default; off only for raw passthrough) tags them, lifts `role:"system"` into `instructions`, and adds the `id`/`status` OpenRouter wants on assistant history. It acts on `/v1/responses` only.
201
+
202
+ **Strict providers reject `role:"system"` inside `/v1/messages`.** Some clients (e.g. an injected `SessionStart` hook payload) place a `role:"system"` item mid-thread in `messages`; the Anthropic Messages API allows only `user`/`assistant` there, so providers like OpenRouter → GLM return `400 ... messages[n].role: Input should be 'user' or 'assistant'`. Enable `normalizeMessages: true` (off by default; Fixes menu or per-model override) to lift each such item's text into the top-level `system` field and drop it from `messages`. It acts on `/v1/messages` only.
203
+
200
204
  **The provider keeps switching between requests.** Make sure `sessionId` is not `skip` — both `auto` (default) and `always` inject a sticky session ID; without it OpenRouter only pins after the first cache hit.
201
205
 
202
206
  **Config edits don't take effect.** They should — proxitor hot-reloads on save. If the file is invalid the proxy keeps the last valid config; `proxitor config validate` shows what was rejected.
package/dist/cli.mjs CHANGED
@@ -14,7 +14,7 @@ import { STATUS_CODES, createServer as createServer$1 } from "node:http";
14
14
  import { Http2ServerRequest, constants } from "node:http2";
15
15
  import { Readable } from "node:stream";
16
16
  import { readFile, writeFile } from "node:fs/promises";
17
- import { createHash } from "node:crypto";
17
+ import { createHash, randomUUID } from "node:crypto";
18
18
  //#region \0rolldown/runtime.js
19
19
  var __defProp = Object.defineProperty;
20
20
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -12217,7 +12217,7 @@ const allowsEval = /* @__PURE__*/ cached(() => {
12217
12217
  return false;
12218
12218
  }
12219
12219
  });
12220
- function isPlainObject$2(o) {
12220
+ function isPlainObject$3(o) {
12221
12221
  if (isObject(o) === false) return false;
12222
12222
  const ctor = o.constructor;
12223
12223
  if (ctor === void 0) return true;
@@ -12228,7 +12228,7 @@ function isPlainObject$2(o) {
12228
12228
  return true;
12229
12229
  }
12230
12230
  function shallowClone(o) {
12231
- if (isPlainObject$2(o)) return { ...o };
12231
+ if (isPlainObject$3(o)) return { ...o };
12232
12232
  if (Array.isArray(o)) return [...o];
12233
12233
  if (o instanceof Map) return new Map(o);
12234
12234
  if (o instanceof Set) return new Set(o);
@@ -12311,7 +12311,7 @@ function omit(schema, mask) {
12311
12311
  }));
12312
12312
  }
12313
12313
  function extend(schema, shape) {
12314
- if (!isPlainObject$2(shape)) throw new Error("Invalid input to extend: expected a plain object");
12314
+ if (!isPlainObject$3(shape)) throw new Error("Invalid input to extend: expected a plain object");
12315
12315
  const checks = schema._zod.def.checks;
12316
12316
  if (checks && checks.length > 0) {
12317
12317
  const existingShape = schema._zod.def.shape;
@@ -12327,7 +12327,7 @@ function extend(schema, shape) {
12327
12327
  } }));
12328
12328
  }
12329
12329
  function safeExtend(schema, shape) {
12330
- if (!isPlainObject$2(shape)) throw new Error("Invalid input to safeExtend: expected a plain object");
12330
+ if (!isPlainObject$3(shape)) throw new Error("Invalid input to safeExtend: expected a plain object");
12331
12331
  return clone(schema, mergeDefs(schema._zod.def, { get shape() {
12332
12332
  const _shape = {
12333
12333
  ...schema._zod.def.shape,
@@ -13862,7 +13862,7 @@ function mergeValues(a, b) {
13862
13862
  valid: true,
13863
13863
  data: a
13864
13864
  };
13865
- if (isPlainObject$2(a) && isPlainObject$2(b)) {
13865
+ if (isPlainObject$3(a) && isPlainObject$3(b)) {
13866
13866
  const bKeys = Object.keys(b);
13867
13867
  const sharedKeys = Object.keys(a).filter((key) => bKeys.indexOf(key) !== -1);
13868
13868
  const newObj = {
@@ -13938,7 +13938,7 @@ const $ZodRecord = /*@__PURE__*/ $constructor("$ZodRecord", (inst, def) => {
13938
13938
  $ZodType.init(inst, def);
13939
13939
  inst._zod.parse = (payload, ctx) => {
13940
13940
  const input = payload.value;
13941
- if (!isPlainObject$2(input)) {
13941
+ if (!isPlainObject$3(input)) {
13942
13942
  payload.issues.push({
13943
13943
  expected: "record",
13944
13944
  code: "invalid_type",
@@ -16293,6 +16293,8 @@ const modelOverrideSchema = object({
16293
16293
  cacheControlTtl: ttlSchema.optional(),
16294
16294
  rewriteBlockTtl: triStateSchema.optional(),
16295
16295
  sessionId: triStateSchema.optional(),
16296
+ normalizeResponses: boolean().optional(),
16297
+ normalizeMessages: boolean().optional(),
16296
16298
  normalizeVolatileSystem: boolean().optional()
16297
16299
  }).strict();
16298
16300
  const proxyConfigSchema = object({
@@ -16312,6 +16314,8 @@ const proxyConfigSchema = object({
16312
16314
  cacheControlTtl: ttlSchema.optional(),
16313
16315
  rewriteBlockTtl: triStateSchema.default("skip"),
16314
16316
  sessionId: triStateSchema.default("auto"),
16317
+ normalizeResponses: boolean().default(true),
16318
+ normalizeMessages: boolean().default(false),
16315
16319
  normalizeVolatileSystem: boolean().default(false),
16316
16320
  observability: observabilityConfigSchema,
16317
16321
  modelOverrides: record(string$1().min(1), modelOverrideSchema).optional()
@@ -16488,6 +16492,8 @@ function resolveModelConfig(config, modelName) {
16488
16492
  cacheControlTtl: config.cacheControlTtl,
16489
16493
  rewriteBlockTtl: config.rewriteBlockTtl,
16490
16494
  sessionId: config.sessionId,
16495
+ normalizeResponses: config.normalizeResponses,
16496
+ normalizeMessages: config.normalizeMessages,
16491
16497
  normalizeVolatileSystem: config.normalizeVolatileSystem
16492
16498
  };
16493
16499
  if (!modelName || !config.modelOverrides) return result;
@@ -16521,6 +16527,8 @@ function applyOverride(result, override) {
16521
16527
  if (override.cacheControlTtl !== void 0) result.cacheControlTtl = override.cacheControlTtl;
16522
16528
  if (override.rewriteBlockTtl !== void 0) result.rewriteBlockTtl = override.rewriteBlockTtl;
16523
16529
  if (override.sessionId !== void 0) result.sessionId = override.sessionId;
16530
+ if (override.normalizeResponses !== void 0) result.normalizeResponses = override.normalizeResponses;
16531
+ if (override.normalizeMessages !== void 0) result.normalizeMessages = override.normalizeMessages;
16524
16532
  if (override.normalizeVolatileSystem !== void 0) result.normalizeVolatileSystem = override.normalizeVolatileSystem;
16525
16533
  }
16526
16534
  /** Reject base URLs ending in /v1 — paths are forwarded as-is, so /v1 suffix causes doubled paths. */
@@ -16796,7 +16804,7 @@ const LogTypes = {
16796
16804
  trace: { level: LogLevels.trace },
16797
16805
  verbose: { level: LogLevels.verbose }
16798
16806
  };
16799
- function isPlainObject$1(value) {
16807
+ function isPlainObject$1$1(value) {
16800
16808
  if (value === null || typeof value !== "object") return false;
16801
16809
  const prototype = Object.getPrototypeOf(value);
16802
16810
  if (prototype !== null && prototype !== Object.prototype && Object.getPrototypeOf(prototype) !== null) return false;
@@ -16805,7 +16813,7 @@ function isPlainObject$1(value) {
16805
16813
  return true;
16806
16814
  }
16807
16815
  function _defu(baseObject, defaults, namespace = ".", merger) {
16808
- if (!isPlainObject$1(defaults)) return _defu(baseObject, {}, namespace, merger);
16816
+ if (!isPlainObject$1$1(defaults)) return _defu(baseObject, {}, namespace, merger);
16809
16817
  const object = Object.assign({}, defaults);
16810
16818
  for (const key in baseObject) {
16811
16819
  if (key === "__proto__" || key === "constructor") continue;
@@ -16813,7 +16821,7 @@ function _defu(baseObject, defaults, namespace = ".", merger) {
16813
16821
  if (value === null || value === void 0) continue;
16814
16822
  if (merger && merger(object, key, value, namespace)) continue;
16815
16823
  if (Array.isArray(value) && Array.isArray(object[key])) object[key] = [...value, ...object[key]];
16816
- else if (isPlainObject$1(value) && isPlainObject$1(object[key])) object[key] = _defu(value, object[key], (namespace ? `${namespace}.` : "") + key.toString(), merger);
16824
+ else if (isPlainObject$1$1(value) && isPlainObject$1$1(object[key])) object[key] = _defu(value, object[key], (namespace ? `${namespace}.` : "") + key.toString(), merger);
16817
16825
  else object[key] = value;
16818
16826
  }
16819
16827
  return object;
@@ -16822,11 +16830,11 @@ function createDefu(merger) {
16822
16830
  return (...arguments_) => arguments_.reduce((p, c) => _defu(p, c, "", merger), {});
16823
16831
  }
16824
16832
  const defu = createDefu();
16825
- function isPlainObject(obj) {
16833
+ function isPlainObject$2(obj) {
16826
16834
  return Object.prototype.toString.call(obj) === "[object Object]";
16827
16835
  }
16828
16836
  function isLogObj(arg) {
16829
- if (!isPlainObject(arg)) return false;
16837
+ if (!isPlainObject$2(arg)) return false;
16830
16838
  if (!arg.message && !arg.args) return false;
16831
16839
  if (arg.stack) return false;
16832
16840
  return true;
@@ -18119,6 +18127,58 @@ const REWRITE_HINTS = {
18119
18127
  always: "All models",
18120
18128
  skip: "Leave client block ttl as-is (may mismatch root)"
18121
18129
  };
18130
+ /** Hint texts for the normalize-responses on/off toggle — repairs /v1/responses bodies for OpenRouter. */
18131
+ const NORMALIZE_RESPONSES_HINTS = {
18132
+ on: "Repair /v1/responses bodies (type tagging, system→instructions, id/status)",
18133
+ off: "Raw passthrough (OpenRouter may reject malformed /v1/responses)"
18134
+ };
18135
+ async function askNormalizeResponses(message, current, opts) {
18136
+ const options = [{
18137
+ value: true,
18138
+ label: "On",
18139
+ hint: NORMALIZE_RESPONSES_HINTS.on
18140
+ }, {
18141
+ value: false,
18142
+ label: "Off",
18143
+ hint: NORMALIZE_RESPONSES_HINTS.off
18144
+ }];
18145
+ if (opts?.removable) options.push({
18146
+ value: "reset",
18147
+ label: "Reset / inherit",
18148
+ hint: opts.resetHint ?? "Remove override"
18149
+ });
18150
+ return await select({
18151
+ message,
18152
+ initialValue: current ?? (opts?.removable ? "reset" : false),
18153
+ options
18154
+ });
18155
+ }
18156
+ /** Hint texts for the normalize-messages on/off toggle — lifts stray role:"system" out of /v1/messages. */
18157
+ const NORMALIZE_MESSAGES_HINTS = {
18158
+ on: "Lift role:system → top-level system (/v1/messages only)",
18159
+ off: "Raw passthrough (strict providers may reject role:system)"
18160
+ };
18161
+ async function askNormalizeMessages(message, current, opts) {
18162
+ const options = [{
18163
+ value: true,
18164
+ label: "On",
18165
+ hint: NORMALIZE_MESSAGES_HINTS.on
18166
+ }, {
18167
+ value: false,
18168
+ label: "Off",
18169
+ hint: NORMALIZE_MESSAGES_HINTS.off
18170
+ }];
18171
+ if (opts?.removable) options.push({
18172
+ value: "reset",
18173
+ label: "Reset / inherit",
18174
+ hint: opts.resetHint ?? "Remove override"
18175
+ });
18176
+ return await select({
18177
+ message,
18178
+ initialValue: current ?? (opts?.removable ? "reset" : false),
18179
+ options
18180
+ });
18181
+ }
18122
18182
  const NORMALIZE_HINTS = {
18123
18183
  on: "Rewrite cch → stable prefix cache",
18124
18184
  off: "Passthrough — rewrite nothing"
@@ -18223,6 +18283,18 @@ async function collectSessionTriState(currentSid) {
18223
18283
  if (sid === "reset") return { sessionId: { remove: true } };
18224
18284
  return { sessionId: { value: sid } };
18225
18285
  }
18286
+ async function collectNormalizeResponses(currentNr) {
18287
+ const nr = await askNormalizeResponses("Repair /v1/responses bodies", currentNr, { removable: true });
18288
+ if (typeof nr === "symbol") return null;
18289
+ if (nr === "reset") return { normalizeResponses: { remove: true } };
18290
+ return { normalizeResponses: { value: nr } };
18291
+ }
18292
+ async function collectNormalizeMessages(currentNm) {
18293
+ const nm = await askNormalizeMessages("Lift role:system out of /v1/messages", currentNm, { removable: true });
18294
+ if (typeof nm === "symbol") return null;
18295
+ if (nm === "reset") return { normalizeMessages: { remove: true } };
18296
+ return { normalizeMessages: { value: nm } };
18297
+ }
18226
18298
  /**
18227
18299
  * Collects cacheControl mode, TTL, and rewriteBlockTtl.
18228
18300
  * Mode-cancel aborts; TTL-cancel keeps existing TTL; rewrite-cancel keeps existing rewrite.
@@ -18670,6 +18742,20 @@ async function editSessionId(current) {
18670
18742
  applyField(next, "sessionId", result.sessionId);
18671
18743
  return next;
18672
18744
  }
18745
+ async function editNormalizeResponses(current) {
18746
+ const result = await collectNormalizeResponses(current.normalizeResponses);
18747
+ if (result === null) return current;
18748
+ const next = { ...current };
18749
+ applyField(next, "normalizeResponses", result.normalizeResponses);
18750
+ return next;
18751
+ }
18752
+ async function editNormalizeMessages(current) {
18753
+ const result = await collectNormalizeMessages(current.normalizeMessages);
18754
+ if (result === null) return current;
18755
+ const next = { ...current };
18756
+ applyField(next, "normalizeMessages", result.normalizeMessages);
18757
+ return next;
18758
+ }
18673
18759
  async function editCacheControl(current, configPath) {
18674
18760
  const globalTtl = readGlobalTtl(configPath);
18675
18761
  const result = await collectCacheTriState(current.cacheControl, current.cacheControlTtl, globalTtl, current.rewriteBlockTtl);
@@ -18777,8 +18863,110 @@ async function cachingCommand(opts) {
18777
18863
  outro("Bye!");
18778
18864
  }
18779
18865
  //#endregion
18866
+ //#region src/commands/config/normalize-messages.ts
18867
+ async function normalizeMessagesCommand(opts) {
18868
+ const configPath = requireConfigPath(opts?.configPath);
18869
+ const raw = readConfigFileRaw(configPath).normalizeMessages;
18870
+ const effective = raw ?? DEFAULTS.normalizeMessages;
18871
+ log.info(`Current: normalizeMessages = ${raw === void 0 ? `(default -> ${effective ? "on" : "off"})` : effective}`);
18872
+ const choice = await askNormalizeMessages("Lift stray role:\"system\" out of /v1/messages into top-level system? Fixes 400 rejections from strict Anthropic-format providers (OpenRouter → GLM et al.). Acts on /v1/messages only.", raw, {
18873
+ removable: true,
18874
+ resetHint: `remove (default: ${DEFAULTS.normalizeMessages ? "on" : "off"})`
18875
+ });
18876
+ if (typeof choice === "symbol") return;
18877
+ const fields = {};
18878
+ fields.normalizeMessages = choice === "reset" ? void 0 : choice;
18879
+ setGlobalConfigFields(configPath, fields);
18880
+ const label = choice === "reset" ? `(default: ${DEFAULTS.normalizeMessages})` : choice;
18881
+ log.success(`normalizeMessages set to ${label}`);
18882
+ }
18883
+ //#endregion
18884
+ //#region src/commands/config/normalize-responses.ts
18885
+ async function normalizeResponsesCommand(opts) {
18886
+ const configPath = requireConfigPath(opts?.configPath);
18887
+ const raw = readConfigFileRaw(configPath).normalizeResponses;
18888
+ const effective = raw ?? DEFAULTS.normalizeResponses;
18889
+ log.info(`Current: normalizeResponses = ${raw === void 0 ? `(default -> ${effective ? "on" : "off"})` : effective}`);
18890
+ const choice = await askNormalizeResponses("Repair /v1/responses request bodies for OpenRouter (tag input types, lift role:\"system\" into instructions, synthesize assistant id/status)? Acts on /v1/responses only.", raw, {
18891
+ removable: true,
18892
+ resetHint: `remove (default: ${DEFAULTS.normalizeResponses ? "on" : "off"})`
18893
+ });
18894
+ if (typeof choice === "symbol") return;
18895
+ const fields = {};
18896
+ fields.normalizeResponses = choice === "reset" ? void 0 : choice;
18897
+ setGlobalConfigFields(configPath, fields);
18898
+ const label = choice === "reset" ? `(default: ${DEFAULTS.normalizeResponses})` : choice;
18899
+ log.success(`normalizeResponses set to ${label}`);
18900
+ }
18901
+ //#endregion
18902
+ //#region src/commands/config/fixes-menu.ts
18903
+ /** Compatibility fixes for request bodies OpenRouter rejects. Mirrors caching-menu's lever table. */
18904
+ const FIXES_LEVERS = [{
18905
+ value: "normalizeResponses",
18906
+ label: "Repair /v1/responses bodies (normalizeResponses)",
18907
+ global: (configPath) => normalizeResponsesCommand({ configPath }),
18908
+ perModel: (current) => editNormalizeResponses(current)
18909
+ }, {
18910
+ value: "normalizeMessages",
18911
+ label: "Lift role:system out of /v1/messages (normalizeMessages)",
18912
+ global: (configPath) => normalizeMessagesCommand({ configPath }),
18913
+ perModel: (current) => editNormalizeMessages(current)
18914
+ }];
18915
+ async function runFixesLeverMenu(opts) {
18916
+ for (;;) {
18917
+ note(opts.renderNote(), opts.noteTitle);
18918
+ const choice = await select({
18919
+ message: "Tune which fix?",
18920
+ options: [...FIXES_LEVERS.map((lever) => ({
18921
+ value: lever.value,
18922
+ label: lever.label
18923
+ })), {
18924
+ value: "back",
18925
+ label: opts.backLabel
18926
+ }]
18927
+ });
18928
+ if (isCancel(choice) || choice === "back") return;
18929
+ const chosen = FIXES_LEVERS.find((l) => l.value === choice);
18930
+ if (chosen) await opts.onLever(chosen);
18931
+ }
18932
+ }
18933
+ async function globalFixesMenu(opts) {
18934
+ const configPath = requireConfigPath(opts?.configPath);
18935
+ await runFixesLeverMenu({
18936
+ noteTitle: "Fixes",
18937
+ backLabel: "← Back",
18938
+ renderNote: () => {
18939
+ const raw = readConfigFileRaw(configPath);
18940
+ return [`normalizeResponses: ${raw.normalizeResponses ?? "(default → on)"}`, `normalizeMessages: ${raw.normalizeMessages ?? "(default → off)"}`].join("\n");
18941
+ },
18942
+ onLever: (lever) => lever.global(configPath)
18943
+ });
18944
+ }
18945
+ /** Self-persists each lever; returns the latest override. */
18946
+ async function perModelFixesMenu(opts) {
18947
+ let current = opts.current;
18948
+ await runFixesLeverMenu({
18949
+ noteTitle: `Fixes for "${opts.modelKey}"`,
18950
+ backLabel: "← Back to override edit",
18951
+ renderNote: () => [`normalizeResponses: ${current.normalizeResponses ?? "(inherit)"}`, `normalizeMessages: ${current.normalizeMessages ?? "(inherit)"}`].join("\n"),
18952
+ onLever: async (lever) => {
18953
+ const next = await lever.perModel(current);
18954
+ if (!overridesEqual(next, current)) {
18955
+ setModelOverride(opts.configPath, opts.modelKey, next);
18956
+ current = next;
18957
+ }
18958
+ }
18959
+ });
18960
+ return current;
18961
+ }
18962
+ async function fixesCommand(opts) {
18963
+ intro("Proxitor · Fixes");
18964
+ await globalFixesMenu(opts);
18965
+ outro("Bye!");
18966
+ }
18967
+ //#endregion
18780
18968
  //#region src/commands/config/edit.ts
18781
- function nvsHint(value) {
18969
+ function toggleHint(value) {
18782
18970
  if (value === void 0) return "(inherit)";
18783
18971
  return value ? "on" : "off";
18784
18972
  }
@@ -18791,12 +18979,16 @@ function formatOverrideHint(override) {
18791
18979
  }
18792
18980
  if (override.sessionId) parts.push(`session: ${override.sessionId}`);
18793
18981
  if (override.cacheControl) parts.push(`cache: ${override.cacheControl}`);
18794
- if (override.normalizeVolatileSystem !== void 0) parts.push(`normalize: ${nvsHint(override.normalizeVolatileSystem)}`);
18982
+ if (override.normalizeVolatileSystem !== void 0) parts.push(`normalize: ${toggleHint(override.normalizeVolatileSystem)}`);
18983
+ if (override.normalizeResponses !== void 0) parts.push(`fix: ${toggleHint(override.normalizeResponses)}`);
18795
18984
  if (override.headers) parts.push(`${Object.keys(override.headers).length} header(s)`);
18796
18985
  return parts.join(", ") || "(empty)";
18797
18986
  }
18798
18987
  function formatCachingHint(current) {
18799
- return `cc ${current.cacheControl ?? "inherit"} · ttl ${current.cacheControlTtl ? describeTtl(current.cacheControlTtl) : "inherit"} · sid ${current.sessionId ?? "inherit"} · nvs ${nvsHint(current.normalizeVolatileSystem)}`;
18988
+ return `cc ${current.cacheControl ?? "inherit"} · ttl ${current.cacheControlTtl ? describeTtl(current.cacheControlTtl) : "inherit"} · sid ${current.sessionId ?? "inherit"} · nvs ${toggleHint(current.normalizeVolatileSystem)}`;
18989
+ }
18990
+ function formatFixesHint(current) {
18991
+ return `normalizeResponses: ${current.normalizeResponses ?? "inherit"}`;
18800
18992
  }
18801
18993
  async function editProvider(modelKey, current, client) {
18802
18994
  const isPattern = modelKey.includes("*");
@@ -18851,6 +19043,11 @@ async function editOverrideCommand(client, configPath) {
18851
19043
  label: "💾 Caching",
18852
19044
  hint: formatCachingHint(current)
18853
19045
  },
19046
+ {
19047
+ value: "fixes",
19048
+ label: "🛠 Fixes",
19049
+ hint: formatFixesHint(current)
19050
+ },
18854
19051
  {
18855
19052
  value: "done",
18856
19053
  label: "✓ Done"
@@ -18866,6 +19063,14 @@ async function editOverrideCommand(client, configPath) {
18866
19063
  });
18867
19064
  continue;
18868
19065
  }
19066
+ if (field === "fixes") {
19067
+ current = await perModelFixesMenu({
19068
+ modelKey,
19069
+ current,
19070
+ configPath: resolvedConfigPath
19071
+ });
19072
+ continue;
19073
+ }
18869
19074
  const before = current;
18870
19075
  current = await editProvider(modelKey, current, client);
18871
19076
  if (!overridesEqual(current, before)) setModelOverride(resolvedConfigPath, modelKey, current);
@@ -19493,6 +19698,10 @@ async function runConfigMenu(client) {
19493
19698
  value: "caching",
19494
19699
  label: "💾 Caching"
19495
19700
  },
19701
+ {
19702
+ value: "fixes",
19703
+ label: "🛠 Fixes"
19704
+ },
19496
19705
  {
19497
19706
  value: "_sep2",
19498
19707
  label: "── Model Overrides ──",
@@ -19547,6 +19756,9 @@ async function runConfigMenu(client) {
19547
19756
  case "caching":
19548
19757
  await globalCachingMenu();
19549
19758
  break;
19759
+ case "fixes":
19760
+ await globalFixesMenu();
19761
+ break;
19550
19762
  case "add":
19551
19763
  await addOverrideCommand({ client });
19552
19764
  break;
@@ -19570,7 +19782,7 @@ async function runConfigMenu(client) {
19570
19782
  }
19571
19783
  //#endregion
19572
19784
  //#region src/version.ts
19573
- const version = "0.17.0";
19785
+ const version = "0.19.0";
19574
19786
  //#endregion
19575
19787
  //#region src/commands/doctor.ts
19576
19788
  const DEFAULT_TIMEOUT_MS = 3e3;
@@ -23426,7 +23638,12 @@ function rewriteBlockTtls(body, ttl, isAnthropic) {
23426
23638
  visitBlocks(body.system);
23427
23639
  visitBlocks(body.tools);
23428
23640
  const messages = body.messages;
23429
- for (const m of messages ?? []) visitBlocks(m?.content);
23641
+ for (const m of messages ?? []) {
23642
+ if (m === null || typeof m !== "object" || Array.isArray(m)) continue;
23643
+ rewriteNode(m);
23644
+ visitBlocks(m.content);
23645
+ visitBlocks(m.tool_calls);
23646
+ }
23430
23647
  return mutated;
23431
23648
  }
23432
23649
  //#endregion
@@ -23527,6 +23744,188 @@ const injectSessionId = createMiddleware(async (c, next) => {
23527
23744
  await next();
23528
23745
  });
23529
23746
  //#endregion
23747
+ //#region src/proxy/utils/messages-system.ts
23748
+ /**
23749
+ * Whether to lift stray `role:"system"` items out of `messages`. The lift is
23750
+ * only valid on `/v1/messages` (where `system` is a top-level field) — never on
23751
+ * chat-completions (system belongs in `messages`) or responses — so it is gated
23752
+ * by endpoint regardless of the on/off setting.
23753
+ */
23754
+ function shouldNormalizeMessages(enabled, path) {
23755
+ return enabled && classifyEndpoint(path) === "messages";
23756
+ }
23757
+ function isPlainObject$1(value) {
23758
+ return value !== null && typeof value === "object" && !Array.isArray(value);
23759
+ }
23760
+ /** Flatten a message content value (string or block array) to plain text. */
23761
+ function contentToText$1(content) {
23762
+ if (typeof content === "string") return content;
23763
+ if (!Array.isArray(content)) return void 0;
23764
+ const parts = [];
23765
+ for (const block of content) {
23766
+ const text = isPlainObject$1(block) ? block.text : void 0;
23767
+ if (typeof text === "string") parts.push(text);
23768
+ }
23769
+ return parts.length > 0 ? parts.join("\n") : void 0;
23770
+ }
23771
+ /**
23772
+ * Append lifted system text to a top-level `system` value, preserving its form:
23773
+ * a block array stays an array (new `{type:"text"}` block), a string stays a
23774
+ * string, and a missing system starts as a plain string. A stray single-block
23775
+ * object (malformed but parseable) is wrapped as a one-element array so its
23776
+ * content is never silently discarded.
23777
+ */
23778
+ function appendSystemText(current, text) {
23779
+ if (Array.isArray(current)) return [...current, {
23780
+ type: "text",
23781
+ text
23782
+ }];
23783
+ if (typeof current === "string") return current.length > 0 ? `${current}\n\n${text}` : text;
23784
+ if (isPlainObject$1(current)) return [current, {
23785
+ type: "text",
23786
+ text
23787
+ }];
23788
+ return text;
23789
+ }
23790
+ /**
23791
+ * Lift every `role:"system"` item in `messages` into the top-level `system`
23792
+ * field. The Anthropic Messages API allows only `user`/`assistant` in
23793
+ * `messages` — a stray `role:"system"` (e.g. injected hook output like a
23794
+ * SessionStart payload) is rejected by strict Anthropic-format providers
23795
+ * (OpenRouter → GLM et al.) with a 400 on `messages[n].role`. System content
23796
+ * belongs in the top-level `system`, so its text is merged there and the item
23797
+ * is dropped from `messages`, which also preserves user/assistant alternation.
23798
+ * Items with no extractable text are dropped. Idempotent; returns whether the
23799
+ * body changed.
23800
+ */
23801
+ function liftSystemMessages(body) {
23802
+ const messages = body.messages;
23803
+ if (!Array.isArray(messages)) return false;
23804
+ let system = body.system;
23805
+ const next = [];
23806
+ let mutated = false;
23807
+ for (const raw of messages) {
23808
+ if (isPlainObject$1(raw) && raw.role === "system") {
23809
+ const text = contentToText$1(raw.content);
23810
+ if (text !== void 0) system = appendSystemText(system, text);
23811
+ mutated = true;
23812
+ continue;
23813
+ }
23814
+ next.push(raw);
23815
+ }
23816
+ if (mutated) {
23817
+ body.messages = next;
23818
+ if (system !== body.system) body.system = system;
23819
+ }
23820
+ return mutated;
23821
+ }
23822
+ //#endregion
23823
+ //#region src/proxy/middleware/normalize-messages.ts
23824
+ /** Lift stray role:"system" items from messages into top-level system (see liftSystemMessages); no-op for non-messages endpoints. */
23825
+ const normalizeMessagesMiddleware = createMiddleware(async (c, next) => {
23826
+ const parsedBody = c.var.parsedBody;
23827
+ if (parsedBody && shouldNormalizeMessages(c.var.resolvedConfig.normalizeMessages, c.req.path) && liftSystemMessages(parsedBody)) c.set("bodyMutated", true);
23828
+ await next();
23829
+ });
23830
+ //#endregion
23831
+ //#region src/proxy/utils/responses-input.ts
23832
+ /**
23833
+ * Whether to normalize a Responses-api body. The lift is only valid on
23834
+ * /v1/responses — never on messages or chat-completions — so it is gated by
23835
+ * endpoint regardless of the on/off setting.
23836
+ */
23837
+ function shouldNormalizeResponses(enabled, path) {
23838
+ return enabled && classifyEndpoint(path) === "responses";
23839
+ }
23840
+ function isPlainObject(value) {
23841
+ return value !== null && typeof value === "object" && !Array.isArray(value);
23842
+ }
23843
+ /** Flatten a Responses content value (string or block array) to plain text. */
23844
+ function contentToText(content) {
23845
+ if (typeof content === "string") return content;
23846
+ if (!Array.isArray(content)) return void 0;
23847
+ const parts = [];
23848
+ for (const block of content) {
23849
+ const text = isPlainObject(block) ? block.text : void 0;
23850
+ if (typeof text === "string") parts.push(text);
23851
+ }
23852
+ return parts.length > 0 ? parts.join("\n") : void 0;
23853
+ }
23854
+ function appendInstructions(current, text) {
23855
+ return typeof current === "string" ? `${current}\n\n${text}` : text;
23856
+ }
23857
+ /** Tag a role-bearing message item and complete assistant-history metadata. */
23858
+ function tagMessageItem(obj) {
23859
+ let changed = false;
23860
+ if (typeof obj.role === "string" && obj.type === void 0) {
23861
+ obj.type = "message";
23862
+ changed = true;
23863
+ }
23864
+ if (obj.type === "message" && obj.role === "assistant") {
23865
+ if (obj.id === void 0) {
23866
+ obj.id = `msg_${randomUUID().slice(0, 12)}`;
23867
+ changed = true;
23868
+ }
23869
+ if (obj.status === void 0) {
23870
+ obj.status = "completed";
23871
+ changed = true;
23872
+ }
23873
+ }
23874
+ return changed;
23875
+ }
23876
+ /**
23877
+ * Lift a `role: "system"` item: OpenRouter Responses has no system role in
23878
+ * `input`, so its text moves to `instructions`. Items with no extractable text
23879
+ * (image-only/empty) can't be represented there and are dropped — keeping them
23880
+ * as `role: "system"` would reproduce the rejection this normalizer exists to fix.
23881
+ */
23882
+ function handleSystemItem(obj, state) {
23883
+ const text = contentToText(obj.content);
23884
+ if (text !== void 0) state.instructions = appendInstructions(state.instructions, text);
23885
+ }
23886
+ /**
23887
+ * Make a Responses body satisfy OpenRouter's `input` schema. Each item is a
23888
+ * `type`-discriminated union, so items missing `type` (OpenAI infers "message")
23889
+ * are rejected as `invalid_prompt`. Also relocates `role: "system"` items to
23890
+ * `instructions` and synthesizes the `id`/`status` assistant history requires.
23891
+ * Idempotent; returns whether the body changed.
23892
+ */
23893
+ function normalizeResponsesInput(body) {
23894
+ const input = body.input;
23895
+ if (!Array.isArray(input)) return false;
23896
+ let mutated = false;
23897
+ const state = {
23898
+ instructions: body.instructions,
23899
+ next: []
23900
+ };
23901
+ for (const raw of input) {
23902
+ if (!isPlainObject(raw)) {
23903
+ state.next.push(raw);
23904
+ continue;
23905
+ }
23906
+ if (raw.role === "system") {
23907
+ handleSystemItem(raw, state);
23908
+ mutated = true;
23909
+ continue;
23910
+ }
23911
+ if (tagMessageItem(raw)) mutated = true;
23912
+ state.next.push(raw);
23913
+ }
23914
+ if (mutated) {
23915
+ body.input = state.next;
23916
+ if (state.instructions !== void 0) body.instructions = state.instructions;
23917
+ }
23918
+ return mutated;
23919
+ }
23920
+ //#endregion
23921
+ //#region src/proxy/middleware/normalize-responses-input.ts
23922
+ /** Normalize Responses-API input for OpenRouter's schema (see normalizeResponsesInput); no-op for chat/messages. */
23923
+ const normalizeResponsesInputMiddleware = createMiddleware(async (c, next) => {
23924
+ const parsedBody = c.var.parsedBody;
23925
+ if (parsedBody && shouldNormalizeResponses(c.var.resolvedConfig.normalizeResponses, c.req.path) && normalizeResponsesInput(parsedBody)) c.set("bodyMutated", true);
23926
+ await next();
23927
+ });
23928
+ //#endregion
23530
23929
  //#region src/proxy/middleware/normalize-volatile-system.ts
23531
23930
  /** Normalize Claude Code's per-request volatile hashes in system[0] to constants; they drift every turn and break prefix-cache for non-Anthropic providers:
23532
23931
  * - `cch=…` per-turn hash
@@ -23842,8 +24241,10 @@ const injectChain = [
23842
24241
  parseBody,
23843
24242
  resolveConfig,
23844
24243
  injectProvider,
24244
+ normalizeMessagesMiddleware,
23845
24245
  injectCacheControl,
23846
24246
  normalizeVolatileSystemMiddleware,
24247
+ normalizeResponsesInputMiddleware,
23847
24248
  injectSessionId
23848
24249
  ];
23849
24250
  function createProxyServer(source, onReady) {
@@ -24075,6 +24476,14 @@ const configCli = (0, import_cjs.subcommands)({
24075
24476
  await cachingCommand({ configPath: args.configPath });
24076
24477
  }
24077
24478
  }),
24479
+ fixes: (0, import_cjs.command)({
24480
+ name: "fixes",
24481
+ description: "Tune compatibility fixes (interactive)",
24482
+ args: { ...configArgs },
24483
+ handler: async (args) => {
24484
+ await fixesCommand({ configPath: args.configPath });
24485
+ }
24486
+ }),
24078
24487
  edit: (0, import_cjs.command)({
24079
24488
  name: "edit",
24080
24489
  description: "Edit an existing model override (interactive)",