llm-switcher 1.6.11 → 1.6.13
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 +27 -0
- package/README.md +3 -2
- package/README.vi.md +3 -2
- package/formats.mjs +158 -13
- package/package.json +1 -1
- package/proxy.mjs +54 -38
- package/scripts/run-tests.mjs +60 -12
- package/switch.mjs +38 -13
- package/tests/blindfold.task3.test.mjs +1 -0
- package/tests/blindfold.test.mjs +1 -1
- package/tests/compaction.e2e.test.mjs +5 -1
- package/tests/contract-check.test.mjs +7 -2
- package/tests/contract-lab.test.mjs +2 -2
- package/tests/datadir.test.mjs +4 -2
- package/tests/formats.test.mjs +301 -1
- package/tests/gateway.e2e.test.mjs +231 -1
- package/tests/goalcheck.e2e.test.mjs +90 -8
- package/tests/idlecompact.e2e.test.mjs +6 -1
- package/tests/lifecycle.test.mjs +5 -1
- package/tests/run-tests.test.mjs +50 -0
- package/tests/shim.test.mjs +24 -18
- package/tests/switch.test.mjs +46 -0
- package/tests/update.e2e.test.mjs +6 -1
- package/tests/version.test.mjs +5 -2
- package/tests/windows.test.mjs +147 -3
- package/windows.mjs +63 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog — LLM Switcher
|
|
2
2
|
|
|
3
|
+
## Release 1.6.13 — 2026-10-12
|
|
4
|
+
|
|
5
|
+
- **`switch on` with no profile.** `switch on` gives each tool the profile that it had before `switch off`. A tool that was off stays off. A tool that is on keeps its profile.
|
|
6
|
+
- **System turns on the translated route.** A `system` turn inside the `messages` of an Anthropic request now goes into the system prompt when the proxy translates the request.
|
|
7
|
+
- **System messages on the direct Anthropic route.** A folded `[system] ` text keeps its place between the turns around it. It never moves past an assistant turn. A system message with no text is dropped, and the healer note now says so. A system message after a final assistant prefill becomes the last user turn, so the request no longer ends on the prefill.
|
|
8
|
+
- **Gemini `_placeholder` in `anyOf`.** If one branch of an `anyOf` declares a real `_placeholder` property, the proxy keeps the key in the arguments.
|
|
9
|
+
- **Gemini `_placeholder` and tool names.** If two padded tools map back to one client tool name, the proxy removes nothing for that name.
|
|
10
|
+
- **Retry to another upstream format.** A request that the proxy builds again for another upstream format gets a new tool vocabulary and a new placeholder rule.
|
|
11
|
+
- **Reasoning before a compaction item.** In a Codex request, reasoning that waits for an assistant turn is dropped at a `compaction` item.
|
|
12
|
+
- **Model list cache time.** A cache entry with a time in the future is stale. It no longer stays fresh forever.
|
|
13
|
+
- **More token count forms.** A context overflow message with `128k`, `1m`, `131 072` (no-break space), `131'072` or `1,31,072` gives the full number to Claude Code. A decimal number after `limit` is not read as a token count. A plain `limit N` counts as the maximum only when `tokens` follows the number. A count of zero, or a count too large to be exact, is not read as a token count.
|
|
14
|
+
- **Output limit for every profile.** If a converted request finds no fresh model list for its profile, the proxy reads the list in the background. The request does not wait. The next request gets the limit. Each profile keeps its own limit, also on a shared base URL. The proxy reads `<baseURL>/models` with the API key of the profile, except for a profile with an `endpoints` override. That profile gets no output limit. The `/v1/models` list also does not send the key of that profile to `<baseURL>/models`.
|
|
15
|
+
- **Goal check when the evaluator call fails.** If the native evaluator call fails, the goal check does not let the goal through. A test now holds this behavior.
|
|
16
|
+
- **Test cleanup.** Every test file that makes a temp directory deletes it, also when a test fails. `npm test` deletes `node-compile-cache` only when npm made it for this run. A failed delete no longer changes the exit status of the tests.
|
|
17
|
+
|
|
18
|
+
## Release 1.6.12 — 2026-10-11
|
|
19
|
+
|
|
20
|
+
- **Reasoning from an aborted turn.** In a Codex request, reasoning that waits for an assistant turn is dropped at the next user message. Separate reasoning items are joined with a newline.
|
|
21
|
+
- **Token counts with separators.** A context overflow message with `131,072`, `131.072`, `131 072` or `131_072` gives the full number to Claude Code. A message without numbers keeps the plain form.
|
|
22
|
+
- **Context overflow over WebSocket.** A Codex turn on WebSocket fails with the code `context_length_exceeded`, the same as on HTTP.
|
|
23
|
+
- **Goal check and images.** The goal-check request to a chat upstream uses the same image retry as other requests.
|
|
24
|
+
- **Output limit cache.** The output limit comes only from the cache entry of the same profile, and only when the entry is less than one hour old. The cache key ignores the case of scheme and host and a trailing slash. A new model list is merged per model, so a list with only output limits keeps the cached windows. A cached output limit is kept only while the newest list still states it.
|
|
25
|
+
- **System messages on the direct Anthropic route.** A `system` message inside `messages` is no longer lost. Its text goes into the next user turn, or into the previous one if no user turn follows, with the prefix `[system] `.
|
|
26
|
+
- **Gemini `_placeholder` in tool calls.** If the model fills the `_placeholder` property that the proxy added, the proxy removes it from the arguments. This applies to stream and non-stream answers for every client format, Code Assist (agy) included, and only where the proxy added the property.
|
|
27
|
+
- **Test temp files.** The tests delete all their temp directories, also when a test fails. `npm test` deletes the `node-compile-cache` directory that npm made at the start of the run.
|
|
28
|
+
- **`replayReasoning` documentation.** The README files now say that `reasoning_content` goes out only on a request with thinking on.
|
|
29
|
+
|
|
3
30
|
## Release 1.6.11 — 2026-10-11
|
|
4
31
|
|
|
5
32
|
- **Output limit.** The proxy keeps the output limit of each model from the `/models` list of the profile (`max_output_tokens` or `top_provider.max_completion_tokens`) in `model-windows.json`. If the client asks for more tokens than this limit, the proxy sends the limit. This applies to `max_tokens`, `max_completion_tokens` and `maxOutputTokens`. If the limit is not known, the value does not change. The limit does not apply to the anthropic-to-anthropic passthrough (direct mode, outFormat `anthropic`, and the native goal-check request). These requests go to the upstream unchanged.
|
package/README.md
CHANGED
|
@@ -519,7 +519,8 @@ switch status # Display status for all active CLI targets
|
|
|
519
519
|
switch version # Show the version and tell you when npm has a newer one
|
|
520
520
|
switch doctor # Audit environment, settings & routing
|
|
521
521
|
switch update # Install the newest release and restart the gateway on it
|
|
522
|
-
switch on
|
|
522
|
+
switch on # Start the gateway; each tool gets back the profile it had before `switch off`
|
|
523
|
+
switch on <profile> # Start the gateway and activate a profile
|
|
523
524
|
switch <profile> # Activate a profile for both tools
|
|
524
525
|
switch claude <profile> # Set the active profile for Claude Code only
|
|
525
526
|
switch codex <profile> # Set the active profile for Codex only
|
|
@@ -869,7 +870,7 @@ Because of this split, a provider fingerprint is never a rule in this gateway. I
|
|
|
869
870
|
| `LLM_SWITCHER_BIFROST_RETRY_MS` | Time before the gateway asks intact again after a failed Bifrost lookup (default 30000 ms). |
|
|
870
871
|
| `x-llm-profile: <key>` header (alias `x-profile`) or `?profile=<key>` | Route a single request through a specific profile. An unknown key returns HTTP 400 instead of silently falling back. |
|
|
871
872
|
| `profile.thinkingMode` | `auto` (default, for gateways like intact or 9Router): restore stripped thinking, inject a `<think>` guide for non-reasoning models, send `thinking` + `reasoning_effort`, and send the earlier thinking as `reasoning_content` on assistant tool-call turns. `native` (strict OpenAI APIs): send only `reasoning_effort` when the client asks, never touch the prompt, use `max_completion_tokens`. Native mode never sends `reasoning_content` unless the profile sets `replayReasoning: true`. `off`: never send reasoning parameters. |
|
|
872
|
-
| `profile.replayReasoning` | `true` sends the earlier thinking as `reasoning_content` on assistant tool-call turns in `native` mode. DeepSeek and Kimi thinking mode need it. The default is `false`. `
|
|
873
|
+
| `profile.replayReasoning` | `true` sends the earlier thinking as `reasoning_content` on assistant tool-call turns in `native` mode. DeepSeek and Kimi thinking mode need it. The default is `false`. The field goes out only on a request with thinking on. In `native` mode, thinking is on when the client asks for it. In `auto` mode, the flag is not needed: thinking is on when the client asks for it, or when the model reasons natively and the client did not turn thinking off. `off` never sends the field. |
|
|
873
874
|
| `profile.thinkingEffort` | `auto` (default, caller/client controls effort), or an explicit override level: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra`. Overrides client effort before routing upstream. Automatically maps to the nearest equivalent level accepted by the target upstream (e.g. OpenAI ceiling `high`, Anthropic `max`, Gemini budget bounds). In the Web UI, this selector dynamically hides with a guidance hint when Bifrost passthrough is active. |
|
|
874
875
|
| `profile.endpoints.countTokens` | Override the Anthropic `count_tokens` URL. |
|
|
875
876
|
| `profile.endpoints` | Override upstream URLs per format: `{ "openai-chat": "...", "anthropic": "...", "vertex": "https://.../models/{model}:{action}" }`. |
|
package/README.vi.md
CHANGED
|
@@ -516,7 +516,8 @@ switch status # Xem trạng thái kích hoạt của tất cả
|
|
|
516
516
|
switch version # Xem version đang chạy và báo khi npm có bản mới
|
|
517
517
|
switch doctor # Quét & thanh tra toàn bộ môi trường, settings và định tuyến
|
|
518
518
|
switch update # Cài bản mới nhất và khởi động lại gateway trên bản đó
|
|
519
|
-
switch on
|
|
519
|
+
switch on # Khởi động gateway; mỗi công cụ lấy lại profile nó có trước `switch off`
|
|
520
|
+
switch on <profile> # Khởi động gateway và kích hoạt một profile
|
|
520
521
|
switch <profile> # Kích hoạt một profile cho cả hai công cụ
|
|
521
522
|
switch claude <profile> # Đặt profile kích hoạt riêng cho Claude Code
|
|
522
523
|
switch codex <profile> # Đặt profile kích hoạt riêng cho Codex
|
|
@@ -864,7 +865,7 @@ Nhờ cách chia này, fingerprint của provider không bao giờ là rule tron
|
|
|
864
865
|
| `LLM_SWITCHER_BIFROST_RETRY_MS` | Thời gian trước khi gateway hỏi lại intact sau một lần tra cứu Bifrost thất bại (mặc định 30000 ms). |
|
|
865
866
|
| Header `x-llm-profile: <key>` (tên khác `x-profile`) hoặc `?profile=<key>` | Định tuyến riêng 1 request qua profile chỉ định. Key không tồn tại trả HTTP 400 thay vì âm thầm dùng profile khác. |
|
|
866
867
|
| `profile.thinkingMode` | `auto` (mặc định, cho gateway như intact hoặc 9Router): phục hồi thinking bị xoá, inject hướng dẫn `<think>` cho model không có reasoning, gửi `thinking` + `reasoning_effort`, và gửi lại thinking trước đó thành `reasoning_content` ở lượt assistant có tool call. `native` (API OpenAI nghiêm ngặt): chỉ gửi `reasoning_effort` khi client yêu cầu, không sửa prompt, dùng `max_completion_tokens`; không gửi `reasoning_content` trừ khi profile đặt `replayReasoning: true`. `off`: không bao giờ gửi tham số reasoning. |
|
|
867
|
-
| `profile.replayReasoning` | `true`: ở chế độ `native`, gửi lại thinking trước đó thành `reasoning_content` ở lượt assistant có tool call. Thinking mode của DeepSeek và Kimi cần field này. Mặc định là `false`.
|
|
868
|
+
| `profile.replayReasoning` | `true`: ở chế độ `native`, gửi lại thinking trước đó thành `reasoning_content` ở lượt assistant có tool call. Thinking mode của DeepSeek và Kimi cần field này. Mặc định là `false`. Field chỉ được gửi khi request bật thinking. Ở `native`, thinking bật khi client yêu cầu. Ở `auto`, không cần cờ này: thinking bật khi client yêu cầu, hoặc khi model có reasoning sẵn và client không tắt thinking. `off` không bao giờ gửi field này. |
|
|
868
869
|
| `profile.thinkingEffort` | `auto` (mặc định, client tự quyết định), hoặc đặt cố định mức override: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra`. Ghi đè mức reasoning effort từ công cụ trước khi gửi lên upstream. Tự động ánh xạ về mức tương đương gần nhất mà upstream đích hỗ trợ (OpenAI trần `high`, Anthropic trần `max`, Gemini điều chỉnh `thinkingBudget`). Trên Web UI, tùy chọn này tự động ẩn cùng thông báo gợi ý khi profile bật chế độ Bifrost passthrough. |
|
|
869
870
|
| `profile.endpoints.countTokens` | Ghi đè URL `count_tokens` của Anthropic. |
|
|
870
871
|
| `profile.endpoints` | Ghi đè URL upstream theo từng format: `{ "openai-chat": "...", "anthropic": "...", "vertex": "https://.../models/{model}:{action}" }`. |
|
package/formats.mjs
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// }
|
|
25
25
|
// ============================================================
|
|
26
26
|
|
|
27
|
-
import { sanitizeToolName, toolNameMapForIR, rewriteToolNameMentions, rewriteToolSearchArgs } from './toolvocab.mjs';
|
|
27
|
+
import { sanitizeToolName, toolNameMapForIR, rewriteToolNameMentions, rewriteToolSearchArgs, createToolVocab, NULL_TOOL_VOCAB } from './toolvocab.mjs';
|
|
28
28
|
import { decodeCompaction, COMPACTION_INSTRUCTION } from './compaction.mjs';
|
|
29
29
|
|
|
30
30
|
export const OUT_FORMATS = ['openai-chat', 'anthropic', 'vertex'];
|
|
@@ -455,9 +455,22 @@ function anthropicToIR(payload) {
|
|
|
455
455
|
}
|
|
456
456
|
}
|
|
457
457
|
|
|
458
|
+
// Messages has no system role. Left in place, the emitters send such a turn as user text, so fold
|
|
459
|
+
// it into the system prompt, as responsesToIR and the Chat reader already do.
|
|
460
|
+
const foldSystemTurn = (msg) => {
|
|
461
|
+
if (msg.role !== 'system' && msg.role !== 'developer') return false;
|
|
462
|
+
const text =
|
|
463
|
+
typeof msg.content === 'string'
|
|
464
|
+
? msg.content
|
|
465
|
+
: (msg.content || []).filter(p => p && p.type === 'text').map(p => p.text).join('\n');
|
|
466
|
+
if (text.trim()) ir.system = ir.system ? `${ir.system}\n\n${text}` : text;
|
|
467
|
+
return true;
|
|
468
|
+
};
|
|
469
|
+
|
|
458
470
|
if (Array.isArray(payload.messages)) {
|
|
459
471
|
for (const msg of payload.messages) {
|
|
460
472
|
if (!msg) continue;
|
|
473
|
+
if (foldSystemTurn(msg)) continue;
|
|
461
474
|
if (typeof msg.content === 'string') {
|
|
462
475
|
ir.messages.push({ role: msg.role, content: msg.content });
|
|
463
476
|
continue;
|
|
@@ -693,10 +706,12 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
|
|
|
693
706
|
if (text) ir.system = ir.system ? `${ir.system}\n\n${text}` : text;
|
|
694
707
|
};
|
|
695
708
|
|
|
696
|
-
// A reasoning item precedes the assistant output it belongs to.
|
|
709
|
+
// A reasoning item precedes the assistant output it belongs to. A user message or a compaction item
|
|
710
|
+
// ends that turn, so reasoning still pending there came from an aborted turn and is dropped.
|
|
697
711
|
let reasoning = '';
|
|
712
|
+
const joinLines = (a, b) => (a ? `${a}\n${b}` : b);
|
|
698
713
|
const takeReasoning = (m) => {
|
|
699
|
-
if (reasoning && m?.role === 'assistant') { m.reasoning = (m.reasoning || ''
|
|
714
|
+
if (reasoning && m?.role === 'assistant') { m.reasoning = joinLines(m.reasoning || '', reasoning); reasoning = ''; }
|
|
700
715
|
};
|
|
701
716
|
|
|
702
717
|
// Parallel tool calls arrive as consecutive items -> merge into 1 assistant turn,
|
|
@@ -715,7 +730,7 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
|
|
|
715
730
|
ir.messages.push({ role: 'user', content: input });
|
|
716
731
|
} else if (Array.isArray(input)) {
|
|
717
732
|
for (const item of input) {
|
|
718
|
-
if (typeof item === 'string') { pushText('user', item); continue; }
|
|
733
|
+
if (typeof item === 'string') { reasoning = ''; pushText('user', item); continue; }
|
|
719
734
|
if (!item || typeof item !== 'object') continue;
|
|
720
735
|
// EasyInputMessage ({role, content}) doesn't require `type`.
|
|
721
736
|
if (item.type === 'message' || (!item.type && item.role)) {
|
|
@@ -739,6 +754,7 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
|
|
|
739
754
|
continue;
|
|
740
755
|
}
|
|
741
756
|
const role = item.role === 'assistant' ? 'assistant' : 'user';
|
|
757
|
+
if (role === 'user') reasoning = '';
|
|
742
758
|
if (parts.every(p => p.type === 'text')) {
|
|
743
759
|
pushText(role, parts.map(p => p.text).join('\n'));
|
|
744
760
|
} else {
|
|
@@ -747,7 +763,7 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
|
|
|
747
763
|
if (role === 'assistant') takeReasoning(ir.messages[ir.messages.length - 1]);
|
|
748
764
|
} else if (item.type === 'reasoning') {
|
|
749
765
|
const texts = (Array.isArray(item.summary) ? item.summary : []).map(x => x?.text).filter(t => typeof t === 'string' && t);
|
|
750
|
-
reasoning
|
|
766
|
+
if (texts.length) reasoning = joinLines(reasoning, texts.join('\n'));
|
|
751
767
|
} else if (item.type === 'function_call') {
|
|
752
768
|
pushCall({ id: item.call_id || item.id || null, name: responsesToolName(item.namespace, item.name), args: parseArgs(item.arguments) });
|
|
753
769
|
} else if (item.type === 'custom_tool_call') {
|
|
@@ -760,6 +776,7 @@ function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
|
|
|
760
776
|
} else if (item.type === 'additional_tools' && Array.isArray(item.tools)) {
|
|
761
777
|
toolList.push(...item.tools);
|
|
762
778
|
} else if (item.type === 'compaction') {
|
|
779
|
+
reasoning = '';
|
|
763
780
|
if (allowOpaqueCompaction && !item.encrypted_content?.startsWith('llm-gateway-compact-v1:')) { ir.opaqueCompaction = true; continue; }
|
|
764
781
|
pushText('user', `Conversation summary from an earlier turn:\n${decodeCompaction(item.encrypted_content)}`);
|
|
765
782
|
} else if (item.type === 'compaction_trigger') {
|
|
@@ -1560,6 +1577,112 @@ function collapseNullableUnion(branches) {
|
|
|
1560
1577
|
// key keeps the type an object; the root of a tool with no arguments may stay empty.
|
|
1561
1578
|
const GEMINI_PLACEHOLDER_PROPERTIES = { _placeholder: { type: 'boolean', description: 'Unused. Leave unset.' } };
|
|
1562
1579
|
|
|
1580
|
+
// A node the padding produced: its only property is the placeholder. Matched by shape, not by
|
|
1581
|
+
// reference, because the schema is deep-copied again before it is sent.
|
|
1582
|
+
function isGeminiPadded(schema) {
|
|
1583
|
+
const props = schema?.properties;
|
|
1584
|
+
const p = props?._placeholder;
|
|
1585
|
+
return Boolean(p && Object.keys(props).length === 1 && p.type === GEMINI_PLACEHOLDER_PROPERTIES._placeholder.type
|
|
1586
|
+
&& p.description === GEMINI_PLACEHOLDER_PROPERTIES._placeholder.description);
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// Removes `_placeholder` from the arguments wherever the schema sent upstream was padded, and only there.
|
|
1590
|
+
export function stripGeminiPlaceholders(value, schema) {
|
|
1591
|
+
if (!value || typeof value !== 'object' || !schema || typeof schema !== 'object') return value;
|
|
1592
|
+
if (Array.isArray(value)) {
|
|
1593
|
+
if (!schema.items) return value;
|
|
1594
|
+
const out = value.map(v => stripGeminiPlaceholders(v, schema.items));
|
|
1595
|
+
return out.some((v, i) => v !== value[i]) ? out : value;
|
|
1596
|
+
}
|
|
1597
|
+
if (isGeminiPadded(schema)) {
|
|
1598
|
+
if (!Object.hasOwn(value, '_placeholder')) return value;
|
|
1599
|
+
const { _placeholder, ...rest } = value;
|
|
1600
|
+
return rest;
|
|
1601
|
+
}
|
|
1602
|
+
let out = value;
|
|
1603
|
+
const props = schema.properties && typeof schema.properties === 'object' ? schema.properties : {};
|
|
1604
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1605
|
+
if (!Object.hasOwn(props, k)) continue;
|
|
1606
|
+
const next = stripGeminiPlaceholders(v, props[k]);
|
|
1607
|
+
if (next !== v) out = { ...out, [k]: next };
|
|
1608
|
+
}
|
|
1609
|
+
// A branch that declares a real `_placeholder` makes the key the client's own, so no padded branch strips it.
|
|
1610
|
+
const branches = Array.isArray(schema.anyOf) ? schema.anyOf : [];
|
|
1611
|
+
const declared = branches.some(b => !isGeminiPadded(b) && b?.properties && Object.hasOwn(b.properties, '_placeholder'));
|
|
1612
|
+
for (const branch of branches) if (!(declared && isGeminiPadded(branch))) out = stripGeminiPlaceholders(out, branch);
|
|
1613
|
+
return out;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
function hasGeminiPadding(schema) {
|
|
1617
|
+
if (!schema || typeof schema !== 'object') return false;
|
|
1618
|
+
if (isGeminiPadded(schema)) return true;
|
|
1619
|
+
return Object.values(schema.properties || {}).some(hasGeminiPadding) || hasGeminiPadding(schema.items)
|
|
1620
|
+
|| (Array.isArray(schema.anyOf) && schema.anyOf.some(hasGeminiPadding));
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
// The upstream tool name -> the schema sent, for each tool whose schema was padded. Reads both the
|
|
1624
|
+
// Vertex and the chat body shape; an empty map means no argument needs the strip.
|
|
1625
|
+
export function geminiPaddedTools(upBody) {
|
|
1626
|
+
const padded = new Map();
|
|
1627
|
+
for (const t of Array.isArray(upBody?.tools) ? upBody.tools : []) {
|
|
1628
|
+
const decls = Array.isArray(t?.functionDeclarations) ? t.functionDeclarations : t?.function ? [t.function] : [];
|
|
1629
|
+
for (const d of decls) if (d?.name && hasGeminiPadding(d.parameters)) padded.set(d.name, d.parameters);
|
|
1630
|
+
}
|
|
1631
|
+
return padded;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// The tool vocabulary, plus the strip for padded tools. A renderer holds the arguments of a tool
|
|
1635
|
+
// whose needsArgsRewrite is true until the JSON is whole, so the strip also works on a stream.
|
|
1636
|
+
export function withGeminiPlaceholderStrip(vocab, padded) {
|
|
1637
|
+
if (!padded?.size) return vocab;
|
|
1638
|
+
// By client name too. Two padded tools that one client name maps back to make that name
|
|
1639
|
+
// ambiguous, so it gets no strip, as the vocabulary refuses a tie.
|
|
1640
|
+
const byClient = new Map();
|
|
1641
|
+
for (const [upstream, schema] of padded) {
|
|
1642
|
+
const client = vocab.name(upstream);
|
|
1643
|
+
byClient.set(client, byClient.has(client) ? null : schema);
|
|
1644
|
+
}
|
|
1645
|
+
const schemaOf = (name) => padded.get(name) || byClient.get(vocab.name(name)) || byClient.get(name);
|
|
1646
|
+
const args = (name, argsJson) => {
|
|
1647
|
+
const out = vocab.args(name, argsJson);
|
|
1648
|
+
const schema = schemaOf(name);
|
|
1649
|
+
if (!schema) return out;
|
|
1650
|
+
let parsed;
|
|
1651
|
+
try { parsed = JSON.parse(out || '{}'); } catch { return out; }
|
|
1652
|
+
const stripped = stripGeminiPlaceholders(parsed, schema);
|
|
1653
|
+
return stripped === parsed ? out : JSON.stringify(stripped);
|
|
1654
|
+
};
|
|
1655
|
+
return {
|
|
1656
|
+
...vocab,
|
|
1657
|
+
args,
|
|
1658
|
+
needsArgsRewrite: (name) => Boolean(schemaOf(name)) || Boolean(vocab.needsArgsRewrite?.(name)),
|
|
1659
|
+
repair(upstream, argsJson) {
|
|
1660
|
+
const fixedName = vocab.name(upstream);
|
|
1661
|
+
return { name: fixedName, args: args(upstream, argsJson), renamed: fixedName !== upstream };
|
|
1662
|
+
}
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// The tool vocabulary the caller declared, so a model that answers with a name from its own
|
|
1667
|
+
// training is written back in the caller's words instead of ending the turn with "No such tool
|
|
1668
|
+
// available". Built once per request from the tools the caller actually sent, so a request that
|
|
1669
|
+
// declared none pays nothing and a turn where the model agreed with the list changes nothing.
|
|
1670
|
+
export function toolVocabForIR(ir, outFormat) {
|
|
1671
|
+
if (!ir || typeof ir !== 'object') return NULL_TOOL_VOCAB;
|
|
1672
|
+
if (ir._vocab?.outFormat !== outFormat) {
|
|
1673
|
+
const vocab = createToolVocab(ir.tools, { allowHyphens: outFormat !== 'vertex', names: [...toolNameMapForIR(ir).forward.keys()] });
|
|
1674
|
+
ir._vocab = { outFormat, vocab: withGeminiPlaceholderStrip(vocab, ir._geminiPadded) };
|
|
1675
|
+
}
|
|
1676
|
+
return ir._vocab.vocab;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
// Read by toolVocabForIR when the answer is rendered: the arguments lose the keys padding added.
|
|
1680
|
+
// Called on every emit, so a rebuild of the same IR never keeps the strip of an earlier body.
|
|
1681
|
+
export function recordGeminiPadding(ir, upBody, gemini) {
|
|
1682
|
+
ir._geminiPadded = gemini && Array.isArray(upBody?.tools) ? geminiPaddedTools(upBody) : null;
|
|
1683
|
+
ir._vocab = null;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1563
1686
|
function toGeminiSchema(schema, root, seen) {
|
|
1564
1687
|
const nested = Boolean(root);
|
|
1565
1688
|
const emptyProperties = () => (nested ? { ...GEMINI_PLACEHOLDER_PROPERTIES } : {});
|
|
@@ -1744,8 +1867,28 @@ function endsInUnsignedToolLoop(messages) {
|
|
|
1744
1867
|
function healAnthropicPayload(payload) {
|
|
1745
1868
|
const notes = [];
|
|
1746
1869
|
if (!payload || !Array.isArray(payload.messages)) return { payload, changed: false, notes };
|
|
1747
|
-
|
|
1748
|
-
|
|
1870
|
+
// Anthropic has no system role inside messages. Its text becomes a user turn in the same place, so it
|
|
1871
|
+
// never moves past an assistant turn; Step 0 merges it into a user turn beside it, Steps 1-3 keep a tool_result first.
|
|
1872
|
+
const src = [];
|
|
1873
|
+
let folded = false;
|
|
1874
|
+
let droppedSystem = false;
|
|
1875
|
+
for (const m of payload.messages) {
|
|
1876
|
+
if (m?.role === 'system') {
|
|
1877
|
+
const blocks = toAnthropicBlocks(m.content);
|
|
1878
|
+
const kept = blocks.filter(b => b.type === 'text' && !isBlankText(b));
|
|
1879
|
+
if (!kept.length || kept.length !== blocks.length) droppedSystem = true;
|
|
1880
|
+
if (kept.length) {
|
|
1881
|
+
src.push({ role: 'user', content: [{ type: 'text', text: `[system] ${kept.map(b => b.text).join('\n')}` }] });
|
|
1882
|
+
folded = true;
|
|
1883
|
+
}
|
|
1884
|
+
continue;
|
|
1885
|
+
}
|
|
1886
|
+
if (!m || (m.role !== 'user' && m.role !== 'assistant')) continue;
|
|
1887
|
+
src.push(m);
|
|
1888
|
+
}
|
|
1889
|
+
if (folded) notes.push('folded system messages into user turns');
|
|
1890
|
+
if (droppedSystem) notes.push('dropped system content with no text');
|
|
1891
|
+
let changed = folded || droppedSystem || src.length !== payload.messages.length;
|
|
1749
1892
|
|
|
1750
1893
|
let system = payload.system;
|
|
1751
1894
|
if (Array.isArray(system) && system.some(isBlankText)) {
|
|
@@ -2753,7 +2896,7 @@ function vertexUsage(prompt, completion, cached, reasoning) {
|
|
|
2753
2896
|
return u;
|
|
2754
2897
|
}
|
|
2755
2898
|
|
|
2756
|
-
function buildVertexMessage({ model, think, text, tools, finish, prompt, completion, cached, reasoning, sig, ids = false }) {
|
|
2899
|
+
function buildVertexMessage({ model, think, text, tools, finish, prompt, completion, cached, reasoning, sig, ids = false, vocab = null }) {
|
|
2757
2900
|
const parts = [];
|
|
2758
2901
|
const thinking = (think || []).join('');
|
|
2759
2902
|
if (thinking) {
|
|
@@ -2764,7 +2907,8 @@ function buildVertexMessage({ model, think, text, tools, finish, prompt, complet
|
|
|
2764
2907
|
const body = (text || []).join('');
|
|
2765
2908
|
if (body) parts.push({ text: body });
|
|
2766
2909
|
for (const tc of (tools || [])) {
|
|
2767
|
-
const
|
|
2910
|
+
const args = vocab && typeof tc.args === 'string' ? vocab.args(tc.name, tc.args) : tc.args;
|
|
2911
|
+
const part = { functionCall: { ...(ids && tc.id ? { id: tc.id } : {}), name: tc.name || 'tool', args: parseArgs(args) } };
|
|
2768
2912
|
const tsig = tc.sig || lookupToolSignature(tc.id);
|
|
2769
2913
|
if (tsig) part.thoughtSignature = tsig;
|
|
2770
2914
|
parts.push(part);
|
|
@@ -2778,7 +2922,7 @@ function buildVertexMessage({ model, think, text, tools, finish, prompt, complet
|
|
|
2778
2922
|
}
|
|
2779
2923
|
|
|
2780
2924
|
// ids: name each functionCall, as Code Assist does. Plain Vertex has no call id.
|
|
2781
|
-
function createVertexStream(emit, model, { ids = false } = {}) {
|
|
2925
|
+
function createVertexStream(emit, model, { ids = false, vocab = null } = {}) {
|
|
2782
2926
|
const cand = (parts) => emit(null, { candidates: [{ content: { role: 'model', parts }, index: 0 }], modelVersion: model });
|
|
2783
2927
|
// Tool args arrive from OpenAI/Anthropic as deltas; Vertex needs complete functionCalls -> buffer them, emit at the end.
|
|
2784
2928
|
const tools = new Map();
|
|
@@ -2805,7 +2949,8 @@ function createVertexStream(emit, model, { ids = false } = {}) {
|
|
|
2805
2949
|
const parts = [...tools.values()]
|
|
2806
2950
|
.filter(t => t.name)
|
|
2807
2951
|
.map(t => {
|
|
2808
|
-
const
|
|
2952
|
+
const args = vocab ? vocab.args(t.name, t.args || '{}') : (t.args || '{}');
|
|
2953
|
+
const part = { functionCall: { ...(ids && t.id ? { id: t.id } : {}), name: t.name, args: parseArgs(args) } };
|
|
2809
2954
|
const tsig = t.sig || lookupToolSignature(t.id);
|
|
2810
2955
|
if (tsig) part.thoughtSignature = tsig;
|
|
2811
2956
|
return part;
|
|
@@ -2830,10 +2975,10 @@ function codeAssistTrace() {
|
|
|
2830
2975
|
return Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
|
|
2831
2976
|
}
|
|
2832
2977
|
|
|
2833
|
-
function createCodeAssistStream(emit, model) {
|
|
2978
|
+
function createCodeAssistStream(emit, model, { vocab = null } = {}) {
|
|
2834
2979
|
const traceId = codeAssistTrace();
|
|
2835
2980
|
const responseId = `agy-${traceId}`;
|
|
2836
|
-
return createVertexStream((e, d) => emit(e, d?.error ? d : { response: { ...d, responseId }, traceId }), model, { ids: true });
|
|
2981
|
+
return createVertexStream((e, d) => emit(e, d?.error ? d : { response: { ...d, responseId }, traceId }), model, { ids: true, vocab });
|
|
2837
2982
|
}
|
|
2838
2983
|
|
|
2839
2984
|
function buildCodeAssistMessage(args) {
|
package/package.json
CHANGED
package/proxy.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { createFrameReader } from './blindfold/wsframe.mjs';
|
|
|
11
11
|
import {
|
|
12
12
|
OUT_FORMATS, parseToIR, emitUpstreamBody, irToAnthropicBody, createUpstreamNormalizer, createCollector,
|
|
13
13
|
createThinkTagSplitter, splitThinkTags, healAnthropicPayload, estimateTokens, THINKING_MODES, VALID_EFFORTS,
|
|
14
|
+
toolVocabForIR, recordGeminiPadding,
|
|
14
15
|
toGeminiSchema, isAntigravityModel, refusesImageInput, omitChatImages,
|
|
15
16
|
createAnthropicStream, createChatStream, createResponsesStream, createVertexStream,
|
|
16
17
|
buildAnthropicMessage, buildChatMessage, buildResponsesMessage, buildVertexMessage,
|
|
@@ -26,8 +27,7 @@ import {
|
|
|
26
27
|
codexModelEntry,
|
|
27
28
|
contractLabSettings, STATE_DIR, stopRecordedBlindfold
|
|
28
29
|
} from './state.mjs';
|
|
29
|
-
import { parseModelWindows, resolveProfileWindows, readLocalCodexWindows,
|
|
30
|
-
import { createToolVocab, NULL_TOOL_VOCAB, toolNameMapForIR } from './toolvocab.mjs';
|
|
30
|
+
import { parseModelWindows, resolveProfileWindows, readLocalCodexWindows, windowsCacheKey, cachedProfileWindows, createWindowsRefresher } from './windows.mjs';
|
|
31
31
|
import { readUpstreamPayloads } from './upstream-stream.mjs';
|
|
32
32
|
import { compactionResponse, emitCompaction, expandGatewayCompactions, collectCompaction } from './compaction.mjs';
|
|
33
33
|
import { classifyCodexRole, classifyClaudeTier, syncLocalCatalog, refreshCatalog, checkVersionAndRefresh } from './catalog.mjs';
|
|
@@ -386,10 +386,26 @@ function isContextOverflow(status, message) {
|
|
|
386
386
|
}
|
|
387
387
|
|
|
388
388
|
// Claude Code compacts on "prompt is too long: N tokens > M maximum", so the numbers lead when the upstream names them.
|
|
389
|
+
// A count may group its digits (131,072 / 131.072 / 131 072 / 131'072 / 1,31,072, also with U+00A0 or U+202F) or
|
|
390
|
+
// carry a k or m suffix. A dot before exactly three digits reads as a group, so 3.141 reads as 3141: the cost of
|
|
391
|
+
// reading 131.072. Any other decimal part (0.125, 3.14) with no k or m after it is no token count.
|
|
392
|
+
const TOKEN_COUNT = String.raw`(\d+(?:\.\d+)?[km](?![a-z])|[1-9]\d?(?:,\d\d)+,\d{3}(?![.,]?\d)|[1-9]\d{0,2}(?:[,. _'\u00a0\u202f]\d{3})+(?![.,]?\d)|\d+(?![.,]?\d))`;
|
|
393
|
+
// A bare "limit" may be a rate or a time, so it counts only when "tokens" follows the number.
|
|
394
|
+
const MAX_TOKENS_RE = new RegExp(String.raw`(?:maximum context length|context window|tokens? limit) (?:is |of )?${TOKEN_COUNT}|limit (?:is |of )?${TOKEN_COUNT}(?= ?tokens?\b)`, 'i');
|
|
395
|
+
const ASKED_TOKENS_RE = new RegExp(String.raw`(?:requested|resulted in|prompt (?:is|has|contains)|input (?:is|has)) (?:about )?` + TOKEN_COUNT, 'i');
|
|
396
|
+
|
|
397
|
+
function tokenCount(text) {
|
|
398
|
+
if (!text) return '';
|
|
399
|
+
const short = text.match(/^(\d+(?:\.\d+)?)([km])$/i);
|
|
400
|
+
const n = short ? Math.round(Number(short[1]) * (short[2].toLowerCase() === 'k' ? 1e3 : 1e6)) : Number(text.replace(/\D/g, ''));
|
|
401
|
+
return Number.isSafeInteger(n) && n > 0 ? String(n) : '';
|
|
402
|
+
}
|
|
403
|
+
|
|
389
404
|
function promptTooLongMessage(message) {
|
|
390
405
|
if (/^prompt is too long/i.test(message)) return message;
|
|
391
|
-
const
|
|
392
|
-
const
|
|
406
|
+
const m = message.match(MAX_TOKENS_RE);
|
|
407
|
+
const max = tokenCount(m?.[1] ?? m?.[2]);
|
|
408
|
+
const asked = tokenCount(message.match(ASKED_TOKENS_RE)?.[1]);
|
|
393
409
|
return max && asked ? `prompt is too long: ${asked} tokens > ${max} maximum (${message})` : `prompt is too long: ${message}`;
|
|
394
410
|
}
|
|
395
411
|
|
|
@@ -488,21 +504,11 @@ function upstreamEndpoint(profile, outFormat, model, stream, req) {
|
|
|
488
504
|
return { url: ov['openai-chat'] || `${base}/chat/completions`, headers };
|
|
489
505
|
}
|
|
490
506
|
|
|
491
|
-
// The tool vocabulary the caller declared, so a model that answers with a name from its own
|
|
492
|
-
// training is written back in the caller's words instead of ending the turn with "No such tool
|
|
493
|
-
// available". Built once per request from the tools the caller actually sent, so a request that
|
|
494
|
-
// declared none pays nothing and a turn where the model agreed with the list changes nothing.
|
|
495
|
-
function toolVocabFor(ir, outFormat) {
|
|
496
|
-
if (!ir || typeof ir !== 'object') return NULL_TOOL_VOCAB;
|
|
497
|
-
if (!ir._vocab) ir._vocab = createToolVocab(ir.tools, { allowHyphens: outFormat !== 'vertex', names: [...toolNameMapForIR(ir).forward.keys()] });
|
|
498
|
-
return ir._vocab;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
507
|
function clientRenderer(clientFormat, res, model, opts = {}) {
|
|
502
508
|
if (clientFormat === 'anthropic') return createAnthropicStream((e, d) => sendSSE(res, e, d), model, opts);
|
|
503
509
|
if (clientFormat === 'responses') return createResponsesStream((e, d) => sendSSE(res, e, d), model, opts);
|
|
504
|
-
if (clientFormat === 'vertex') return createVertexStream((e, d) => sendSSE(res, null, d), model);
|
|
505
|
-
if (clientFormat === 'codeassist') return createCodeAssistStream((e, d) => sendSSE(res, null, d), model);
|
|
510
|
+
if (clientFormat === 'vertex') return createVertexStream((e, d) => sendSSE(res, null, d), model, opts);
|
|
511
|
+
if (clientFormat === 'codeassist') return createCodeAssistStream((e, d) => sendSSE(res, null, d), model, opts);
|
|
506
512
|
return createChatStream((e, d) => sendSSE(res, null, d), model, opts);
|
|
507
513
|
}
|
|
508
514
|
|
|
@@ -781,7 +787,7 @@ async function maybeCompactIdle(req, clientFormat, payload, ir, profile, profile
|
|
|
781
787
|
const summaryModel = mapModel(policy.model, profile, clientFormat);
|
|
782
788
|
const summaryFormat = resolveOutFormat(profile, summaryModel);
|
|
783
789
|
askSummary({ ir: prefixIR, policy, profile, model: summaryModel,
|
|
784
|
-
build: (prof, model, summaryIR) => buildUpstreamRequest(prof, summaryFormat, summaryIR, model, req),
|
|
790
|
+
build: (prof, model, summaryIR) => buildUpstreamRequest(prof, summaryFormat, summaryIR, model, req, null, profileKey),
|
|
785
791
|
onDiagnostic: ({ code, status }) => console.warn(`[llm-switcher] idle compact ${key.slice(0, 12)}: ${code}${status ? ` (${status})` : ''}`),
|
|
786
792
|
}).then(summary => {
|
|
787
793
|
if (!summary) return;
|
|
@@ -828,24 +834,30 @@ function resolveThinkingEffort(profile, slot, mappedModel) {
|
|
|
828
834
|
return profile?.thinkingEffort || 'auto';
|
|
829
835
|
}
|
|
830
836
|
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
837
|
+
const refreshProfileWindows = createWindowsRefresher(STATE_DIR, { debug: (...a) => debugLog(...a) });
|
|
838
|
+
|
|
839
|
+
// A profile with an endpoints override sends its requests past baseURL, so its key must not go to baseURL/models.
|
|
840
|
+
const routesThroughOverride = (profile) => Object.values(profile?.endpoints || {}).some(Boolean);
|
|
841
|
+
|
|
842
|
+
// The output limit this profile's own model list names for this model, or 0 when no fresh cached
|
|
843
|
+
// list names one. With no fresh entry the list is read in the background; this request never waits.
|
|
844
|
+
function modelOutputLimit(profileKey, profile, mappedModel) {
|
|
834
845
|
if (!profile?.baseURL || !mappedModel) return 0;
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
846
|
+
if (routesThroughOverride(profile)) return 0;
|
|
847
|
+
const key = windowsCacheKey(profileKey, profile.baseURL);
|
|
848
|
+
const models = cachedProfileWindows(STATE_DIR, key);
|
|
849
|
+
if (!Object.keys(models).length) refreshProfileWindows(key, profile.baseURL, profile.apiKey);
|
|
850
|
+
const output = Number(models[mappedModel]?.output);
|
|
851
|
+
return output > 0 ? output : 0;
|
|
840
852
|
}
|
|
841
853
|
|
|
842
|
-
function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = null) {
|
|
854
|
+
function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = null, profileKey = null) {
|
|
843
855
|
const effort = resolveThinkingEffort(profile, slot, mappedModel);
|
|
844
856
|
const upBody = emitUpstreamBody(outFormat, ir, mappedModel, {
|
|
845
857
|
thinkingMode: profile.thinkingMode,
|
|
846
858
|
thinkingEffort: effort,
|
|
847
859
|
replayReasoning: profile.replayReasoning === true,
|
|
848
|
-
maxOutputTokens: modelOutputLimit(profile, mappedModel),
|
|
860
|
+
maxOutputTokens: modelOutputLimit(profileKey, profile, mappedModel),
|
|
849
861
|
});
|
|
850
862
|
if (upBody?.tools && Array.isArray(upBody.tools)) {
|
|
851
863
|
upBody.tools = cleanSchemaDeep(upBody.tools);
|
|
@@ -853,6 +865,7 @@ function buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot = n
|
|
|
853
865
|
upBody.tools = geminiSafeTools(upBody.tools);
|
|
854
866
|
}
|
|
855
867
|
}
|
|
868
|
+
recordGeminiPadding(ir, upBody, outFormat === 'vertex' || (outFormat === 'openai-chat' && isAntigravityModel(mappedModel)));
|
|
856
869
|
const { url, headers } = upstreamEndpoint(profile, outFormat, mappedModel, ir.stream, req);
|
|
857
870
|
return { url, headers, upBody };
|
|
858
871
|
}
|
|
@@ -1001,7 +1014,7 @@ async function executeGoalCheckGate({ req, res, clientFormat, payload, ir, profi
|
|
|
1001
1014
|
try {
|
|
1002
1015
|
const nativePromise = (async () => {
|
|
1003
1016
|
try {
|
|
1004
|
-
let url, headers, body;
|
|
1017
|
+
let url, headers, body, upBody = null;
|
|
1005
1018
|
if (bifrost) {
|
|
1006
1019
|
url = bifrostURL(profile, clientFormat, req, opts);
|
|
1007
1020
|
headers = bifrostHeaders(req, profile);
|
|
@@ -1019,16 +1032,18 @@ async function executeGoalCheckGate({ req, res, clientFormat, payload, ir, profi
|
|
|
1019
1032
|
body = Buffer.from(JSON.stringify(json), 'utf8');
|
|
1020
1033
|
} else {
|
|
1021
1034
|
const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
|
|
1022
|
-
const reqData = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
|
|
1035
|
+
const reqData = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot, profileKey);
|
|
1023
1036
|
url = reqData.url;
|
|
1024
1037
|
headers = reqData.headers;
|
|
1025
|
-
|
|
1038
|
+
upBody = reqData.upBody;
|
|
1026
1039
|
}
|
|
1027
1040
|
|
|
1028
1041
|
const traceId = clientFormat === 'codeassist' ? null : (probeTraceId(req) || contractLab.traceFor(mappedModel));
|
|
1029
1042
|
if (traceId) headers['x-intact-trace'] = traceId;
|
|
1030
1043
|
|
|
1031
|
-
const upRes =
|
|
1044
|
+
const upRes = upBody
|
|
1045
|
+
? await fetchUpstream(url, headers, upBody, gateAC.signal, outFormat, profileKey, mappedModel)
|
|
1046
|
+
: await fetch(url, { method: 'POST', headers, body, signal: gateAC.signal });
|
|
1032
1047
|
if (!upRes.ok) {
|
|
1033
1048
|
const errText = await upRes.text().catch(() => '');
|
|
1034
1049
|
return { malformed: true, error: `Upstream HTTP ${upRes.status}: ${errText.slice(0, 150)}` };
|
|
@@ -1302,7 +1317,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
1302
1317
|
return log({ status: 400, error: 'unsupported compaction state' });
|
|
1303
1318
|
}
|
|
1304
1319
|
const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
|
|
1305
|
-
const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
|
|
1320
|
+
const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot, profileKey);
|
|
1306
1321
|
if (traceId) headers['x-intact-trace'] = traceId;
|
|
1307
1322
|
debugLog(`[${profileKey}] ${clientFormat} -> ${outFormat} ${url} ::`, JSON.stringify(upBody).slice(0, 500));
|
|
1308
1323
|
|
|
@@ -1372,7 +1387,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
1372
1387
|
const out = clientMessage(clientFormat, {
|
|
1373
1388
|
model: requestedModel || mappedModel, think, text: [split.text], tools, toolMeta: ir.toolMeta,
|
|
1374
1389
|
finish: col.finish, prompt: col.prompt, completion, cached: col.cached,
|
|
1375
|
-
reasoning: col.reasoning, sig: col.sig, vocab:
|
|
1390
|
+
reasoning: col.reasoning, sig: col.sig, vocab: toolVocabForIR(ir, outFormat)
|
|
1376
1391
|
});
|
|
1377
1392
|
sendJson(res, 200, out);
|
|
1378
1393
|
answered = true;
|
|
@@ -1389,7 +1404,7 @@ async function handleConvert(clientFormat, req, res, bodyBuffer, opts = {}) {
|
|
|
1389
1404
|
'Connection': 'keep-alive',
|
|
1390
1405
|
'X-Accel-Buffering': 'no'
|
|
1391
1406
|
});
|
|
1392
|
-
const renderer = clientRenderer(clientFormat, res, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab:
|
|
1407
|
+
const renderer = clientRenderer(clientFormat, res, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab: toolVocabForIR(ir, outFormat) });
|
|
1393
1408
|
renderer.start();
|
|
1394
1409
|
const splitter = createThinkTagSplitter(t => renderer.think(t), t => renderer.text(t));
|
|
1395
1410
|
const streamError = await pumpStream(upstreamRes, { normalize, col, renderer, splitter, signal: ac.signal, sink: res, tag: profileKey });
|
|
@@ -1962,7 +1977,8 @@ async function servedModels(req) {
|
|
|
1962
1977
|
// still gets the cached numbers, because an entry with the template window is better than none.
|
|
1963
1978
|
async function modelWindowsFor(profileKey, profile) {
|
|
1964
1979
|
if (!profile?.baseURL) return {};
|
|
1965
|
-
|
|
1980
|
+
if (routesThroughOverride(profile)) return readLocalCodexWindows();
|
|
1981
|
+
const listed = await resolveProfileWindows(STATE_DIR, windowsCacheKey(profileKey, profile.baseURL), profile.baseURL, profile.apiKey);
|
|
1966
1982
|
if (Object.keys(listed).length) return listed;
|
|
1967
1983
|
return readLocalCodexWindows();
|
|
1968
1984
|
}
|
|
@@ -2385,7 +2401,7 @@ function sendWsFailed(socket, model, message, status = 500, tap = null, code = n
|
|
|
2385
2401
|
if (socket.writable) socket.write(encodeWsFrame(JSON.stringify(d)));
|
|
2386
2402
|
}, model || 'main');
|
|
2387
2403
|
renderer.start();
|
|
2388
|
-
renderer.error(message, code || responsesErrorCode(status));
|
|
2404
|
+
renderer.error(message, code || (isContextOverflow(status, message) ? 'context_length_exceeded' : responsesErrorCode(status)));
|
|
2389
2405
|
}
|
|
2390
2406
|
|
|
2391
2407
|
// Codex on WebSocket sends only the new items of a turn and names the turn before in
|
|
@@ -2573,7 +2589,7 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
|
|
|
2573
2589
|
let answered = false; // set only after a complete answer; the half upload depends on it
|
|
2574
2590
|
try {
|
|
2575
2591
|
const slot = resolveSlot(requestedModel, mappedModel, profile, clientFormat);
|
|
2576
|
-
const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot);
|
|
2592
|
+
const { url, headers, upBody } = buildUpstreamRequest(profile, outFormat, ir, mappedModel, req, slot, profileKey);
|
|
2577
2593
|
if (traceId) headers['x-intact-trace'] = traceId;
|
|
2578
2594
|
debugLog(`[${profileKey}:ws] ${clientFormat} -> ${outFormat} ${url} ::`, JSON.stringify(upBody).slice(0, 300));
|
|
2579
2595
|
|
|
@@ -2619,7 +2635,7 @@ async function handleWsResponseCreate(socket, payload, req, ac, history = null)
|
|
|
2619
2635
|
if (socket.writable) {
|
|
2620
2636
|
socket.write(encodeWsFrame(JSON.stringify(d)));
|
|
2621
2637
|
}
|
|
2622
|
-
}, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab:
|
|
2638
|
+
}, requestedModel || mappedModel, { toolMeta: ir.toolMeta, vocab: toolVocabForIR(ir, outFormat) });
|
|
2623
2639
|
|
|
2624
2640
|
renderer.start();
|
|
2625
2641
|
const splitter = createThinkTagSplitter(t => renderer.think(t), t => renderer.text(t));
|