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 +18 -0
- package/dist/server/providers/codex.js +78 -9
- package/dist/server/routes/admin/combos.js +4 -1
- package/dist/web/assets/index-DAzlm3Y6.js +391 -0
- package/dist/web/assets/index-Xccks9E5.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DdtyoPpS.js +0 -391
- package/dist/web/assets/index-Drmqi8MO.css +0 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|