llm-switcher 1.6.8 → 1.6.10
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 +23 -2
- package/README.md +59 -4
- package/README.vi.md +60 -4
- package/formats.mjs +169 -20
- package/package.json +1 -1
- package/proxy.mjs +25 -3
- package/tests/compaction.e2e.test.mjs +17 -1
- package/tests/formats.test.mjs +292 -2
- package/tests/gateway.e2e.test.mjs +19 -1
- package/tests/ui.test.mjs +7 -1
- package/ui.html +761 -126
- package/upstream-stream.mjs +3 -1
package/tests/formats.test.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
createChatStream, buildChatMessage, chatFinish, anthropicStopReason,
|
|
13
13
|
smartText, smartReasoning, sanitizeJsonSchema, normalizeUpstream, buildVertexMessage,
|
|
14
14
|
createCodeAssistStream,
|
|
15
|
-
budgetToEffort, effortToBudget, normalizeEffort, capEffort, mapEquivalentEffort, EFFORT_LEVELS
|
|
15
|
+
budgetToEffort, effortToBudget, normalizeEffort, capEffort, mapEquivalentEffort, EFFORT_LEVELS, parseArgs, buildAnthropicMessage
|
|
16
16
|
} from '../formats.mjs';
|
|
17
17
|
import { assertValidAnthropicEvents } from './helpers.mjs';
|
|
18
18
|
|
|
@@ -420,7 +420,7 @@ test('toGeminiSchema: bare string shorthands become Schema objects (Vertex 400 f
|
|
|
420
420
|
assert.deepEqual(toGeminiSchema('string'), { type: 'string' });
|
|
421
421
|
assert.deepEqual(
|
|
422
422
|
toGeminiSchema({ type: 'object', properties: { tags: { type: 'array', items: 'object' }, n: 'string' } }),
|
|
423
|
-
{ type: 'object', properties: { tags: { type: 'array', items: { type: 'object', properties: {} } }, n: { type: 'string' } } }
|
|
423
|
+
{ type: 'object', properties: { tags: { type: 'array', items: { type: 'object', properties: { _placeholder: { type: 'boolean', description: 'Unused. Leave unset.' } } } }, n: { type: 'string' } } }
|
|
424
424
|
);
|
|
425
425
|
});
|
|
426
426
|
|
|
@@ -1053,3 +1053,293 @@ test('thinkingEffort profile option overrides client effort across all upstreams
|
|
|
1053
1053
|
const autoChat = emitUpstreamBody('openai-chat', clientIR, 'gpt-5', { thinkingEffort: 'auto' });
|
|
1054
1054
|
assert.equal(autoChat.reasoning_effort, 'low');
|
|
1055
1055
|
});
|
|
1056
|
+
|
|
1057
|
+
test('compaction_trigger with tool history keeps tools and forces tool_choice none', () => {
|
|
1058
|
+
const ir = responsesToIR({
|
|
1059
|
+
model: 'm', tools: [fn('Read')], text: { format: { type: 'json_object' } }, input: [
|
|
1060
|
+
{ role: 'user', content: 'read it' },
|
|
1061
|
+
{ type: 'function_call', call_id: 'c1', name: 'Read', arguments: '{"p":"a"}' },
|
|
1062
|
+
{ type: 'function_call_output', call_id: 'c1', output: 'data' },
|
|
1063
|
+
{ type: 'compaction_trigger' }
|
|
1064
|
+
]
|
|
1065
|
+
});
|
|
1066
|
+
assert.equal(ir.toolChoice, 'none');
|
|
1067
|
+
assert.equal(ir.responseFormat, null);
|
|
1068
|
+
const chat = irToChatBody(ir, 'gpt-x');
|
|
1069
|
+
assert.equal(chat.tools?.length, 1);
|
|
1070
|
+
assert.equal(chat.tool_choice, 'none');
|
|
1071
|
+
assert.equal(chat.response_format, undefined);
|
|
1072
|
+
const anthropic = irToAnthropicBody(ir, 'claude-x');
|
|
1073
|
+
assert.equal(anthropic.tools?.length, 1);
|
|
1074
|
+
assert.deepEqual(anthropic.tool_choice, { type: 'none' });
|
|
1075
|
+
assert.ok(anthropic.messages.some(m => m.content.some(b => b.type === 'tool_use')));
|
|
1076
|
+
const vertex = irToVertexBody(ir, 'gemini-x');
|
|
1077
|
+
assert.equal(vertex.tools?.[0].functionDeclarations.length, 1);
|
|
1078
|
+
assert.equal(vertex.toolConfig.functionCallingConfig.mode, 'NONE');
|
|
1079
|
+
// With no tools at all there is nothing to switch off: tool_choice without tools is refused.
|
|
1080
|
+
const bare = responsesToIR({ model: 'm', input: [{ role: 'user', content: 'hi' }, { type: 'compaction_trigger' }] });
|
|
1081
|
+
assert.equal(irToChatBody(bare, 'gpt-x').tool_choice, undefined);
|
|
1082
|
+
assert.equal(irToAnthropicBody(bare, 'claude-x').tool_choice, undefined);
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
test('anthropic emitters sanitize foreign tool ids consistently', () => {
|
|
1086
|
+
const foreign = 'functions.Read:0';
|
|
1087
|
+
const valid = /^[a-zA-Z0-9_-]+$/;
|
|
1088
|
+
const history = [
|
|
1089
|
+
{ role: 'user', content: 'go' },
|
|
1090
|
+
{ role: 'assistant', content: [{ type: 'tool_use', id: foreign, name: 'Read', input: {} }] },
|
|
1091
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: foreign, content: 'ok' }] }
|
|
1092
|
+
];
|
|
1093
|
+
const ir = anthropicToIR({ model: 'x', tools: [{ name: 'Read', input_schema: { type: 'object', properties: {} } }], messages: history });
|
|
1094
|
+
const body = irToAnthropicBody(ir, 'claude-x');
|
|
1095
|
+
const use = body.messages[1].content.find(b => b.type === 'tool_use');
|
|
1096
|
+
assert.match(use.id, valid);
|
|
1097
|
+
assert.equal(body.messages[2].content[0].tool_use_id, use.id);
|
|
1098
|
+
assert.equal(irToAnthropicBody(ir, 'claude-x').messages[1].content[0].id, use.id, 'a replayed history maps identically');
|
|
1099
|
+
|
|
1100
|
+
const message = buildAnthropicMessage({ model: 'x', tools: [{ id: foreign, name: 'Read', args: '{}' }], finish: 'tool_calls' });
|
|
1101
|
+
assert.equal(message.content[0].id, use.id);
|
|
1102
|
+
const events = [];
|
|
1103
|
+
const stream = createAnthropicStream((e, d) => events.push(d), 'x');
|
|
1104
|
+
stream.start();
|
|
1105
|
+
stream.tool({ index: 0, id: foreign, name: 'Read', args: '{}' });
|
|
1106
|
+
stream.finish('tool_calls');
|
|
1107
|
+
assert.equal(events.find(e => e.type === 'content_block_start').content_block.id, use.id);
|
|
1108
|
+
|
|
1109
|
+
const healed = healAnthropicPayload({ model: 'claude', messages: history });
|
|
1110
|
+
assert.equal(healed.changed, true);
|
|
1111
|
+
assert.equal(healed.payload.messages[1].content[0].id, use.id);
|
|
1112
|
+
assert.equal(healed.payload.messages[2].content[0].tool_use_id, use.id);
|
|
1113
|
+
|
|
1114
|
+
// Two ids that differ only in an invalid character must not collide, and a valid id is kept.
|
|
1115
|
+
const ids = ['a.b', 'a:b', 'a_b'].map(id => buildAnthropicMessage({ model: 'x', tools: [{ id, name: 'f', args: '{}' }] }).content[0].id);
|
|
1116
|
+
assert.equal(new Set(ids).size, 3);
|
|
1117
|
+
assert.equal(ids[2], 'a_b');
|
|
1118
|
+
assert.match(buildAnthropicMessage({ model: 'x', tools: [{ id: '', name: 'f', args: '{}' }] }).content[0].id, valid);
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
test('healAnthropicPayload drops empty system and empty text blocks', () => {
|
|
1122
|
+
const nonEmpty = msgs => msgs.every(m => typeof m.content === 'string' ? m.content.trim()
|
|
1123
|
+
: m.content.length && m.content.every(b => b.type !== 'text' || b.text.trim()));
|
|
1124
|
+
const alternates = msgs => msgs[0].role === 'user' && msgs.every((m, i) => !i || m.role !== msgs[i - 1].role);
|
|
1125
|
+
const r = healAnthropicPayload({
|
|
1126
|
+
model: 'claude', system: [{ type: 'text', text: '' }], messages: [
|
|
1127
|
+
{ role: 'user', content: [{ type: 'text', text: '' }, { type: 'text', text: 'hi' }] },
|
|
1128
|
+
{ role: 'assistant', content: [{ type: 'text', text: ' ' }, { type: 'tool_use', id: 't1', name: 'f', input: {} }] },
|
|
1129
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: 'ok' }] },
|
|
1130
|
+
{ role: 'assistant', content: [{ type: 'text', text: '' }] },
|
|
1131
|
+
{ role: 'user', content: [{ type: 'text', text: 'next' }] }
|
|
1132
|
+
]
|
|
1133
|
+
});
|
|
1134
|
+
assert.equal(r.changed, true);
|
|
1135
|
+
assert.equal('system' in r.payload, false);
|
|
1136
|
+
assert.ok(nonEmpty(r.payload.messages), JSON.stringify(r.payload.messages));
|
|
1137
|
+
assert.ok(alternates(r.payload.messages), JSON.stringify(r.payload.messages));
|
|
1138
|
+
assert.deepEqual(r.payload.messages.at(-1).content.map(b => b.type), ['tool_result', 'text']);
|
|
1139
|
+
|
|
1140
|
+
// A user turn that empties out between two assistant turns, or at the end, keeps its place.
|
|
1141
|
+
const r2 = healAnthropicPayload({
|
|
1142
|
+
model: 'claude', system: [{ type: 'text', text: 'sys' }, { type: 'text', text: '' }], messages: [
|
|
1143
|
+
{ role: 'user', content: 'go' },
|
|
1144
|
+
{ role: 'assistant', content: 'a' },
|
|
1145
|
+
{ role: 'user', content: [{ type: 'text', text: '' }] },
|
|
1146
|
+
{ role: 'assistant', content: 'b' },
|
|
1147
|
+
{ role: 'user', content: '' }
|
|
1148
|
+
]
|
|
1149
|
+
});
|
|
1150
|
+
assert.deepEqual(r2.payload.system, [{ type: 'text', text: 'sys' }]);
|
|
1151
|
+
assert.ok(nonEmpty(r2.payload.messages), JSON.stringify(r2.payload.messages));
|
|
1152
|
+
assert.ok(alternates(r2.payload.messages), JSON.stringify(r2.payload.messages));
|
|
1153
|
+
assert.equal(r2.payload.messages.length, 5);
|
|
1154
|
+
assert.equal(r2.payload.messages.at(-1).role, 'user');
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
test("parseArgs('') returns {}", () => {
|
|
1158
|
+
assert.deepEqual(parseArgs(''), {});
|
|
1159
|
+
assert.deepEqual(parseArgs(' \n'), {});
|
|
1160
|
+
assert.deepEqual(parseArgs('{"a":1}'), { a: 1 });
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
test('Gemini MALFORMED_FUNCTION_CALL finish is an error, not stop', () => {
|
|
1164
|
+
const ev = normalizeUpstream({ candidates: [{ content: { parts: [] }, finishReason: 'MALFORMED_FUNCTION_CALL' }] }, 'vertex');
|
|
1165
|
+
assert.equal(ev.finish, null);
|
|
1166
|
+
assert.match(String(ev.error), /MALFORMED_FUNCTION_CALL/);
|
|
1167
|
+
const chat = normalizeUpstream({ choices: [{ delta: {}, finish_reason: 'malformed_function_call' }] }, 'openai-chat');
|
|
1168
|
+
assert.match(String(chat.error), /malformed_function_call/i);
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
// DeepSeek/Kimi thinking mode refuse a tool step whose earlier tool-call turn lost its reasoning_content.
|
|
1172
|
+
test('irToChatBody replays thinking as reasoning_content on tool-call turns', () => {
|
|
1173
|
+
const ir = anthropicToIR({ model: 'x', max_tokens: 8000, thinking: { type: 'enabled', budget_tokens: 4000 }, messages: [
|
|
1174
|
+
{ role: 'user', content: 'list files' },
|
|
1175
|
+
{ role: 'assistant', content: [{ type: 'thinking', thinking: 'I should run ls', signature: 'sig' }, { type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'ls' } }] },
|
|
1176
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'a.txt' }] },
|
|
1177
|
+
{ role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_2', name: 'Bash', input: { command: 'pwd' } }] },
|
|
1178
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_2', content: '/' }] }
|
|
1179
|
+
] });
|
|
1180
|
+
for (const opts of [{ thinkingMode: 'auto' }, { thinkingMode: 'native', replayReasoning: true }]) {
|
|
1181
|
+
const calls = irToChatBody(ir, 'deepseek-reasoner', opts).messages.filter(m => m.tool_calls);
|
|
1182
|
+
assert.equal(calls[0].reasoning_content, 'I should run ls', opts.thinkingMode);
|
|
1183
|
+
assert.equal(calls[1].reasoning_content, '', opts.thinkingMode);
|
|
1184
|
+
}
|
|
1185
|
+
const off = emitUpstreamBody('openai-chat', ir, 'deepseek-reasoner', { thinkingMode: 'off' });
|
|
1186
|
+
assert.ok(off.messages.every(m => !('reasoning_content' in m)));
|
|
1187
|
+
|
|
1188
|
+
const rir = responsesToIR({ model: 'x', reasoning: { effort: 'high' }, input: [
|
|
1189
|
+
{ type: 'message', role: 'user', content: 'go' },
|
|
1190
|
+
{ type: 'reasoning', summary: [{ type: 'summary_text', text: 'Plan: run ls' }], encrypted_content: 'opaque' },
|
|
1191
|
+
{ type: 'function_call', call_id: 'c1', name: 'shell', arguments: '{}' },
|
|
1192
|
+
{ type: 'function_call_output', call_id: 'c1', output: 'ok' }
|
|
1193
|
+
] });
|
|
1194
|
+
const rb = irToChatBody(rir, 'kimi-k2-thinking', { thinkingMode: 'native', replayReasoning: true });
|
|
1195
|
+
assert.equal(rb.messages.find(m => m.tool_calls).reasoning_content, 'Plan: run ls');
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
// Strict OpenAI-compatible servers may refuse the extra key, so native mode sends it only on the profile opt-in.
|
|
1199
|
+
test('irToChatBody native mode sends no reasoning_content without the profile opt-in', () => {
|
|
1200
|
+
const ir = anthropicToIR({ model: 'x', max_tokens: 8000, thinking: { type: 'enabled', budget_tokens: 4000 }, messages: [
|
|
1201
|
+
{ role: 'user', content: 'list files' },
|
|
1202
|
+
{ role: 'assistant', content: [{ type: 'thinking', thinking: 'I should run ls', signature: 'sig' }, { type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'ls' } }] },
|
|
1203
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'a.txt' }] },
|
|
1204
|
+
{ role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_2', name: 'Bash', input: { command: 'pwd' } }] },
|
|
1205
|
+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_2', content: '/' }] }
|
|
1206
|
+
] });
|
|
1207
|
+
const cases = [
|
|
1208
|
+
['gpt-5', ir, { thinkingMode: 'native' }],
|
|
1209
|
+
['deepseek-reasoner', ir, { thinkingMode: 'native' }],
|
|
1210
|
+
['gpt-5', ir, { thinkingMode: 'native', replayReasoning: 'yes' }],
|
|
1211
|
+
['gpt-5', { ...ir, replayReasoning: true }, { thinkingMode: 'native' }]
|
|
1212
|
+
];
|
|
1213
|
+
for (const [model, input, opts] of cases) {
|
|
1214
|
+
const body = irToChatBody(input, model, opts);
|
|
1215
|
+
assert.ok(body.messages.some(m => m.tool_calls), 'fixture has tool-call turns');
|
|
1216
|
+
assert.ok(body.messages.every(m => !('reasoning_content' in m)), `${model} ${JSON.stringify(opts)}`);
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
test('irToAnthropicBody drops thinking for an unsigned tool loop', () => {
|
|
1221
|
+
const ir = responsesToIR({ model: 'x', reasoning: { effort: 'high' }, input: [
|
|
1222
|
+
{ type: 'message', role: 'user', content: 'run ls' },
|
|
1223
|
+
{ type: 'function_call', call_id: 'c1', name: 'shell', arguments: '{"command":["ls"]}' },
|
|
1224
|
+
{ type: 'function_call_output', call_id: 'c1', output: 'a.txt' }
|
|
1225
|
+
] });
|
|
1226
|
+
const body = irToAnthropicBody(ir, 'claude-opus-4-6');
|
|
1227
|
+
assert.equal(body.thinking, undefined);
|
|
1228
|
+
assert.equal(body.messages.at(-1).content[0].type, 'tool_result');
|
|
1229
|
+
const forced = irToAnthropicBody(ir, 'claude-opus-4-6', { thinkingEffort: 'high' });
|
|
1230
|
+
assert.equal(forced.thinking, undefined);
|
|
1231
|
+
const fresh = irToAnthropicBody(responsesToIR({ model: 'x', reasoning: { effort: 'high' }, input: 'hi' }), 'claude-opus-4-6');
|
|
1232
|
+
assert.equal(fresh.thinking?.type, 'enabled');
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
test('toGeminiSchema never emits an empty nested OBJECT', () => {
|
|
1236
|
+
const out = toGeminiSchema({ type: 'object', properties: {
|
|
1237
|
+
env: { type: 'object', additionalProperties: { type: 'string' } },
|
|
1238
|
+
list: { type: 'array', items: { type: 'object' } },
|
|
1239
|
+
either: { anyOf: [{ type: 'object' }, { type: 'string' }] },
|
|
1240
|
+
short: 'object'
|
|
1241
|
+
} });
|
|
1242
|
+
const walk = (s, nested) => {
|
|
1243
|
+
if (!s || typeof s !== 'object') return;
|
|
1244
|
+
if (nested && s.type === 'object') assert.ok(Object.keys(s.properties || {}).length, JSON.stringify(s));
|
|
1245
|
+
for (const p of Object.values(s.properties || {})) walk(p, true);
|
|
1246
|
+
if (s.items) walk(s.items, true);
|
|
1247
|
+
for (const b of s.anyOf || []) walk(b, true);
|
|
1248
|
+
};
|
|
1249
|
+
walk(out, false);
|
|
1250
|
+
assert.deepEqual(toGeminiSchema({ type: 'object' }), { type: 'object', properties: {} });
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
test('irToVertexBody wraps tool output with $ keys', () => {
|
|
1254
|
+
const output = JSON.stringify({ $ref: '#/definitions/Pet', definitions: { Pet: { $id: 'p' } } });
|
|
1255
|
+
const nested = JSON.stringify({ a: { b: [{ $schema: 'x' }] } });
|
|
1256
|
+
const ir = { ...anthropicToIR({ model: 'x', messages: [
|
|
1257
|
+
{ role: 'user', content: 'go' },
|
|
1258
|
+
{ role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: {} }, { type: 'tool_use', id: 't2', name: 'Bash', input: {} }, { type: 'tool_use', id: 't3', name: 'Bash', input: {} }] },
|
|
1259
|
+
{ role: 'user', content: [
|
|
1260
|
+
{ type: 'tool_result', tool_use_id: 't1', content: output },
|
|
1261
|
+
{ type: 'tool_result', tool_use_id: 't2', content: nested },
|
|
1262
|
+
{ type: 'tool_result', tool_use_id: 't3', content: '{"ok":true}' }
|
|
1263
|
+
] }
|
|
1264
|
+
] }) };
|
|
1265
|
+
const responses = irToVertexBody(ir, 'gemini-2.5-pro').contents.flatMap(c => c.parts).filter(p => p.functionResponse).map(p => p.functionResponse.response);
|
|
1266
|
+
assert.deepEqual(responses[0], { result: output });
|
|
1267
|
+
assert.deepEqual(responses[1], { result: nested });
|
|
1268
|
+
assert.deepEqual(responses[2], { ok: true });
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
test('toGeminiSchema stringifies an untyped const', () => {
|
|
1272
|
+
const out = toGeminiSchema({ type: 'object', properties: { flag: { const: true }, n: { const: 3 }, s: { const: 'x' } } });
|
|
1273
|
+
assert.deepEqual(out.properties.flag, { enum: ['true'], type: 'string' });
|
|
1274
|
+
assert.deepEqual(out.properties.n, { enum: ['3'], type: 'string' });
|
|
1275
|
+
assert.deepEqual(out.properties.s, { enum: ['x'], type: 'string' });
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
test('responsesToIR keeps long colliding tool names distinct', () => {
|
|
1279
|
+
const ns = `mcp__${'server_with_a_long_name_'.repeat(3)}`;
|
|
1280
|
+
const ir = responsesToIR({ model: 'x', input: [
|
|
1281
|
+
{ type: 'message', role: 'user', content: 'go' },
|
|
1282
|
+
{ type: 'function_call', call_id: 'c1', namespace: ns, name: 'list_items_with_filter_b', arguments: '{}' },
|
|
1283
|
+
{ type: 'function_call_output', call_id: 'c1', output: 'ok' }
|
|
1284
|
+
], tools: [{ type: 'namespace', name: ns, tools: [
|
|
1285
|
+
{ type: 'function', name: 'list_items_with_filter_a', parameters: { type: 'object', properties: {} } },
|
|
1286
|
+
{ type: 'function', name: 'list_items_with_filter_b', parameters: { type: 'object', properties: {} } }
|
|
1287
|
+
] }] });
|
|
1288
|
+
const names = ir.tools.map(t => t.name);
|
|
1289
|
+
assert.equal(names.length, 2);
|
|
1290
|
+
assert.notEqual(names[0], names[1]);
|
|
1291
|
+
assert.ok(names.every(n => n.length <= 64 && /^[A-Za-z0-9_-]+$/.test(n)), names.join());
|
|
1292
|
+
assert.equal(ir.toolMeta[names[1]].name, 'list_items_with_filter_b');
|
|
1293
|
+
assert.equal(ir.messages[1].toolCalls[0].name, names[1]);
|
|
1294
|
+
assert.equal(responsesToIR({ model: 'x', input: 'hi', tools: [{ type: 'function', name: 'read_file', parameters: {} }] }).tools[0].name, 'read_file');
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
test('sanitizeJsonSchema drops null keywords and lookaround patterns', () => {
|
|
1298
|
+
const out = sanitizeJsonSchema({ type: 'object', required: null, description: null, properties: {
|
|
1299
|
+
slug: { type: 'string', pattern: '^(?!-)[a-z-]+$' },
|
|
1300
|
+
tag: { type: 'string', pattern: '(?<=#)\\w+' },
|
|
1301
|
+
plain: { type: 'string', pattern: '^[a-z]+$', default: null },
|
|
1302
|
+
named: { type: 'string', pattern: '^(?<year>\\d{4})$' },
|
|
1303
|
+
pattern: { type: 'string', enum: ['a', null], const: null }
|
|
1304
|
+
} });
|
|
1305
|
+
assert.equal('required' in out, false);
|
|
1306
|
+
assert.equal('description' in out, false);
|
|
1307
|
+
assert.equal('pattern' in out.properties.slug, false);
|
|
1308
|
+
assert.equal('pattern' in out.properties.tag, false);
|
|
1309
|
+
assert.equal(out.properties.plain.pattern, '^[a-z]+$');
|
|
1310
|
+
assert.equal(out.properties.plain.default, null);
|
|
1311
|
+
assert.equal(out.properties.named.pattern, '^(?<year>\\d{4})$');
|
|
1312
|
+
assert.deepEqual(out.properties.pattern, { type: 'string', enum: ['a', null], const: null });
|
|
1313
|
+
assert.deepEqual(sanitizeJsonSchema({ type: 'object', properties: {}, default: { a: null, b: {} } }).default, { a: null, b: {} });
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
test('irToVertexBody never ends on a model turn', () => {
|
|
1317
|
+
const ir = anthropicToIR({ model: 'x', messages: [
|
|
1318
|
+
{ role: 'user', content: 'Write a haiku' },
|
|
1319
|
+
{ role: 'assistant', content: 'Autumn moon' }
|
|
1320
|
+
] });
|
|
1321
|
+
const body = irToVertexBody(ir, 'gemini-2.5-pro');
|
|
1322
|
+
assert.equal(body.contents.at(-1).role, 'user');
|
|
1323
|
+
assert.ok(body.contents.some(c => c.role === 'model' && c.parts.some(p => p.text === 'Autumn moon')), 'the prefill text is kept');
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
test('healAnthropicPayload dedupes repeated tool_use ids', () => {
|
|
1327
|
+
const use = (id, cmd) => ({ type: 'tool_use', id, name: 'Bash', input: { command: cmd } });
|
|
1328
|
+
const result = (id, text) => ({ type: 'tool_result', tool_use_id: id, content: text });
|
|
1329
|
+
const r = healAnthropicPayload({ model: 'claude', messages: [
|
|
1330
|
+
{ role: 'user', content: 'go' },
|
|
1331
|
+
{ role: 'assistant', content: [use('functions_Bash_0', 'ls')] },
|
|
1332
|
+
{ role: 'user', content: [result('functions_Bash_0', 'a.txt')] },
|
|
1333
|
+
{ role: 'assistant', content: [use('functions_Bash_0', 'pwd'), use('functions_Bash_1', 'id')] },
|
|
1334
|
+
{ role: 'user', content: [result('functions_Bash_0', '/'), result('functions_Bash_1', 'uid')] }
|
|
1335
|
+
] });
|
|
1336
|
+
assert.equal(r.changed, true);
|
|
1337
|
+
const msgs = r.payload.messages;
|
|
1338
|
+
const ids = msgs.filter(m => m.role === 'assistant').flatMap(m => m.content.filter(b => b.type === 'tool_use').map(b => b.id));
|
|
1339
|
+
assert.equal(new Set(ids).size, ids.length, ids.join());
|
|
1340
|
+
assert.ok(ids.every(id => /^[a-zA-Z0-9_-]+$/.test(id)));
|
|
1341
|
+
assert.equal(ids[0], 'functions_Bash_0');
|
|
1342
|
+
// Each result still answers its own call.
|
|
1343
|
+
assert.deepEqual(msgs[2].content.map(b => [b.tool_use_id, b.content]), [[ids[0], 'a.txt']]);
|
|
1344
|
+
assert.deepEqual(msgs[4].content.map(b => [b.tool_use_id, b.content]), [[ids[1], '/'], [ids[2], 'uid']]);
|
|
1345
|
+
});
|
|
@@ -112,6 +112,10 @@ function startUpstream() {
|
|
|
112
112
|
res.writeHead(429, { 'Content-Type': 'application/json', 'retry-after': '7' });
|
|
113
113
|
return res.end(JSON.stringify({ error: { message: 'slow down' } }));
|
|
114
114
|
}
|
|
115
|
+
if (lastUser.includes('CONTEXT_OVERFLOW')) {
|
|
116
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
117
|
+
return res.end(JSON.stringify({ error: { message: "This model's maximum context length is 131072 tokens. However, you requested 140000 tokens (132000 in the messages, 8000 in the completion). Please reduce the length of the messages or completion.", type: 'invalid_request_error', code: 'invalid_request_error' } }));
|
|
118
|
+
}
|
|
115
119
|
if (lastUser.includes('APPLY_PATCH')) {
|
|
116
120
|
return sse(res, [
|
|
117
121
|
{ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call_patch', type: 'function', function: { name: 'apply_patch', arguments: '{"input":"*** Begin Patch\\n' } }] } }] },
|
|
@@ -407,7 +411,7 @@ test('Codex via ag/* target: Gemini-hostile tool schemas are rewritten before up
|
|
|
407
411
|
const params = up.body.tools[0].function.parameters;
|
|
408
412
|
assert.deepEqual(params.properties.ids, { type: 'array', items: { type: 'string' }, nullable: true });
|
|
409
413
|
assert.deepEqual(params.properties.part, { type: 'object', properties: { t: { type: 'string' } } });
|
|
410
|
-
assert.deepEqual(params.properties.raw, { type: 'object', properties: {} });
|
|
414
|
+
assert.deepEqual(params.properties.raw, { type: 'object', properties: { _placeholder: { type: 'boolean', description: 'Unused. Leave unset.' } } });
|
|
411
415
|
assert.deepEqual(params.required, ['ids']);
|
|
412
416
|
assert.ok(!JSON.stringify(params).includes('$ref'), 'no $ref survives');
|
|
413
417
|
assert.equal(params.$defs, undefined);
|
|
@@ -594,6 +598,20 @@ test('Upstream 429 is returned in Anthropic error shape with retry-after', async
|
|
|
594
598
|
assert.deepEqual(json, { type: 'error', error: { type: 'rate_limit_error', message: 'slow down' } });
|
|
595
599
|
});
|
|
596
600
|
|
|
601
|
+
// Claude Code compacts only on "prompt is too long", and Codex only on context_length_exceeded.
|
|
602
|
+
test('sendClientError maps context overflow per client', async () => {
|
|
603
|
+
const cc = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 10, messages: [{ role: 'user', content: 'CONTEXT_OVERFLOW' }] });
|
|
604
|
+
assert.equal(cc.status, 400);
|
|
605
|
+
const ccJson = await cc.json();
|
|
606
|
+
assert.equal(ccJson.error.type, 'invalid_request_error');
|
|
607
|
+
assert.match(ccJson.error.message, /^prompt is too long: 140000 tokens > 131072 maximum/);
|
|
608
|
+
const codex = await post('/v1/responses', { model: 'gpt-5-codex', input: 'CONTEXT_OVERFLOW' });
|
|
609
|
+
assert.equal(codex.status, 400);
|
|
610
|
+
const codexJson = await codex.json();
|
|
611
|
+
assert.equal(codexJson.error.code, 'context_length_exceeded');
|
|
612
|
+
assert.match(codexJson.error.message, /maximum context length is 131072/);
|
|
613
|
+
});
|
|
614
|
+
|
|
597
615
|
test('Mid-stream upstream error surfaces as an error event instead of a fake end_turn', async () => {
|
|
598
616
|
const res = await post('/v1/messages', { model: 'claude-opus-4-6', max_tokens: 10, stream: true, messages: [{ role: 'user', content: 'MID_STREAM_ERROR' }] });
|
|
599
617
|
const events = parseSSE(await res.text());
|
package/tests/ui.test.mjs
CHANGED
|
@@ -124,10 +124,11 @@ test('documentation tab is properly integrated with sidebar navigation, sections
|
|
|
124
124
|
// Table of Contents navigation exists
|
|
125
125
|
assert.match(html, /<nav class="docs-toc" id="docsToc"/);
|
|
126
126
|
|
|
127
|
-
// All
|
|
127
|
+
// All 11 core sections exist
|
|
128
128
|
const expectedSections = [
|
|
129
129
|
'doc-overview',
|
|
130
130
|
'doc-install',
|
|
131
|
+
'doc-intact',
|
|
131
132
|
'doc-cli',
|
|
132
133
|
'doc-blindfold',
|
|
133
134
|
'doc-agy',
|
|
@@ -150,3 +151,8 @@ test('documentation tab is properly integrated with sidebar navigation, sections
|
|
|
150
151
|
assert.match(html, /function copyDocCode\(/);
|
|
151
152
|
assert.match(html, /function initDocsScrollSpy\(/);
|
|
152
153
|
});
|
|
154
|
+
|
|
155
|
+
test('sidebar menu is arranged logically by frequency of use', () => {
|
|
156
|
+
const navMatches = [...html.matchAll(/data-nav="([^"]+)"/g)].map(m => m[1]);
|
|
157
|
+
assert.deepEqual(navMatches, ['routes', 'logs', 'models', 'doctor', 'goal-check', 'compact', 'docs']);
|
|
158
|
+
});
|