llm-switcher 1.6.6 → 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 CHANGED
@@ -1,5 +1,12 @@
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
+
3
10
  ## Release 1.6.6 — 2026-10-09
4
11
 
5
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).
@@ -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.
@@ -0,0 +1,208 @@
1
+ // Network transport and schema validation for Jev System One decision API.
2
+ import { buildJevQuestions } from './goalcheck.mjs';
3
+
4
+ /**
5
+ * Resolve effective URL and API key for Jev decision call.
6
+ */
7
+ export function resolveJevTarget(policy = {}, profiles = {}) {
8
+ let url = '';
9
+ let apiKey = policy.apiKey || '';
10
+ const model = policy.model || 'typesafe/jev-latest';
11
+
12
+ const sourceProf = policy.sourceProfile ? profiles[policy.sourceProfile] : null;
13
+
14
+ if (policy.decisionEndpoint && /^https?:\/\//i.test(policy.decisionEndpoint)) {
15
+ url = policy.decisionEndpoint;
16
+ } else if (policy.decisionEndpoint && policy.baseURL) {
17
+ const base = policy.baseURL.replace(/\/+$/, '');
18
+ const path = policy.decisionEndpoint.replace(/^\/+/, '');
19
+ url = `${base}/${path}`;
20
+ } else if (policy.baseURL) {
21
+ url = `${policy.baseURL.replace(/\/+$/, '')}/systemone`;
22
+ } else if (sourceProf && sourceProf.baseURL) {
23
+ url = `${sourceProf.baseURL.replace(/\/+$/, '')}/systemone`;
24
+ }
25
+
26
+ if (!apiKey && sourceProf && sourceProf.apiKey) {
27
+ // Only inherit key if not overridden to a different host
28
+ apiKey = sourceProf.apiKey;
29
+ }
30
+
31
+ return { url, apiKey, model };
32
+ }
33
+
34
+ /**
35
+ * Extract probability / score in [0, 1] from a Jev typed answer.
36
+ */
37
+ export function extractScoreFromAnswer(answer) {
38
+ if (!answer || typeof answer !== 'object') return Number.NaN;
39
+ if (typeof answer.noul === 'number' && Number.isFinite(answer.noul)) {
40
+ return answer.noul;
41
+ }
42
+ if (typeof answer.score === 'number' && Number.isFinite(answer.score)) {
43
+ return answer.score;
44
+ }
45
+ if (answer.probabilities && typeof answer.probabilities.yes === 'number' && Number.isFinite(answer.probabilities.yes)) {
46
+ return answer.probabilities.yes;
47
+ }
48
+ if (answer.choice === 'yes' && typeof answer.confidence === 'number' && Number.isFinite(answer.confidence)) {
49
+ return answer.confidence;
50
+ }
51
+ if (answer.choice === 'no' && typeof answer.confidence === 'number' && Number.isFinite(answer.confidence)) {
52
+ return Math.round((1 - answer.confidence) * 1e6) / 1e6;
53
+ }
54
+ return Number.NaN;
55
+ }
56
+
57
+ /**
58
+ * Call Jev System One decision endpoint.
59
+ */
60
+ export async function callJev({
61
+ url,
62
+ model,
63
+ apiKey,
64
+ state,
65
+ questions,
66
+ timeoutMs = 8000,
67
+ signal,
68
+ fetchImpl = globalThis.fetch
69
+ }) {
70
+ if (!url) {
71
+ return { ok: false, reason: 'missing-url', message: 'No decision URL configured' };
72
+ }
73
+ if (!apiKey) {
74
+ return { ok: false, reason: 'no-key', message: 'No API key configured for decision endpoint' };
75
+ }
76
+
77
+ const ac = new AbortController();
78
+ const onAbort = () => ac.abort();
79
+ if (signal) {
80
+ if (signal.aborted) ac.abort();
81
+ else signal.addEventListener('abort', onAbort, { once: true });
82
+ }
83
+
84
+ const effectiveTimeout = Number.isFinite(timeoutMs) ? Math.max(500, Math.min(60000, timeoutMs)) : 8000;
85
+ const timer = setTimeout(() => ac.abort(), effectiveTimeout);
86
+ const startTime = Date.now();
87
+
88
+ try {
89
+ const res = await fetchImpl(url, {
90
+ method: 'POST',
91
+ headers: {
92
+ 'Content-Type': 'application/json',
93
+ Authorization: `Bearer ${apiKey}`
94
+ },
95
+ body: JSON.stringify({ model, state, questions }),
96
+ signal: ac.signal
97
+ });
98
+
99
+ const latencyMs = Date.now() - startTime;
100
+
101
+ if (!res.ok) {
102
+ const errBody = await res.text().catch(() => '');
103
+ return {
104
+ ok: false,
105
+ reason: `http-${res.status}`,
106
+ message: `HTTP ${res.status}: ${errBody.slice(0, 150)}`,
107
+ latencyMs
108
+ };
109
+ }
110
+
111
+ let data;
112
+ try {
113
+ data = JSON.parse(await res.text());
114
+ } catch {
115
+ return { ok: false, reason: 'bad-response', message: 'Response was not valid JSON', latencyMs };
116
+ }
117
+
118
+ if (!data || typeof data !== 'object' || !data.answers || typeof data.answers !== 'object') {
119
+ return { ok: false, reason: 'bad-response', message: 'Response missing answers map', latencyMs };
120
+ }
121
+
122
+ const completeScore = extractScoreFromAnswer(data.answers.complete);
123
+ const evidenceScore = extractScoreFromAnswer(data.answers.evidence);
124
+ const unfinishedScore = extractScoreFromAnswer(data.answers.unfinished);
125
+
126
+ if (Number.isNaN(completeScore) || Number.isNaN(evidenceScore) || Number.isNaN(unfinishedScore)) {
127
+ return {
128
+ ok: false,
129
+ reason: 'missing-answers',
130
+ message: 'One or more required answer scores (complete, evidence, unfinished) are missing or NaN',
131
+ latencyMs,
132
+ raw: data
133
+ };
134
+ }
135
+
136
+ return {
137
+ ok: true,
138
+ scores: {
139
+ complete: completeScore,
140
+ evidence: evidenceScore,
141
+ unfinished: unfinishedScore
142
+ },
143
+ model: data.model || model,
144
+ latencyMs,
145
+ raw: data
146
+ };
147
+ } catch (err) {
148
+ const latencyMs = Date.now() - startTime;
149
+ const isTimeout = ac.signal.aborted;
150
+ return {
151
+ ok: false,
152
+ reason: isTimeout ? 'timeout' : 'network',
153
+ message: isTimeout ? `Request timed out after ${effectiveTimeout}ms` : (err.message || 'Network error'),
154
+ latencyMs
155
+ };
156
+ } finally {
157
+ clearTimeout(timer);
158
+ if (signal) signal.removeEventListener('abort', onAbort);
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Probe connection with a minimal synthetic decision query.
164
+ */
165
+ export async function testJevConnection({
166
+ baseURL,
167
+ decisionEndpoint,
168
+ apiKey,
169
+ model,
170
+ sourceProfile,
171
+ profiles = {},
172
+ timeoutMs = 8000,
173
+ fetchImpl = globalThis.fetch
174
+ }) {
175
+ const policy = { baseURL, decisionEndpoint, apiKey, model, sourceProfile };
176
+ const target = resolveJevTarget(policy, profiles);
177
+
178
+ if (!target.url) {
179
+ return { ok: false, error: 'Could not resolve decision URL. Specify Base URL or choose a profile.' };
180
+ }
181
+ if (!target.apiKey) {
182
+ return { ok: false, error: 'No API key provided or available from selected profile.' };
183
+ }
184
+
185
+ const testState = 'The user asked to add a unit test for password validation. The assistant created tests/auth.test.mjs and ran node --test tests/auth.test.mjs, which passed with 0 errors.';
186
+ const testQuestions = buildJevQuestions('Add a unit test for password validation');
187
+
188
+ const res = await callJev({
189
+ url: target.url,
190
+ model: target.model,
191
+ apiKey: target.apiKey,
192
+ state: testState,
193
+ questions: testQuestions,
194
+ timeoutMs,
195
+ fetchImpl
196
+ });
197
+
198
+ if (!res.ok) {
199
+ return { ok: false, error: res.message || res.reason || 'Probe failed', latencyMs: res.latencyMs };
200
+ }
201
+
202
+ return {
203
+ ok: true,
204
+ message: `Connected successfully to ${target.model} at ${target.url} (${res.latencyMs}ms)`,
205
+ latencyMs: res.latencyMs,
206
+ scores: res.scores
207
+ };
208
+ }