llm-switcher 1.6.5 → 1.6.7
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 +12 -0
- package/compaction.mjs +72 -0
- package/docs/goal-check.md +75 -0
- package/formats.mjs +107 -35
- package/goalcheck-call.mjs +208 -0
- package/goalcheck.mjs +498 -0
- package/maintenance/gateway-upstream-watch/REPOSITORIES.md +64 -0
- package/maintenance/gateway-upstream-watch/SKILL.md +18 -0
- package/maintenance/gateway-upstream-watch/prompt.vi.md +11 -0
- package/maintenance/gateway-upstream-watch/references/repos.json +570 -0
- package/maintenance/gateway-upstream-watch/watch.py +222 -0
- package/package.json +1 -1
- package/preflight-check.mjs +188 -0
- package/proxy.mjs +393 -53
- package/shim.mjs +13 -2
- package/state.mjs +5 -0
- package/tests/codex-daemon.test.mjs +1 -0
- package/tests/compaction.e2e.test.mjs +79 -0
- package/tests/gateway-contract.test.mjs +59 -0
- package/tests/gateway.e2e.test.mjs +47 -0
- package/tests/goalcheck.e2e.test.mjs +316 -0
- package/tests/goalcheck.test.mjs +312 -0
- package/tests/preflight.test.mjs +119 -0
- package/tests/tool-repair.test.mjs +119 -2
- package/tests/update.test.mjs +2 -2
- package/tests/upstream-stream.test.mjs +30 -0
- package/toolvocab.mjs +59 -11
- package/ui.html +676 -1
- package/upstream-stream.mjs +37 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog — LLM Switcher
|
|
2
2
|
|
|
3
|
+
## Release 1.6.7 — 2026-10-09
|
|
4
|
+
|
|
5
|
+
- **Jev Goal Check Interception Gate:** Intercept Claude Code stopping condition evaluators (`/goal`) and verify task completion independently using Jev System One typed decision judgments (`complete`, `evidence`, `unfinished`). Fails closed on missing or unverified work, preventing premature completion claims when models merely edit reports rather than implementing fixes.
|
|
6
|
+
- **Dedicated Goal Check Dashboard (`#/goal-check`):** Manage Goal Check with provider source inheritance, custom URL and endpoint overrides, model combobox with auto-discovery, live connection test probe, fine-grained threshold controls, and real-time verification history.
|
|
7
|
+
- **Notice Shim Preflight Model & Credential Check:** Automatically verify upstream endpoint reachability, API key validity, and configured model availability whenever a tool session is launched from the notice shim (`claude`, `codex`, `agy`). Instantly alerts the developer in the terminal if an active model was deprecated or dropped upstream, preventing cryptic downstream errors.
|
|
8
|
+
- **Clear Gateway Error Messages:** Translate ambiguous upstream 404 (model not found) and 401 (invalid key) errors into actionable explanations directing users to reconfigure via `switch ui`.
|
|
9
|
+
|
|
10
|
+
## Release 1.6.6 — 2026-10-09
|
|
11
|
+
|
|
12
|
+
- **Consistent MCP Tool Aliasing across ToolSearch and History:** Ensure tool names in instructions, structured `ToolSearch` references, query history, and upstream declarations use the identical collision-resolved alias mapping across Chat, Anthropic, and Vertex conversions. Original client tool names are restored before Claude Code executes tool calls or queries `ToolSearch`, preventing mid-response stream rejections (`upstream called undeclared tool`) when interacting with MCP tools exceeding 64 characters (such as Chrome DevTools MCP).
|
|
13
|
+
- **Buffered Argument Restoration for Parallel Streaming Calls:** Hold streaming `ToolSearch` query arguments across Anthropic, Chat, and Responses renderers until JSON reassembly completes, ensuring query tool aliases are accurately restored to their original names while guaranteeing independent parallel calls flush without truncation.
|
|
14
|
+
|
|
3
15
|
## Release 1.6.5 — 2026-10-08
|
|
4
16
|
|
|
5
17
|
- **Automatic Tether Gate Disable for Claude Code (`CLAUDE_CODE_TETHER_LIVE=0`):** Automatically inject `CLAUDE_CODE_TETHER_LIVE=0` into `env-claude.sh` and `env-claude.cmd` whenever the Claude route is active, ensuring Claude Code runs in stateless mode and prevents `message-threads` validation failures on non-Anthropic upstreams. The shim cleans the variable on route deactivation, and the gateway safely rejects unexpected thread requests with `thread_unsupported_request` (HTTP 400).
|
package/compaction.mjs
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { readUpstreamPayloads } from './upstream-stream.mjs';
|
|
3
|
+
|
|
4
|
+
// A gateway capsule, not OpenAI ciphertext. Both intact and this gateway use this format.
|
|
5
|
+
export const COMPACTION_PREFIX = 'llm-gateway-compact-v1:';
|
|
6
|
+
export const COMPACTION_INSTRUCTION = 'Summarize this conversation so it can replace the history. Keep the user task, decisions and reasons, changed files and commands, resolved errors and pending work. Write a complete, concise summary that a continuing agent can use without the original transcript.';
|
|
7
|
+
|
|
8
|
+
export function encodeCompaction(summary) {
|
|
9
|
+
return COMPACTION_PREFIX + Buffer.from(JSON.stringify({ summary })).toString('base64url');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function decodeCompaction(value) {
|
|
13
|
+
if (typeof value !== 'string' || !value.startsWith(COMPACTION_PREFIX)) {
|
|
14
|
+
throw new Error('Cannot convert native or legacy compaction state; resume through its original Responses provider.');
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const encoded = value.slice(COMPACTION_PREFIX.length);
|
|
18
|
+
if (!/^[A-Za-z0-9_-]+$/.test(encoded)) throw new Error('invalid encoding');
|
|
19
|
+
const decoded = Buffer.from(encoded, 'base64url');
|
|
20
|
+
if (decoded.toString('base64url') !== encoded) throw new Error('invalid encoding');
|
|
21
|
+
const item = JSON.parse(decoded.toString('utf8'));
|
|
22
|
+
if (typeof item.summary !== 'string' || !item.summary.trim()) throw new Error('empty summary');
|
|
23
|
+
return item.summary;
|
|
24
|
+
} catch { throw new Error('Invalid gateway compaction capsule; history was not forwarded.'); }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function expandGatewayCompactions(payload) {
|
|
28
|
+
if (!Array.isArray(payload.input)) return payload;
|
|
29
|
+
let changed = false;
|
|
30
|
+
const input = payload.input.map(item => {
|
|
31
|
+
if (item?.type !== 'compaction' || !item.encrypted_content?.startsWith(COMPACTION_PREFIX)) return item;
|
|
32
|
+
changed = true;
|
|
33
|
+
return { type: 'message', role: 'user', content: [{ type: 'input_text', text: `Conversation summary from an earlier turn:\n${decodeCompaction(item.encrypted_content)}` }] };
|
|
34
|
+
});
|
|
35
|
+
return changed ? { ...payload, input } : payload;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function compactionResponse(col, model, splitText = text => text) {
|
|
39
|
+
const summary = splitText(col.text.join('')).trim();
|
|
40
|
+
if (col.error || col.finish !== 'stop' || col.tools.size || !summary) {
|
|
41
|
+
throw new Error('Compaction requires a complete text summary without tool calls; upstream returned incomplete or invalid output.');
|
|
42
|
+
}
|
|
43
|
+
if (Buffer.byteLength(summary, 'utf8') > 24 * 1024) throw new Error('Compaction summary exceeds the 24 KiB limit; history was not replaced.');
|
|
44
|
+
const item = { type: 'compaction', id: `cmp_${crypto.randomUUID()}`, encrypted_content: encodeCompaction(summary) };
|
|
45
|
+
return { id: `resp_${crypto.randomUUID()}`, object: 'response', status: 'completed', model, output: [item] };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function collectCompaction(upstreamRes, format, stream, normalize, col) {
|
|
49
|
+
let messageStopped = false;
|
|
50
|
+
const add = payload => {
|
|
51
|
+
const raw = payload.response || payload;
|
|
52
|
+
const reasons = format === 'openai-chat' ? (raw.choices || []).map(c => c.finish_reason)
|
|
53
|
+
: format === 'vertex' ? (raw.candidates || []).map(c => c.finishReason)
|
|
54
|
+
: [raw.stop_reason, raw.delta?.stop_reason];
|
|
55
|
+
const accepted = format === 'openai-chat' ? ['stop'] : format === 'vertex' ? ['STOP'] : ['end_turn', 'stop_sequence'];
|
|
56
|
+
if (reasons.some(r => r && !accepted.includes(r))) throw new Error('Upstream returned an incomplete compaction summary.');
|
|
57
|
+
if (raw.type === 'message_stop') messageStopped = true;
|
|
58
|
+
col.add(normalize(payload));
|
|
59
|
+
if (col.error) throw new Error(String(col.error));
|
|
60
|
+
if (Buffer.byteLength(col.text.join(''), 'utf8') > 24 * 1024) throw new Error('Compaction summary exceeds the 24 KiB limit.');
|
|
61
|
+
};
|
|
62
|
+
if (stream) {
|
|
63
|
+
for await (const payload of readUpstreamPayloads(upstreamRes)) add(payload);
|
|
64
|
+
if (format === 'anthropic' && !messageStopped) throw new Error('Compaction stream ended before message_stop.');
|
|
65
|
+
} else add(await upstreamRes.json());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function emitCompaction(response, emit) {
|
|
69
|
+
emit('response.created', { type: 'response.created', sequence_number: 0, response: { ...response, status: 'in_progress', output: [] } });
|
|
70
|
+
emit('response.output_item.done', { type: 'response.output_item.done', sequence_number: 1, output_index: 0, item: response.output[0] });
|
|
71
|
+
emit('response.completed', { type: 'response.completed', sequence_number: 2, response });
|
|
72
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Goal Check: Kiểm Tra Hoàn Thành Goal Bằng Jev System One
|
|
2
|
+
|
|
3
|
+
**Goal Check** là cơ chế cổng kiểm soát (gatekeeper/veto) độc lập tích hợp tại `llm-switcher`. Tính năng này bảo vệ các prompt-based stopping condition evaluator của Claude Code (đặc biệt là lệnh `/goal`), ngăn chặn các phán quyết hoàn thành sai (false completion) khi chưa có đủ bằng chứng thực thi bằng tool.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Vấn đề giải quyết
|
|
8
|
+
|
|
9
|
+
Trong Claude Code (v2.1.295), khi người dùng đặt `/goal`, hệ thống sử dụng một evaluator model riêng đọc transcript và trả về `{ok, reason, impossible?}`:
|
|
10
|
+
- **Lỗ hổng**: Evaluator chỉ đọc văn bản và dễ bị nhầm lẫn giữa việc *sửa báo cáo* (rút finding, ghi PASS audit) với việc *sửa code thật sự*.
|
|
11
|
+
- **Hệ quả**: Ngay khi evaluator trả về `ok: true`, Claude Code lập tức gỡ hook và ghi nhận `goal_status.met: true`.
|
|
12
|
+
- **Giải pháp**: `llm-switcher` chặn phản hồi của evaluator trước khi gửi về client, đồng thời gọi mô hình quyết định **Jev (System One typed decision API)** trên 3 trục độc lập:
|
|
13
|
+
1. `complete`: Toàn bộ công việc theo condition đã hoàn thành thực sự (ngưỡng mặc định: $\ge 0.90$).
|
|
14
|
+
2. `evidence`: Có tool-result trực tiếp chứng minh kết quả thực thi (ngưỡng mặc định: $\ge 0.85$).
|
|
15
|
+
3. `unfinished`: Còn công việc dang dở, lỗi chưa xử lý hoặc verification thiếu (ngưỡng mặc định: $\le 0.10$).
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 2. Nguyên tắc hoạt động & Quyết định hợp nhất
|
|
20
|
+
|
|
21
|
+
Gateway chỉ trả về `ok: true` khi **cả hai bên đồng thuận**:
|
|
22
|
+
- Evaluator gốc của Claude Code trả về `ok: true`.
|
|
23
|
+
- Jev xác nhận cả 3 điểm số đều đạt ngưỡng cấu hình và evidence không vượt kích thước tối đa.
|
|
24
|
+
|
|
25
|
+
| Native Evaluator | Jev / Evidence Checks | Trả về Claude Code | Ghi chú |
|
|
26
|
+
| :--- | :--- | :--- | :--- |
|
|
27
|
+
| `ok: false` | Bất kỳ | `ok: false` | Giữ nguyên lý do gốc của native |
|
|
28
|
+
| `ok: true` | Đạt cả 3 ngưỡng | `ok: true` | Cho phép hoàn thành lượt |
|
|
29
|
+
| `ok: true` | Một trong 3 trục không đạt | `ok: false` | Veto: Nêu rõ trục vi phạm (ví dụ `complete=0.14 < 0.90`) và yêu cầu cung cấp tool evidence |
|
|
30
|
+
| `ok: true` | Lỗi mạng / HTTP 5xx / timeout / thiếu key | `ok: false` | Fail-closed: Báo `Goal Check verification unavailable` |
|
|
31
|
+
| `ok: true` | State vượt quá `maxStateChars` | `ok: false` | Báo `Insufficient evidence` (state quá lớn) |
|
|
32
|
+
| `impossible: true` | Bất kỳ | `ok: false, impossible: true` | Giữ nguyên ngữ nghĩa thất bại của native |
|
|
33
|
+
|
|
34
|
+
> **Fail-closed**: Lỗi mạng, thiếu dữ liệu hoặc lỗi cấu hình Jev **không bao giờ** biến thành phán quyết hoàn thành.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## 3. Cấu hình qua Dashboard
|
|
39
|
+
|
|
40
|
+
Truy cập `# /goal-check` từ menu bên trái:
|
|
41
|
+
|
|
42
|
+
1. **Enable Goal Check**: Toggle bật/tắt (Mặc định: **OFF**).
|
|
43
|
+
2. **Model Provider Source**: Chọn profile có sẵn (như `intact-claude`) để kế thừa Base URL và API key, hoặc chọn *Custom Endpoint*.
|
|
44
|
+
3. **Base URL**: URL nhà cung cấp Jev (ví dụ: `https://intact.louispham.qzz.io/v1`).
|
|
45
|
+
4. **API Key**: Khóa truy cập (được che dạng masked khi hiển thị).
|
|
46
|
+
5. **Goal Check Model**: Model ID quyết định (mặc định: `typesafe/jev-latest`).
|
|
47
|
+
6. **Test Connection**: Probe thử nghiệm gửi payload quyết định mẫu nhỏ để xác minh kết nối và schema trả về.
|
|
48
|
+
7. **Save Configuration**: Lưu cấu hình an toàn vào `config.json`.
|
|
49
|
+
|
|
50
|
+
### Tùy chọn nâng cao (Advanced)
|
|
51
|
+
- **Decision Endpoint Override**: Cho phép ghi đè đường dẫn đầy đủ tới API quyết định (mặc định: `<Base URL>/systemone`).
|
|
52
|
+
- **Complete Score Threshold (`completeMin`)**: Ngưỡng hoàn thành tối thiểu (mặc định: 0.90).
|
|
53
|
+
- **Evidence Score Threshold (`evidenceMin`)**: Ngưỡng bằng chứng thực thi tối thiểu (mặc định: 0.85).
|
|
54
|
+
- **Unfinished Score Max (`unfinishedMax`)**: Trần công việc dang dở tối đa (mặc định: 0.10).
|
|
55
|
+
- **Timeout (`timeoutMs`)**: Thời gian chờ cuộc gọi Jev (mặc định: 8000 ms).
|
|
56
|
+
- **Total Gate Deadline (`totalTimeoutMs`)**: Tổng hạn ngạch thời gian của cổng (mặc định: 25000 ms).
|
|
57
|
+
- **Max State Evidence Budget (`maxStateChars`)**: Giới hạn ký tự transcript gửi Jev (mặc định: 60000 ký tự).
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 4. API Endpoints
|
|
62
|
+
|
|
63
|
+
Tất cả endpoint yêu cầu header `x-llm-switcher-token` (hoặc admin cookie):
|
|
64
|
+
- `GET /api/goal-check`: Lấy cấu hình (đã mask key) và trạng thái kết nối.
|
|
65
|
+
- `POST /api/goal-check`: Cập nhật cấu hình, lưu vào `config.json`.
|
|
66
|
+
- `POST /api/goal-check/test`: Thử nghiệm kết nối tới Jev endpoint.
|
|
67
|
+
- `GET /api/goal-check/logs`: Lấy danh sách ring buffer các lượt kiểm tra gần nhất.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## 5. Phạm vi & Giới hạn
|
|
72
|
+
|
|
73
|
+
- **Phạm vi bảo vệ**: Áp dụng cho các prompt stop-condition hook của Claude Code (bao gồm `/goal`).
|
|
74
|
+
- **Không áp dụng**: Request chat thông thường, Idle Compaction, Codex CLI, TaskUpdate/TaskCompleted.
|
|
75
|
+
- **Giới hạn mô hình**: Jev là mô hình quyết định dựa trên văn bản; không tự chạy lệnh, đọc file hay xem ảnh chụp màn hình trực tiếp. Nếu task đòi hỏi bằng chứng thị giác mà không có biểu diễn text tương đương, gate sẽ đánh giá là thiếu bằng chứng.
|
package/formats.mjs
CHANGED
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
// }
|
|
25
25
|
// ============================================================
|
|
26
26
|
|
|
27
|
-
import { sanitizeToolName } from './toolvocab.mjs';
|
|
27
|
+
import { sanitizeToolName, toolNameMapForIR, rewriteToolNameMentions, rewriteToolSearchArgs } from './toolvocab.mjs';
|
|
28
|
+
import { decodeCompaction, COMPACTION_INSTRUCTION } from './compaction.mjs';
|
|
28
29
|
|
|
29
30
|
export const OUT_FORMATS = ['openai-chat', 'anthropic', 'vertex'];
|
|
30
31
|
|
|
@@ -466,20 +467,30 @@ function anthropicToIR(payload) {
|
|
|
466
467
|
toolCalls.push({ id: part.id, name: part.name, args: part.input ?? {} });
|
|
467
468
|
} else if (part.type === 'tool_result') {
|
|
468
469
|
let resultText = '';
|
|
470
|
+
let resultParts;
|
|
471
|
+
const images = [];
|
|
469
472
|
if (typeof part.content === 'string') resultText = part.content;
|
|
470
473
|
else if (Array.isArray(part.content)) {
|
|
471
|
-
|
|
472
|
-
resultText = part.content.map(c => {
|
|
474
|
+
resultParts = part.content.map(c => {
|
|
473
475
|
if (typeof c === 'string') return c;
|
|
474
476
|
if (c?.type === 'text') return c.text || '';
|
|
475
|
-
if (c?.type === 'image')
|
|
477
|
+
if (c?.type === 'image') {
|
|
478
|
+
const url = c.source?.type === 'base64' ? `data:${c.source.media_type || 'image/jpeg'};base64,${c.source.data}`
|
|
479
|
+
: c.source?.type === 'url' ? c.source.url : null;
|
|
480
|
+
if (!url) throw new Error('Unsupported image in tool result');
|
|
481
|
+
images.push({ type: 'image_url', image_url: { url } });
|
|
482
|
+
return '';
|
|
483
|
+
}
|
|
484
|
+
if (c?.type === 'tool_reference' && typeof c.tool_name === 'string') return c;
|
|
476
485
|
return JSON.stringify(c);
|
|
477
|
-
}).filter(Boolean)
|
|
486
|
+
}).filter(Boolean);
|
|
487
|
+
resultText = resultParts.map(c => typeof c === 'string' ? c : JSON.stringify(c)).join('\n');
|
|
478
488
|
} else if (part.content) resultText = JSON.stringify(part.content);
|
|
479
489
|
if (part.is_error && !resultText.toLowerCase().startsWith('error')) {
|
|
480
490
|
resultText = `[Tool Error] ${resultText}`;
|
|
481
491
|
}
|
|
482
|
-
ir.messages.push({ role: 'tool', toolCallId: part.tool_use_id, content: resultText || '(empty tool output)' })
|
|
492
|
+
ir.messages.push({ role: 'tool', toolCallId: part.tool_use_id, content: resultText || '(empty tool output)', ...(images.length ? { images } : {}),
|
|
493
|
+
...(resultParts?.some(c => typeof c === 'object') ? { toolResultParts: resultParts, toolResultIsError: Boolean(part.is_error) } : {}) });
|
|
483
494
|
}
|
|
484
495
|
}
|
|
485
496
|
const out = { role: msg.role };
|
|
@@ -500,6 +511,7 @@ function anthropicToIR(payload) {
|
|
|
500
511
|
.filter(t => t && t.name)
|
|
501
512
|
.map(t => ({
|
|
502
513
|
name: t.name, description: t.description || '',
|
|
514
|
+
...(typeof t.strict === 'boolean' ? { strict: t.strict } : {}),
|
|
503
515
|
parameters: sanitizeJsonSchema(t.input_schema || {}),
|
|
504
516
|
// A client tool is `{"name","input_schema"}`, optionally typed "custom". A dated type is a
|
|
505
517
|
// server tool: Anthropic runs it, its result reaches the caller without the caller executing
|
|
@@ -645,7 +657,7 @@ function responsesOutputToText(out) {
|
|
|
645
657
|
}
|
|
646
658
|
|
|
647
659
|
// OpenAI Responses API (Codex) -> IR.
|
|
648
|
-
function responsesToIR(payload) {
|
|
660
|
+
function responsesToIR(payload, { allowOpaqueCompaction = false } = {}) {
|
|
649
661
|
const ir = baseIR();
|
|
650
662
|
ir.model = payload.model || '';
|
|
651
663
|
ir.stream = payload.stream === true;
|
|
@@ -720,6 +732,13 @@ function responsesToIR(payload) {
|
|
|
720
732
|
ir.messages.push({ role: 'tool', toolCallId: item.call_id || item.id, content: responsesOutputToText(item.output) });
|
|
721
733
|
} else if (item.type === 'additional_tools' && Array.isArray(item.tools)) {
|
|
722
734
|
toolList.push(...item.tools);
|
|
735
|
+
} else if (item.type === 'compaction') {
|
|
736
|
+
if (allowOpaqueCompaction && !item.encrypted_content?.startsWith('llm-gateway-compact-v1:')) { ir.opaqueCompaction = true; continue; }
|
|
737
|
+
pushText('user', `Conversation summary from an earlier turn:\n${decodeCompaction(item.encrypted_content)}`);
|
|
738
|
+
} else if (item.type === 'compaction_trigger') {
|
|
739
|
+
if (item !== input.at(-1)) throw new Error('compaction_trigger must be the last input item');
|
|
740
|
+
ir.compaction = true;
|
|
741
|
+
pushText('user', COMPACTION_INSTRUCTION);
|
|
723
742
|
}
|
|
724
743
|
}
|
|
725
744
|
}
|
|
@@ -737,7 +756,7 @@ function responsesToIR(payload) {
|
|
|
737
756
|
}
|
|
738
757
|
if (t.type === 'function' && t.name) {
|
|
739
758
|
const name = responsesToolName(namespace, t.name);
|
|
740
|
-
ir.tools.push({ name, description: t.description || '', parameters: sanitizeJsonSchema(t.parameters || {}) });
|
|
759
|
+
ir.tools.push({ name, description: t.description || '', parameters: sanitizeJsonSchema(t.parameters || {}), ...(typeof t.strict === 'boolean' ? { strict: t.strict } : {}) });
|
|
741
760
|
ir.toolMeta[name] = { kind: 'function', name: t.name, namespace: namespace || null };
|
|
742
761
|
} else if (t.type === 'custom' && t.name) {
|
|
743
762
|
const name = responsesToolName(namespace, t.name);
|
|
@@ -769,6 +788,7 @@ function responsesToIR(payload) {
|
|
|
769
788
|
ir.thinking = thinkingFromReasoningParam(payload.reasoning);
|
|
770
789
|
}
|
|
771
790
|
ir.responseFormat = responseFormatIR(payload.text?.format);
|
|
791
|
+
if (ir.compaction) { ir.tools = []; ir.toolChoice = 'none'; ir.responseFormat = null; }
|
|
772
792
|
return ir;
|
|
773
793
|
}
|
|
774
794
|
|
|
@@ -871,10 +891,10 @@ function lowerSchemaTypes(schema) {
|
|
|
871
891
|
// that reaches here anyway is an error, never a silent fallback to another protocol (a different
|
|
872
892
|
// API key!). The UPSTREAM formats are a different axis: emitUpstreamBody still speaks openai-chat,
|
|
873
893
|
// anthropic and vertex to whatever provider the profile points at.
|
|
874
|
-
function parseToIR(clientFormat, payload) {
|
|
894
|
+
function parseToIR(clientFormat, payload, opts = {}) {
|
|
875
895
|
switch (clientFormat) {
|
|
876
896
|
case 'anthropic': return anthropicToIR(payload);
|
|
877
|
-
case 'responses': return responsesToIR(payload);
|
|
897
|
+
case 'responses': return responsesToIR(payload, opts);
|
|
878
898
|
case 'codeassist': return codeAssistToIR(payload);
|
|
879
899
|
default: throw new Error(`Unsupported input format: ${clientFormat}. Only anthropic (Claude Code), responses (Codex) and codeassist (agy) accept requests.`);
|
|
880
900
|
}
|
|
@@ -909,16 +929,45 @@ function repairToolCall(tc, vocab) {
|
|
|
909
929
|
//
|
|
910
930
|
// Nothing is said about a tool fewer than two, because one tool has no other name to be confused
|
|
911
931
|
// with, and nothing about a tool the provider runs, because the caller never executes those.
|
|
912
|
-
function toolNamesNotice(ir, systemText) {
|
|
932
|
+
function toolNamesNotice(ir, systemText, toolName = sanitizeToolName) {
|
|
913
933
|
const names = (ir.tools || [])
|
|
914
934
|
.filter(t => t && typeof t.name === 'string' && t.name && t.side !== 'provider')
|
|
915
|
-
.map(t =>
|
|
935
|
+
.map(t => toolName(t.name));
|
|
916
936
|
if (names.length < 2) return '';
|
|
917
937
|
const missing = names.filter(n => !String(systemText || '').includes(n));
|
|
918
938
|
if (!missing.length) return '';
|
|
919
939
|
return `When you call a tool, use exactly these names and do not substitute another: ${missing.join(', ')}.`;
|
|
920
940
|
}
|
|
921
941
|
|
|
942
|
+
// ToolSearch references and tool instructions must use the vocabulary declared upstream.
|
|
943
|
+
// Keep the IR and user/tool data intact; only structured references and ToolSearch queries move.
|
|
944
|
+
function withUpstreamToolNames(ir, names) {
|
|
945
|
+
const declared = new Map(clientTools(ir).map(t => [t.name, names.name(t.name)]));
|
|
946
|
+
const mention = text => rewriteToolNameMentions(text, declared);
|
|
947
|
+
return {
|
|
948
|
+
...ir,
|
|
949
|
+
system: mention(ir.system),
|
|
950
|
+
tools: (ir.tools || []).map(t => t.side === 'provider' ? t : { ...t, description: mention(t.description) }),
|
|
951
|
+
messages: (ir.messages || []).map(m => {
|
|
952
|
+
const out = { ...m };
|
|
953
|
+
if (m.toolResultParts) {
|
|
954
|
+
let content = m.toolResultParts.map(c => typeof c === 'string' ? c
|
|
955
|
+
: JSON.stringify({ ...c, tool_name: declared.get(c.tool_name) || c.tool_name })).join('\n');
|
|
956
|
+
if (m.toolResultIsError && !content.toLowerCase().startsWith('error')) content = `[Tool Error] ${content}`;
|
|
957
|
+
out.content = content;
|
|
958
|
+
}
|
|
959
|
+
if (m.toolCalls) out.toolCalls = m.toolCalls.map(tc => {
|
|
960
|
+
if (tc.name !== 'ToolSearch') return tc;
|
|
961
|
+
let args;
|
|
962
|
+
try { args = typeof tc.args === 'string' ? JSON.parse(tc.args) : tc.args; } catch { return tc; }
|
|
963
|
+
const translated = rewriteToolSearchArgs(tc.name, args, declared);
|
|
964
|
+
return translated === args ? tc : { ...tc, args: typeof tc.args === 'string' ? JSON.stringify(translated) : translated };
|
|
965
|
+
});
|
|
966
|
+
return out;
|
|
967
|
+
})
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
|
|
922
971
|
function hasNativeReasoning(model) {
|
|
923
972
|
// model IDs change often, but all current Opus variants support reasoning.
|
|
924
973
|
const m = String(model || '').toLowerCase();
|
|
@@ -948,7 +997,8 @@ function healToolPairs(messages) {
|
|
|
948
997
|
|
|
949
998
|
const orphanText = (m) => ({
|
|
950
999
|
role: 'user',
|
|
951
|
-
content: `[Tool Result${m.toolCallId ? ` (${m.toolCallId})` : ''}]: ${m.content ?? ''}`
|
|
1000
|
+
content: m.images?.length ? [{ type: 'text', text: `[Tool Result${m.toolCallId ? ` (${m.toolCallId})` : ''}]: ${m.content ?? ''}` }, ...m.images]
|
|
1001
|
+
: `[Tool Result${m.toolCallId ? ` (${m.toolCallId})` : ''}]: ${m.content ?? ''}`
|
|
952
1002
|
});
|
|
953
1003
|
const flush = () => {
|
|
954
1004
|
if (pending) {
|
|
@@ -1023,7 +1073,12 @@ function normThinkingEffort(effort) {
|
|
|
1023
1073
|
// the client requests thinking, don't touch the system prompt, use `max_completion_tokens`.
|
|
1024
1074
|
// - 'off' : never send reasoning params, never inject prompts.
|
|
1025
1075
|
function irToChatBody(ir, model, opts = {}) {
|
|
1076
|
+
const names = toolNameMapForIR(ir);
|
|
1077
|
+
const toolName = names.name;
|
|
1078
|
+
ir = withUpstreamToolNames(ir, names);
|
|
1026
1079
|
const messages = [];
|
|
1080
|
+
const pendingImages = [];
|
|
1081
|
+
const flushImages = () => { if (pendingImages.length) messages.push({ role: 'user', content: pendingImages.splice(0) }); };
|
|
1027
1082
|
const mode = normThinkingMode(opts.thinkingMode);
|
|
1028
1083
|
const clientWantsThinking = Boolean(ir.thinking && ir.thinking.type !== 'disabled');
|
|
1029
1084
|
const modelIsThinking = hasNativeReasoning(model);
|
|
@@ -1044,20 +1099,22 @@ function irToChatBody(ir, model, opts = {}) {
|
|
|
1044
1099
|
// its tools in its system prompt (Claude Code does, at length) gets nothing at all, so there is no
|
|
1045
1100
|
// second list for the model to weigh against the first; a client with no such prompt gets the
|
|
1046
1101
|
// whole list. Fewer tools than two cannot be confused with each other, so nothing is said.
|
|
1047
|
-
const toolNotice = mode === 'auto' ? toolNamesNotice(ir, systemText) : '';
|
|
1102
|
+
const toolNotice = mode === 'auto' ? toolNamesNotice(ir, systemText, toolName) : '';
|
|
1048
1103
|
if (toolNotice) systemText = systemText ? `${systemText}\n\n${toolNotice}` : toolNotice;
|
|
1049
1104
|
if (systemText.trim()) messages.push({ role: 'system', content: systemText });
|
|
1050
1105
|
|
|
1051
1106
|
for (const m of healToolPairs(ir.messages)) {
|
|
1052
1107
|
if (m.role === 'tool') {
|
|
1053
1108
|
messages.push({ role: 'tool', tool_call_id: m.toolCallId, content: m.content ?? '' });
|
|
1109
|
+
if (m.images?.length) pendingImages.push({ type: 'text', text: `Images returned by tool call ${m.toolCallId}:` }, ...m.images);
|
|
1054
1110
|
continue;
|
|
1055
1111
|
}
|
|
1112
|
+
flushImages();
|
|
1056
1113
|
const out = { role: m.role === 'assistant' ? 'assistant' : 'user' };
|
|
1057
1114
|
if (m.content !== undefined) out.content = m.content;
|
|
1058
1115
|
if (m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length) {
|
|
1059
1116
|
out.tool_calls = m.toolCalls.map(tc => {
|
|
1060
|
-
const call = { id: tc.id, type: 'function', function: { name:
|
|
1117
|
+
const call = { id: tc.id, type: 'function', function: { name: toolName(tc.name), arguments: stringifyArgs(tc.args) } };
|
|
1061
1118
|
const sig = tc.sig || lookupToolSignature(tc.id);
|
|
1062
1119
|
if (sig) call.extra_content = { google: { thought_signature: sig } };
|
|
1063
1120
|
return call;
|
|
@@ -1067,6 +1124,7 @@ function irToChatBody(ir, model, opts = {}) {
|
|
|
1067
1124
|
if (out.content === undefined) out.content = '';
|
|
1068
1125
|
messages.push(out);
|
|
1069
1126
|
}
|
|
1127
|
+
flushImages();
|
|
1070
1128
|
|
|
1071
1129
|
const body = { model, messages, stream: ir.stream === true };
|
|
1072
1130
|
if (body.stream) body.stream_options = { include_usage: true };
|
|
@@ -1074,14 +1132,14 @@ function irToChatBody(ir, model, opts = {}) {
|
|
|
1074
1132
|
if (tools.length) {
|
|
1075
1133
|
body.tools = tools.map(t => ({
|
|
1076
1134
|
type: 'function',
|
|
1077
|
-
function: { name:
|
|
1135
|
+
function: { name: toolName(t.name), description: t.description || '', parameters: sanitizeJsonSchema(t.parameters || {}), ...(typeof t.strict === 'boolean' ? { strict: t.strict } : {}) }
|
|
1078
1136
|
}));
|
|
1079
1137
|
}
|
|
1080
1138
|
if (ir.toolChoice) {
|
|
1081
1139
|
if (typeof ir.toolChoice === 'string') {
|
|
1082
1140
|
body.tool_choice = ir.toolChoice === 'required' ? 'required' : ir.toolChoice;
|
|
1083
1141
|
} else if (ir.toolChoice.name) {
|
|
1084
|
-
body.tool_choice = { type: 'function', function: { name:
|
|
1142
|
+
body.tool_choice = { type: 'function', function: { name: toolName(ir.toolChoice.name) } };
|
|
1085
1143
|
}
|
|
1086
1144
|
}
|
|
1087
1145
|
if (typeof ir.params.maxTokens === 'number') {
|
|
@@ -1165,20 +1223,24 @@ function contentToAnthropicBlocks(c) {
|
|
|
1165
1223
|
|
|
1166
1224
|
// IR -> Anthropic Messages body.
|
|
1167
1225
|
function irToAnthropicBody(ir, model, opts = {}) {
|
|
1226
|
+
const names = toolNameMapForIR(ir);
|
|
1227
|
+
const toolName = names.name;
|
|
1228
|
+
ir = withUpstreamToolNames(ir, names);
|
|
1168
1229
|
const messages = [];
|
|
1169
1230
|
|
|
1170
1231
|
for (const m of healToolPairs(ir.messages)) {
|
|
1171
1232
|
if (m.role === 'tool') {
|
|
1172
1233
|
messages.push({
|
|
1173
1234
|
role: 'user',
|
|
1174
|
-
content: [{ type: 'tool_result', tool_use_id: m.toolCallId,
|
|
1235
|
+
content: [{ type: 'tool_result', tool_use_id: m.toolCallId,
|
|
1236
|
+
content: m.images?.length ? [...contentToAnthropicBlocks(m.content), ...contentToAnthropicBlocks(m.images)] : m.content ?? '' }]
|
|
1175
1237
|
});
|
|
1176
1238
|
continue;
|
|
1177
1239
|
}
|
|
1178
1240
|
if (m.role === 'assistant') {
|
|
1179
1241
|
const blocks = contentToAnthropicBlocks(m.content).filter(b => b.type === 'text');
|
|
1180
1242
|
for (const tc of (m.toolCalls || [])) {
|
|
1181
|
-
blocks.push({ type: 'tool_use', id: tc.id, name:
|
|
1243
|
+
blocks.push({ type: 'tool_use', id: tc.id, name: toolName(tc.name), input: parseArgs(tc.args) });
|
|
1182
1244
|
}
|
|
1183
1245
|
if (blocks.length) messages.push({ role: 'assistant', content: blocks });
|
|
1184
1246
|
continue;
|
|
@@ -1222,15 +1284,15 @@ function irToAnthropicBody(ir, model, opts = {}) {
|
|
|
1222
1284
|
const anthropicTools = clientTools(ir);
|
|
1223
1285
|
if (anthropicTools.length) {
|
|
1224
1286
|
body.tools = anthropicTools.map(t => ({
|
|
1225
|
-
name:
|
|
1226
|
-
input_schema: sanitizeJsonSchema(t.parameters || {})
|
|
1287
|
+
name: toolName(t.name), description: t.description || '',
|
|
1288
|
+
input_schema: sanitizeJsonSchema(t.parameters || {}), ...(typeof t.strict === 'boolean' ? { strict: t.strict } : {})
|
|
1227
1289
|
}));
|
|
1228
1290
|
}
|
|
1229
1291
|
if (ir.toolChoice) {
|
|
1230
1292
|
if (typeof ir.toolChoice === 'string') {
|
|
1231
1293
|
body.tool_choice = ir.toolChoice === 'required' ? { type: 'any' } : { type: ir.toolChoice };
|
|
1232
1294
|
} else if (ir.toolChoice.name) {
|
|
1233
|
-
body.tool_choice = { type: 'tool', name:
|
|
1295
|
+
body.tool_choice = { type: 'tool', name: toolName(ir.toolChoice.name) };
|
|
1234
1296
|
}
|
|
1235
1297
|
}
|
|
1236
1298
|
if (ir.params.parallelToolCalls === false && body.tool_choice) body.tool_choice.disable_parallel_tool_use = true;
|
|
@@ -1286,7 +1348,12 @@ function dataUrlToInlineData(url) {
|
|
|
1286
1348
|
|
|
1287
1349
|
// IR -> Vertex generateContent body.
|
|
1288
1350
|
function irToVertexBody(ir, model, opts = {}) {
|
|
1351
|
+
const names = toolNameMapForIR(ir, { allowHyphens: false });
|
|
1352
|
+
const toolName = names.name;
|
|
1353
|
+
ir = withUpstreamToolNames(ir, names);
|
|
1289
1354
|
const contents = [];
|
|
1355
|
+
const pendingImages = [];
|
|
1356
|
+
const flushImages = () => { if (pendingImages.length) push('user', pendingImages.splice(0)); };
|
|
1290
1357
|
const textPartsOf = (c) => {
|
|
1291
1358
|
if (typeof c === 'string') return c ? [{ text: c }] : [];
|
|
1292
1359
|
if (Array.isArray(c)) {
|
|
@@ -1295,7 +1362,8 @@ function irToVertexBody(ir, model, opts = {}) {
|
|
|
1295
1362
|
if (p.type === 'text' && p.text) parts.push({ text: p.text });
|
|
1296
1363
|
else if (p.type === 'image_url') {
|
|
1297
1364
|
const inline = dataUrlToInlineData(p.image_url?.url);
|
|
1298
|
-
if (inline)
|
|
1365
|
+
if (!inline) throw new Error('Vertex image conversion requires a base64 data URL; remote image URLs are not supported on this route.');
|
|
1366
|
+
parts.push(inline);
|
|
1299
1367
|
}
|
|
1300
1368
|
}
|
|
1301
1369
|
return parts;
|
|
@@ -1329,13 +1397,15 @@ function irToVertexBody(ir, model, opts = {}) {
|
|
|
1329
1397
|
resp = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : { result: parsed ?? '' };
|
|
1330
1398
|
} catch { resp = { result: m.content ?? '' }; }
|
|
1331
1399
|
// Gemini API & Vertex only accept roles 'user' | 'model' (functionResponse lives in the 'user' role).
|
|
1332
|
-
push('user', [{ functionResponse: { name:
|
|
1400
|
+
push('user', [{ functionResponse: { name: toolName(m.name || 'tool'), response: resp } }]);
|
|
1401
|
+
if (m.images?.length) pendingImages.push({ text: `Images returned by tool call ${m.toolCallId}:` }, ...textPartsOf(m.images));
|
|
1333
1402
|
continue;
|
|
1334
1403
|
}
|
|
1404
|
+
flushImages();
|
|
1335
1405
|
if (m.role === 'assistant') {
|
|
1336
1406
|
const parts = textPartsOf(m.content).filter(p => p.text);
|
|
1337
1407
|
(m.toolCalls || []).forEach((tc, j) => {
|
|
1338
|
-
const part = { functionCall: { name:
|
|
1408
|
+
const part = { functionCall: { name: toolName(tc.name), args: parseArgs(tc.args) } };
|
|
1339
1409
|
const sig = tc.sig || lookupToolSignature(tc.id);
|
|
1340
1410
|
if (sig) part.thoughtSignature = sig;
|
|
1341
1411
|
// Only the first functionCall of each step in the current turn is checked.
|
|
@@ -1347,6 +1417,7 @@ function irToVertexBody(ir, model, opts = {}) {
|
|
|
1347
1417
|
}
|
|
1348
1418
|
push('user', textPartsOf(m.content));
|
|
1349
1419
|
}
|
|
1420
|
+
flushImages();
|
|
1350
1421
|
|
|
1351
1422
|
const body = { contents };
|
|
1352
1423
|
if (ir.system && ir.system.trim()) {
|
|
@@ -1356,7 +1427,7 @@ function irToVertexBody(ir, model, opts = {}) {
|
|
|
1356
1427
|
if (vertexTools.length) {
|
|
1357
1428
|
body.tools = [{
|
|
1358
1429
|
functionDeclarations: vertexTools.map(t => ({
|
|
1359
|
-
name:
|
|
1430
|
+
name: toolName(t.name), description: t.description || '',
|
|
1360
1431
|
parameters: toGeminiSchema(t.parameters || { type: 'object', properties: {} })
|
|
1361
1432
|
}))
|
|
1362
1433
|
}];
|
|
@@ -1364,7 +1435,7 @@ function irToVertexBody(ir, model, opts = {}) {
|
|
|
1364
1435
|
if (ir.toolChoice) {
|
|
1365
1436
|
const mode = ir.toolChoice === 'none' ? 'NONE' : ir.toolChoice === 'auto' ? 'AUTO' : 'ANY';
|
|
1366
1437
|
const fcc = { mode };
|
|
1367
|
-
if (typeof ir.toolChoice === 'object' && ir.toolChoice.name) fcc.allowedFunctionNames = [
|
|
1438
|
+
if (typeof ir.toolChoice === 'object' && ir.toolChoice.name) fcc.allowedFunctionNames = [toolName(ir.toolChoice.name)];
|
|
1368
1439
|
if (body.tools) body.toolConfig = { functionCallingConfig: fcc };
|
|
1369
1440
|
}
|
|
1370
1441
|
const gc = {};
|
|
@@ -2017,6 +2088,7 @@ function createAnthropicStream(emit, model, opts = {}) {
|
|
|
2017
2088
|
// the block is never closed with arguments still pending and never has two deltas fighting over it.
|
|
2018
2089
|
function flushBuffered(st) {
|
|
2019
2090
|
if (!st.buffered) return;
|
|
2091
|
+
if (st.held && vocab) st.buffered = vocab.args(st.name, st.buffered);
|
|
2020
2092
|
emit('content_block_delta', {
|
|
2021
2093
|
type: 'content_block_delta', index: st.index,
|
|
2022
2094
|
delta: { type: 'input_json_delta', partial_json: st.buffered }
|
|
@@ -2076,8 +2148,8 @@ function createAnthropicStream(emit, model, opts = {}) {
|
|
|
2076
2148
|
const isShort = Boolean(vocab && typeof vocab.isShortened === 'function' && vocab.isShortened(tc.name));
|
|
2077
2149
|
st = {
|
|
2078
2150
|
index: nextIndex++, id: tc.id || `toolu_${rand(24)}`, name: fixed || 'tool', closed: false,
|
|
2079
|
-
//
|
|
2080
|
-
buffered: '', held: Boolean(vocab && fixed && fixed !== tc.name && !isShort)
|
|
2151
|
+
// Shortened names keep caller's schema; ToolSearch query aliases still need restoring.
|
|
2152
|
+
buffered: '', held: Boolean(vocab && ((fixed && fixed !== tc.name && !isShort) || vocab.needsArgsRewrite?.(tc.name)))
|
|
2081
2153
|
};
|
|
2082
2154
|
tools.set(key, st);
|
|
2083
2155
|
emit('content_block_start', {
|
|
@@ -2167,9 +2239,10 @@ function createChatStream(emit, model, opts = {}) {
|
|
|
2167
2239
|
};
|
|
2168
2240
|
// Chat has no rule about which block a delta belongs to, so held arguments go out at the end of the
|
|
2169
2241
|
// turn. A key can only be renamed once the whole object exists.
|
|
2170
|
-
const flushHeld = () => {
|
|
2242
|
+
const flushHeld = (only) => {
|
|
2171
2243
|
for (const st of seenTools.values()) {
|
|
2172
|
-
if (!st.held) continue;
|
|
2244
|
+
if (!st.held || (only && only !== st)) continue;
|
|
2245
|
+
if (vocab) st.buffered = vocab.args(st.name, st.buffered);
|
|
2173
2246
|
chunk([{ index: 0, delta: { tool_calls: [{ index: st.idx, function: { arguments: st.buffered } }] }, finish_reason: null }]);
|
|
2174
2247
|
st.held = false;
|
|
2175
2248
|
st.buffered = '';
|
|
@@ -2190,7 +2263,7 @@ function createChatStream(emit, model, opts = {}) {
|
|
|
2190
2263
|
if (!seenTools.has(idx)) {
|
|
2191
2264
|
const name = vocab ? vocab.name(tc.name) : tc.name;
|
|
2192
2265
|
const isShort = Boolean(vocab && typeof vocab.isShortened === 'function' && vocab.isShortened(tc.name));
|
|
2193
|
-
seenTools.set(idx, { idx, name, buffered: '', held: Boolean(vocab && name && name !== tc.name && !isShort) });
|
|
2266
|
+
seenTools.set(idx, { idx, name, buffered: '', held: Boolean(vocab && ((name && name !== tc.name && !isShort) || vocab.needsArgsRewrite?.(tc.name))) });
|
|
2194
2267
|
chunk([{ index: 0, delta: { tool_calls: [{ index: idx, id: tc.id || `call_${rand(24)}`, type: 'function', function: { name: name || 'tool', arguments: '' } }] }, finish_reason: null }]);
|
|
2195
2268
|
}
|
|
2196
2269
|
const st = seenTools.get(idx);
|
|
@@ -2203,8 +2276,7 @@ function createChatStream(emit, model, opts = {}) {
|
|
|
2203
2276
|
const repaired = vocab.args(st.name, st.buffered);
|
|
2204
2277
|
if (repaired !== st.buffered) {
|
|
2205
2278
|
st.buffered = repaired;
|
|
2206
|
-
st
|
|
2207
|
-
flushHeld();
|
|
2279
|
+
flushHeld(st);
|
|
2208
2280
|
}
|
|
2209
2281
|
},
|
|
2210
2282
|
finish(canonical, stats = {}) {
|
|
@@ -2402,7 +2474,7 @@ function createResponsesStream(emit, model, opts = {}) {
|
|
|
2402
2474
|
st = {
|
|
2403
2475
|
id: `${prefix}_${rand(24)}`, callId: tc.id || `call_${rand(24)}`, index: nextOutput++,
|
|
2404
2476
|
name: name || '', args: '', done: false, kind, upstream: tc.name,
|
|
2405
|
-
held: Boolean(vocab && name && name !== tc.name && !isShort)
|
|
2477
|
+
held: Boolean(vocab && ((name && name !== tc.name && !isShort) || vocab.needsArgsRewrite?.(tc.name)))
|
|
2406
2478
|
};
|
|
2407
2479
|
tools.set(key, st);
|
|
2408
2480
|
if (kind !== 'local_shell') {
|