t176:雲端不再管地端 LLM 設定(leo 08-03 緊急)
leo 回報四件事:①雲端 Gemini 無法用 ②搞不清楚設定 AI 是設雲端還是地端 ③地端不會萃、每個人都死掉因為都去抓 Claude ④雲端還要設置明明就有 Workers AI。 目標:「雲端裝好就能用,地端輸一個 Gemini API Key,然後就開始用。」 根因:extractor_config 的 KV key 由 portalTenant() 組出,而 portalTenant 是 **worker 層級**環境變數(portal.ts:43)⇒ **全租戶共用一把**。任一處設了 claude, 所有人的 daemon 都收到 claude;沒裝 Claude Code 的機器萃取全滅,而 portal 的 Claude 勾選框又恆 disabled(daemon 從未實作 report-capabilities ⇒ daemon_caps 永遠空) ⇒ 用戶自己解不開(awindhon 08-03 實證:雲端同步成功、金鑰有效、零張卡)。 本次(雲端側): - POST /portal/daemon/config **不再下發** extractor/gemini_api_key/llm_model, 只回連線欄位。⚠️ route 本身與 daemon/libraries 資料夾管理完全不動(leo 明確劃界: 「雲端拉地端檔案夾部分不要刪,刪掉從雲端設置地端 LLM 選項部分」)。 - 移除 POST|GET /portal/admin/extractor(t122)——這是「雲端指定地端引擎」的入口, 且無任何伺服器端驗證,打一下就能把全租戶設成 claude。 - 移除 POST /portal/daemon/report-capabilities(t131)——daemon 端從未實作該呼叫。 - 移除 t131 那組重複註冊的 /portal/admin/ai。**它是重複 route**:後段(arcrun-rag#10) 另有同路徑一組,Hono 先到先比 ⇒ 舊的一直贏,後段修好的「金鑰真的寫進 credentials」 形同死碼。保留後段那組(只管 Gemini 金鑰、走 storeCredential),並拿掉 Claude 偏好欄。 - portal 設定頁「AI 設定」整塊移除,改成一句話說明:雲端不需設定(Workers AI 免金鑰), 地端請在同步小幫手的「AI 設定…」填 Gemini Key。順手清掉 main 上既有的 conflict 標記。 - 清掉隨之孤兒化的 helper(ExtractorConfig/AiConfig/DaemonCapabilities/ syncExtractorFromAiConfig/readAiPref 等)。 測試:**相對 merge 前 main 基準線,新增失敗 = 0**(基準線本就 9 紅:console 藏書地圖 6/ portal HTML 殼 2/POST execute 1,皆與本次無關)。t122/t131 兩組測試改寫成 **回歸守衛**(斷言那些端點/欄位確實 404、確實不存在),不是刪掉充綠。 順手修 health bundle_version 測試與實作對齊(實作刻意省略該欄,測試卻期待空字串)。 未送達:本 commit 只到 code,尚未部署上線。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -80,7 +80,7 @@ describe('GET /portal/admin/ai — 認證閘(route 存在的證明)', () =>
|
||||
});
|
||||
|
||||
describe('GET /portal/admin/ai — 回應形狀(D36:永不回傳 key)', () => {
|
||||
it('回 has_key 布林+claude 旗標,且回應完全不含金鑰值', async () => {
|
||||
it('回 has_key 布林,且回應完全不含金鑰值', async () => {
|
||||
await seedSession('tok-a1', 'rec_admin');
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
const res = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-a1'));
|
||||
@@ -89,8 +89,6 @@ describe('GET /portal/admin/ai — 回應形狀(D36:永不回傳 key)', ()
|
||||
const d = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
expect(typeof d.has_key).toBe('boolean');
|
||||
expect(typeof d.claude_available).toBe('boolean');
|
||||
expect(typeof d.use_claude_for_extract).toBe('boolean');
|
||||
|
||||
// D36:回應裡不得出現任何疑似金鑰的欄位
|
||||
expect(raw).not.toContain('gemini_api_key_value');
|
||||
@@ -98,10 +96,22 @@ describe('GET /portal/admin/ai — 回應形狀(D36:永不回傳 key)', ()
|
||||
expect(d).not.toHaveProperty('value');
|
||||
expect(d).not.toHaveProperty('secret_ref');
|
||||
});
|
||||
|
||||
// t176 回歸守衛(leo 08-03):雲端不再有「地端用哪個模型」的概念。
|
||||
// 這兩個欄位若復活,代表又走回「雲端控制地端」的老路——那正是 08-03 事故根因
|
||||
//(extractor_config 全租戶共用一把,任一處設 claude 就讓所有人萃取全滅)。
|
||||
it('不再回 claude_available/use_claude_for_extract(地端模型改由小幫手自己設)', async () => {
|
||||
await seedSession('tok-a1b', 'rec_admin');
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
const res = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-a1b'));
|
||||
const d = (await res.json()) as Record<string, unknown>;
|
||||
expect(d).not.toHaveProperty('claude_available');
|
||||
expect(d).not.toHaveProperty('use_claude_for_extract');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /portal/admin/ai — 不假裝成功', () => {
|
||||
it('空 body(沒 key 也沒偏好)→ 400,不回 success', async () => {
|
||||
it('空 body(沒帶金鑰)→ 400,不回 success', async () => {
|
||||
await seedSession('tok-a2', 'rec_admin');
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
const res = await json('POST', '/portal/admin/ai', {}, authHdr('tok-a2'));
|
||||
@@ -111,31 +121,13 @@ describe('POST /portal/admin/ai — 不假裝成功', () => {
|
||||
expect(String(d.error)).toContain('沒有要變更');
|
||||
});
|
||||
|
||||
it('只改 Claude 偏好(不帶 key)→ 成功並回存後的值', async () => {
|
||||
// t176 回歸守衛:只送 Claude 偏好=沒有要變更的項目 → 400(該欄位已不存在)。
|
||||
it('只送 use_claude_for_extract(已廢欄位)→ 400,不得假裝成功', async () => {
|
||||
await seedSession('tok-a3', 'rec_admin');
|
||||
mockGetRecord('rec_admin', adminValues());
|
||||
const res = await json('POST', '/portal/admin/ai', { use_claude_for_extract: true }, authHdr('tok-a3'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(400);
|
||||
const d = (await res.json()) as Record<string, unknown>;
|
||||
expect(d.success).toBe(true);
|
||||
expect(d.use_claude_for_extract).toBe(true);
|
||||
// 沒送 key ⇒ 不得回報 has_key(避免誤報「已輸入」)
|
||||
expect(d.has_key).toBeUndefined();
|
||||
});
|
||||
|
||||
// 註:vitest-pool-workers 預設 isolatedStorage=true ⇒ **每個 it 之間 KV 會還原**,
|
||||
// 所以「寫在上一個 it、讀在下一個 it」測不出來(那是測試框架語意,不是程式缺陷)。
|
||||
// 要驗來回一致,必須在**同一個 it** 內完成寫→讀。
|
||||
it('偏好可讀回:同一測試內 POST 寫入 → GET 讀得到同一值', async () => {
|
||||
await seedSession('tok-a4', 'rec_admin');
|
||||
mockGetRecord('rec_admin', adminValues()); // POST 的 requirePortalAdmin 回讀
|
||||
mockGetRecord('rec_admin', adminValues()); // GET 的 requirePortalAdmin 回讀
|
||||
|
||||
const post = await json('POST', '/portal/admin/ai', { use_claude_for_extract: true }, authHdr('tok-a4'));
|
||||
expect(post.status).toBe(200);
|
||||
|
||||
const get = await json('GET', '/portal/admin/ai', undefined, authHdr('tok-a4'));
|
||||
const d = (await get.json()) as Record<string, unknown>;
|
||||
expect(d.use_claude_for_extract).toBe(true);
|
||||
expect(d.success).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user