ldrouter 1.17.0 → 1.17.2

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/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/) and the project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.17.2] - 2026-09-17
8
+
9
+ ### Fixed
10
+
11
+ - **Every second message to a `codex/` model failed with `502 Codex upstream HTTP 400`.** The Responses `input` array was built with one content block type for all roles: assistant history went upstream as `input_text`, which the backend rejects ("Invalid value: 'input_text'. Supported values are: 'output_text' and 'refusal'"). The first message of a conversation worked because it carries no assistant turn; every follow-up carries the previous answer, so chat, combo and agent traffic died from the second turn onward. Assistant prose is now sent as `output_text`. Verified live against chatgpt.com with a two-turn conversation (both turns 200).
12
+ - **Tool history was rejected the same way.** `function_call` and `function_call_output` are top-level `input` items on the Responses API, never content blocks — a call nested in `content` answers `400 invalid_value` listing the permitted block types. Tool calls and their results are now emitted as flat items correlated by `call_id`.
13
+ - **`Codex upstream HTTP 400` said nothing about the cause.** The upstream error body, which names the offending field (`param input[i].content[j]`), was discarded. It is now included in the thrown message, secret-redacted and truncated.
14
+
15
+ ## [1.17.1] - 2026-09-17
16
+
17
+ ### Added
18
+
19
+ - **Combo members are ordered by drag and drop.** The combo create/edit dialog lists members as sortable rows (`@dnd-kit`, the same interaction as the Codex and Qoder account pools): drag the handle — or reorder with the keyboard — and the row order becomes the saved priority, `position` 0 being tried first in fallback mode and leading the rotation in weighted round-robin.
20
+
21
+ ### Fixed
22
+
23
+ - **`GET /api/admin/combos/:id` returned members in SQLite row order instead of priority order**, so a reordered combo could reopen showing the previous sequence. Members are now sorted by `position`.
24
+
7
25
  ## [1.17.0] - 2026-09-17
8
26
 
9
27
  ### Added
@@ -1,4 +1,5 @@
1
1
  import { withCodexCredentials, refreshCodexAccount } from './codex-refresh.js';
2
+ import { redactString, redactValue } from '../security/redact.js';
2
3
  const base = (url) => url.replace(/\/$/, '');
3
4
  /**
4
5
  * Reported Codex CLI client version. The models endpoint rejects requests without it:
@@ -75,13 +76,7 @@ export async function codexModels(cfg) {
75
76
  */
76
77
  const CODEX_REQUIRED_FLAGS = { store: false, stream: true };
77
78
  export function codexRequestPayload(req, targetModel = req.model) {
78
- const input = req.messages.map((message) => ({ role: message.role, content: message.content.map((block) => {
79
- if (block.type === 'text')
80
- return { type: 'input_text', text: block.text ?? '' };
81
- if (block.type === 'image' && (block.image?.url || block.image?.base64))
82
- return { type: 'input_image', image_url: block.image?.url ?? `data:${block.image?.mimeType ?? 'image/png'};base64,${block.image?.base64}` };
83
- return null;
84
- }).filter(Boolean) }));
79
+ const input = codexInputItems(req.messages);
85
80
  const payload = { model: targetModel, input, ...CODEX_REQUIRED_FLAGS };
86
81
  if (req.system)
87
82
  payload.instructions = req.system;
@@ -94,6 +89,64 @@ export function codexRequestPayload(req, targetModel = req.model) {
94
89
  payload.reasoning = { effort: req.reasoning.effort };
95
90
  return payload;
96
91
  }
92
+ /**
93
+ * Translate canonical messages into the Responses `input` array.
94
+ *
95
+ * The backend is strict about which item shapes are legal and rejects the rest
96
+ * with `400 invalid_value`, naming the offending `input[i]` path:
97
+ * - a text block on an assistant turn must be `output_text`; `input_text` is
98
+ * rejected ("Invalid value: 'input_text'. Supported values are: 'output_text'
99
+ * and 'refusal'"). So a plain chat follow-up — history contains an assistant
100
+ * turn — failed while the first message of a conversation worked.
101
+ * - tool calls and their results are NOT content blocks: a `function_call`
102
+ * inside `content` is rejected ("Supported values are: 'input_text',
103
+ * 'input_image', …"). They are top-level items, `function_call` and
104
+ * `function_call_output`, correlated by `call_id`.
105
+ * Verified live against chatgpt.com.
106
+ */
107
+ export function codexInputItems(messages) {
108
+ const items = [];
109
+ for (const message of messages) {
110
+ const content = [];
111
+ const flushMessage = () => {
112
+ if (content.length > 0)
113
+ items.push({ role: message.role, content: [...content] });
114
+ content.length = 0;
115
+ };
116
+ for (const block of message.content) {
117
+ if (block.type === 'text') {
118
+ // Assistant prose is "output" from the API's point of view; everything else is input.
119
+ content.push(message.role === 'assistant' ? { type: 'output_text', text: block.text ?? '' } : { type: 'input_text', text: block.text ?? '' });
120
+ continue;
121
+ }
122
+ if (block.type === 'image' && (block.image?.url || block.image?.base64)) {
123
+ content.push({ type: 'input_image', image_url: block.image?.url ?? `data:${block.image?.mimeType ?? 'image/png'};base64,${block.image?.base64}` });
124
+ continue;
125
+ }
126
+ if (block.type === 'tool_use' && block.toolUse) {
127
+ // Top-level item, so the message wrapping the call is emitted first.
128
+ flushMessage();
129
+ items.push({
130
+ type: 'function_call',
131
+ call_id: block.toolUse.id,
132
+ name: block.toolUse.name,
133
+ arguments: typeof block.toolUse.input === 'string' ? block.toolUse.input : JSON.stringify(block.toolUse.input ?? {}),
134
+ });
135
+ continue;
136
+ }
137
+ if (block.type === 'tool_result' && block.toolResult) {
138
+ flushMessage();
139
+ items.push({
140
+ type: 'function_call_output',
141
+ call_id: block.toolResult.toolUseId,
142
+ output: typeof block.toolResult.content === 'string' ? block.toolResult.content : JSON.stringify(block.toolResult.content ?? ''),
143
+ });
144
+ }
145
+ }
146
+ flushMessage();
147
+ }
148
+ return items;
149
+ }
97
150
  function usage(value) {
98
151
  const u = (value && typeof value === 'object' ? value : {});
99
152
  const input = typeof u.input_tokens === 'number' ? u.input_tokens : 0;
@@ -146,7 +199,20 @@ export function codexStreamEventToCanonical(event) {
146
199
  return { text: '', isLast: false };
147
200
  }
148
201
  async function responseError(response) {
149
- const error = Object.assign(new Error(`Codex upstream HTTP ${response.status}`), { status: response.status });
202
+ // The backend explains every rejection (`{"error":{"message":"Invalid value: ...","param":"input[1].content[0]"}}`).
203
+ // Without it an operator only sees "HTTP 400" and has to re-derive the cause by hand.
204
+ let detail = '';
205
+ try {
206
+ const parsed = JSON.parse(await response.text());
207
+ const message = typeof parsed.error?.message === 'string' ? parsed.error.message : typeof parsed.detail === 'string' ? parsed.detail : null;
208
+ // redactValue, not just redactString: an error body can echo a secret under a
209
+ // secret-named key, which only the structured pass recognizes.
210
+ detail = message
211
+ ? `: ${message}${typeof parsed.error?.param === 'string' ? ` (param ${parsed.error.param})` : ''}`
212
+ : `: ${JSON.stringify(redactValue(parsed))}`;
213
+ }
214
+ catch { /* a non-JSON or already-read body must not mask the status */ }
215
+ const error = Object.assign(new Error(`Codex upstream HTTP ${response.status}${redactString(detail).slice(0, 500)}`), { status: response.status });
150
216
  return error;
151
217
  }
152
218
  export async function callCodexNonStreaming(cfg, req) {
@@ -172,8 +238,11 @@ export async function callCodexStreaming(cfg, req, onEvent) {
172
238
  const ctl = timeout(tokenCfg.totalTimeoutMs);
173
239
  try {
174
240
  const response = await fetch(codexRequest(tokenCfg, '/responses'), { method: 'POST', headers: codexHeaders(tokenCfg, 'text/event-stream'), body: JSON.stringify(codexRequestPayload(req)), signal: ctl.signal });
175
- if (!response.ok || !response.body)
241
+ // Only a failed response is read as text here; a 2xx with no body must still report its status.
242
+ if (!response.ok)
176
243
  throw await responseError(response);
244
+ if (!response.body)
245
+ throw Object.assign(new Error(`Codex upstream HTTP ${response.status}: empty response body`), { status: response.status });
177
246
  const reader = response.body.getReader();
178
247
  const decoder = new TextDecoder();
179
248
  let buffer = '';
@@ -118,7 +118,10 @@ export async function registerComboRoutes(app) {
118
118
  const c = db.select().from(schema.combos).where(eq(schema.combos.id, id)).get();
119
119
  if (!c)
120
120
  throw new GatewayError('invalid_request_error', 'Combo not found', { status: 404 });
121
- const members = db.select().from(schema.comboMembers).where(eq(schema.comboMembers.comboId, id)).all();
121
+ // The admin UI shows (and drag-reorders) members by priority, so return them
122
+ // in position order rather than relying on SQLite row order.
123
+ const members = db.select().from(schema.comboMembers).where(eq(schema.comboMembers.comboId, id)).all()
124
+ .sort((a, b) => a.position - b.position);
122
125
  const models = db.select().from(schema.models).all();
123
126
  const modelMap = new Map(models.map((m) => [m.id, m]));
124
127
  return {