vanspod 0.0.0-stage → 0.1.2

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.
Files changed (79) hide show
  1. package/README.md +74 -2
  2. package/bin/register.cjs +11 -0
  3. package/bin/vanspod.cjs +2 -0
  4. package/dist/adapterSettings.js +37 -0
  5. package/dist/adapters/chat-output.js +69 -0
  6. package/dist/adapters/chat-stream.js +44 -0
  7. package/dist/adapters/chat.js +63 -0
  8. package/dist/adapters/claude-reasoning.js +118 -0
  9. package/dist/adapters/claude-request.js +190 -0
  10. package/dist/adapters/claude-response.js +79 -0
  11. package/dist/adapters/index.js +19 -0
  12. package/dist/adapters/messages-stream.js +126 -0
  13. package/dist/adapters/messages.js +21 -0
  14. package/dist/adapters/responses-stream.js +70 -0
  15. package/dist/adapters/responses.js +28 -0
  16. package/dist/adapters/stream.js +99 -0
  17. package/dist/adapters/transport.js +26 -0
  18. package/dist/adapters/types.js +35 -0
  19. package/dist/appearance.js +10 -0
  20. package/dist/classroomApp.js +1814 -0
  21. package/dist/classroomUpdate.js +155 -0
  22. package/dist/claudeWebSearch.js +248 -0
  23. package/dist/clientDetection.js +35 -0
  24. package/dist/clientIcons.js +33 -0
  25. package/dist/clientRouteRuntime.js +149 -0
  26. package/dist/clientRoutes.js +118 -0
  27. package/dist/clientSettingsBackup.js +110 -0
  28. package/dist/clientTraffic.js +26 -0
  29. package/dist/clipboard.js +32 -0
  30. package/dist/codexWebSearch.js +80 -0
  31. package/dist/codexWire/apply-patch-envelope.js +139 -0
  32. package/dist/codexWire/chat-options.js +51 -0
  33. package/dist/codexWire/chat-tools.js +80 -0
  34. package/dist/codexWire/freeform-wrapper-scan.js +326 -0
  35. package/dist/codexWire/progressive-freeform-input.js +133 -0
  36. package/dist/codexWire/request.js +73 -0
  37. package/dist/codexWire/responses-tools.js +88 -0
  38. package/dist/codexWire/tool-arguments.js +39 -0
  39. package/dist/codexWire/tool-guidance.js +77 -0
  40. package/dist/commandRunner.js +100 -0
  41. package/dist/connectionMascot.js +235 -0
  42. package/dist/courseCatalog.js +131 -0
  43. package/dist/desktop.js +25 -0
  44. package/dist/entry.js +17 -0
  45. package/dist/environmentIcons.js +11 -0
  46. package/dist/exitGuard.js +17 -0
  47. package/dist/folderPicker.js +35 -0
  48. package/dist/installProgress.js +171 -0
  49. package/dist/installations.js +72 -0
  50. package/dist/instance.js +160 -0
  51. package/dist/launcher.js +155 -0
  52. package/dist/legacyMigration.js +87 -0
  53. package/dist/lifecyclePage.js +41 -0
  54. package/dist/modelProxy.js +105 -0
  55. package/dist/modelStatus.js +42 -0
  56. package/dist/page.js +1016 -0
  57. package/dist/pagePrototype.js +528 -0
  58. package/dist/platformStorage.js +91 -0
  59. package/dist/removal.js +41 -0
  60. package/dist/responsesChat.js +835 -0
  61. package/dist/routeFiles.js +646 -0
  62. package/dist/routerSessionModels.js +32 -0
  63. package/dist/runtimeOptions.js +14 -0
  64. package/dist/secureStorage.js +63 -0
  65. package/dist/settingsActions.js +100 -0
  66. package/dist/start.js +822 -0
  67. package/dist/storageErrors.js +17 -0
  68. package/dist/storageLocation.js +58 -0
  69. package/dist/version.js +4 -0
  70. package/dist/vsCodeRouteSettings.js +208 -0
  71. package/dist/webSearch.js +56 -0
  72. package/dist/windowsSecureStorage.js +163 -0
  73. package/media/brand-icon.ico +0 -0
  74. package/media/brand-logo.png +0 -0
  75. package/media/fadou-superhero-blink.png +0 -0
  76. package/media/fadou-superhero.png +0 -0
  77. package/package.json +27 -4
  78. package/scripts/tray.ps1 +40 -0
  79. package/scripts/uninstall.ps1 +133 -0
package/README.md CHANGED
@@ -1,3 +1,75 @@
1
- # Temporary Holding Version
1
+ # VansPod
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ VansPod 是 Windows 學生使用的本機課堂入口。提供含 Node runtime 的單檔 `VansPod.exe`,以及給已有 Node.js 24 以上環境的 npm 套件;兩者共用同一個背景服務和學生設定。
4
+
5
+ ## 學生操作
6
+
7
+ 雙擊 exe 或執行 `vanspod start`,會啟動背景服務並開啟瀏覽器。再開一次會回到既有畫面;關閉瀏覽器或啟動用終端機不會停止課堂代理。Windows 右下角的 VansPod logo 可重新開啟畫面,右鍵「結束 VansPod」可恢復客戶端個人設定後退出。電腦重新啟動後須手動開啟,不設定開機自動啟動。
8
+
9
+ 公開 npm 發佈完成後,學生可使用下列安裝方式;本機打包不代表套件已上架:
10
+
11
+ ```powershell
12
+ npm install -g vanspod
13
+ vanspod start
14
+ ```
15
+
16
+ | 指令 | 用途 |
17
+ | --- | --- |
18
+ | `vanspod` / `vanspod start` | 啟動並開啟瀏覽器,或開啟既有畫面 |
19
+ | `vanspod status` | 顯示是否執行中、執行版本、PID 與本機網址 |
20
+ | `vanspod stop` | 恢復客戶端設定後結束,保留課堂連線與模式記憶 |
21
+ | `vanspod uninstall` | 列出刪除內容,確認後完整移除 |
22
+ | `vanspod --version` / `vanspod --help` | 顯示安裝版本/說明,不啟動服務 |
23
+
24
+ 安裝環境或課程專案時不能結束或移除。模型回答中須確認中斷;命令列無法互動確認時保持執行。設定恢復失敗會保留服務與備份,顯示失敗的客戶端並允許重試。
25
+
26
+ 完整移除請用頁面的「移除 VansPod」或 `vanspod uninstall`。這會先恢復客戶端設定,再清理 VansPod 憑證、偏好、日誌、暫存與已登記的 exe/npm 安裝;保留學生作品、課程專案、Node、Git、VS Code 和客戶端個人設定。結果以 Windows 訊息顯示,刪除失敗會保留移除工具和失敗項目供重試;未登記的其他 exe 副本須自行刪除。直接刪 exe 或 `npm uninstall -g vanspod` 無法保證完整清理。
27
+
28
+ 更新前先結束 VansPod。exe 下載指定版本替換,npm 使用 `npm install -g vanspod@<老師指定版本>`;兩者使用相同版本號,上課途中不自動更新。開啟入口與執行版本不同時會提示,先結束才能換版。
29
+
30
+ ## 開發與本機打包
31
+
32
+ ```powershell
33
+ npm install
34
+ npm run typecheck
35
+ npm test
36
+ npm start
37
+ npm run dist
38
+ npm pack --pack-destination dist
39
+ ```
40
+
41
+ exe 產物為 `dist/VansPod.exe`,本機套件為 `dist/vanspod-0.1.2.tgz`;可在 Windows 用 `npm install -g <套件路徑>` 安裝測試。圖片、favicon、系統匣與移除腳本均包含在發佈產物中。更新 logo 後執行 `powershell.exe -NoProfile -File scripts/build-icon.ps1` 重建多尺寸 Windows icon。
42
+
43
+ exe 發佈管道為本專案 GitHub Releases,npm 為公開 registry;正式上架需先確認套件名稱及發佈帳號權限。更新時不要執行完整移除,否則會清除課堂資料。
44
+
45
+ ## GitHub Actions 發佈 exe
46
+
47
+ 一般 push 不發佈;推送 `v*` tag 會在 Windows runner 安裝 Node.js 24、檢查型別、執行測試、打包並驗證 exe/npm 生命週期,全部通過後才建立 GitHub Release。學生從 Release 下載 `VansPod.exe`,同頁提供 `SHA256SUMS.txt`。此流程不會發佈 npm 套件。
48
+
49
+ 發版前同步 `package.json`、`package-lock.json` 與 `src/version.ts` 的版本;tag 必須等於 `v` 加版本號,否則流程失敗。以 `0.1.2` 為例:
50
+
51
+ ```powershell
52
+ git push origin master
53
+ git tag -a v0.1.2 -m "VansPod 0.1.2"
54
+ git push origin v0.1.2
55
+ ```
56
+
57
+ 新版本使用新的版本號與 tag。相同 tag 的 Actions 重跑會更新該 Release 的產物。
58
+
59
+ 本機課堂 app。規格是 [classroom-one-click-install#8](https://github.com/mz038197/classroom-one-click-install/issues/8)。
60
+
61
+ 不放進凡思課堂安裝擴充,也不從 opencodex fork。領域詞以該 repo 的 CONTEXT.md 為準。視窗決定見該 repo 的 ADR 0017。
62
+
63
+ 設計與操作邊界見 [ADR-0008](docs/adr/0008-student-distribution-and-lifecycle.md)。
64
+
65
+ ## Classroom 上游 API
66
+
67
+ 模型區提供各客戶端的上游 API 選項:Codex 可選 Responses/Chat,Claude Code 可選 Responses/Chat/Messages,兩者均預設 Responses。選擇保存在使用者目錄的 `.vanspod/settings.json`,與課堂憑證分開;重啟、切換模型、清除或重新兌換課堂不會移除偏好。此選項只影響 Classroom 模式的下一個請求。
68
+
69
+ 共用 Adapter 介面下的三個實作負責請求、上游呼叫與回應轉換;所有程式都在 VansPod 內,不需要 opencodex 專案。支援多輪文字、圖片輸入、函式工具往返與串流/非串流;Chat 路徑沿用 Codex 自訂工具的文字包裝及 `apply_patch` 修復。
70
+
71
+ 跨協定不能對等保留的資料會明確報錯,例如 Claude 的 thinking/簽章、工具結果的 `is_error`、快取控制或服務端工具,以及 Codex 的加密 reasoning 或工具 namespace。Chat 自訂工具將完整的 Lark/regex grammar 定義保留在 input schema 的描述中,作為模型指令;Chat 不提供 Responses 原生 grammar 的受限解碼保證。原協定直通可保留其原有資料;轉換失敗不會自動換 adapter 重送。使用者取消或客戶端斷線會中止本機上游請求,串流缺少完成事件會回報失敗。
72
+
73
+ 協定轉換參考官方 [Chat API](https://developers.openai.com/api/reference/resources/chat)、[Responses API](https://developers.openai.com/api/reference/resources/responses) 與 [Claude 串流事件](https://platform.claude.com/docs/en/build-with-claude/streaming)。
74
+
75
+ 驗證指令:`npm run typecheck`、`npm test`、`npm run test:windows-exe`。Windows 驗證使用隔離的合成 USERPROFILE/APPDATA 與模擬 Router,涵蓋三個 Client Route、47821~47823/自訂 base port、port collision、獨立模式與 adapter、client-native catalog、失敗恢復,以及五條 JSON/SSE adapter 路徑;測試前後亦確認真實使用者設定未變。通過不表示實際 Router 或模型已完成端到端驗證。
@@ -0,0 +1,11 @@
1
+ // Register a packaged install without starting the classroom service. A source
2
+ // checkout has not been compiled during npm install and needs no registration.
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const entry = path.join(__dirname, '../dist/installations.js');
6
+ if (fs.existsSync(entry)) {
7
+ require(entry).registerInstallation().catch(error => {
8
+ process.stderr.write(`VansPod 安裝紀錄無法保存:${error.message}\n`);
9
+ process.exitCode = 1;
10
+ });
11
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require('../dist/entry.js');
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.defaultAdapters = defaultAdapters;
4
+ exports.isAdapterChoice = isAdapterChoice;
5
+ exports.createAdapterSettingsStorage = createAdapterSettingsStorage;
6
+ const clientRoutes_1 = require("./clientRoutes");
7
+ function defaultAdapters() { return { codex: "responses", claude: "responses" }; }
8
+ function isAdapterChoice(client, id) {
9
+ return id === "chat" || id === "responses" || (client === "claude" && id === "messages");
10
+ }
11
+ function settings(value) {
12
+ if (!value || typeof value !== "object" || Array.isArray(value))
13
+ throw new Error("無法讀取 adapter 設定。");
14
+ const row = value;
15
+ if (typeof row.codex !== "string" || !isAdapterChoice("codex", row.codex) || row.codex === "messages" || typeof row.claude !== "string" || !isAdapterChoice("claude", row.claude))
16
+ throw new Error("adapter 設定格式無效。");
17
+ return { codex: row.codex, claude: row.claude };
18
+ }
19
+ // Non-secret app preferences are separate from Classroom Connection and survive its removal.
20
+ function createAdapterSettingsStorage(filename) {
21
+ const routes = (0, clientRoutes_1.createClientRouteSettingsStorage)(filename);
22
+ return {
23
+ async load() {
24
+ const saved = await routes.load();
25
+ return settings({ codex: saved.codex.adapter, claude: saved.claudeCode.adapter });
26
+ },
27
+ async save(next) {
28
+ const value = settings(next);
29
+ const saved = await routes.load();
30
+ await routes.save({
31
+ ...saved,
32
+ codex: { ...saved.codex, adapter: value.codex },
33
+ claudeCode: { ...saved.claudeCode, adapter: value.claude },
34
+ });
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ChatToolCalls = void 0;
4
+ exports.validateChatContent = validateChatContent;
5
+ exports.chatFinishReason = chatFinishReason;
6
+ const types_1 = require("./types");
7
+ function validateChatContent(message, allowPlaintextReasoning = false) {
8
+ if (allowPlaintextReasoning) {
9
+ for (const key of ["reasoning", "reasoning_content"])
10
+ if (message[key] != null && typeof message[key] !== "string")
11
+ throw new types_1.AdapterError("Chat reasoning 必須是文字。", 502);
12
+ if (message.reasoning && message.reasoning_content && message.reasoning !== message.reasoning_content)
13
+ throw new types_1.AdapterError("Chat 回傳互相衝突的 reasoning 欄位。", 502);
14
+ }
15
+ if ((!allowPlaintextReasoning && (message.reasoning_content || message.reasoning)) || message.refusal || message.audio || (Array.isArray(message.annotations) ? message.annotations.length : message.annotations))
16
+ throw new types_1.AdapterError("所選客戶端無法保留此 Chat 輸出。", 502);
17
+ if (message.function_call !== undefined)
18
+ throw new types_1.AdapterError("無法保留舊版 Chat function_call 輸出。", 502);
19
+ if (message.content !== undefined && message.content !== null && typeof message.content !== "string")
20
+ throw new types_1.AdapterError("Chat 輸出 content 必須是文字。", 502);
21
+ }
22
+ function chatFinishReason(value) {
23
+ if (value === "stop" || value === "tool_calls" || value === "length")
24
+ return value;
25
+ throw new types_1.AdapterError("Chat 串流中斷或結束原因無法轉換。", 502);
26
+ }
27
+ // A Chat tool name can span packets. The downstream start event is immutable, so
28
+ // collect each complete call before publishing it; text still streams immediately.
29
+ class ChatToolCalls {
30
+ calls = new Map();
31
+ add(value) {
32
+ for (const raw of (0, types_1.list)(value, "tool_calls")) {
33
+ const call = (0, types_1.object)(raw, "tool call"), fn = (0, types_1.object)(call.function ?? {}, "tool function");
34
+ const index = call.index ?? 0;
35
+ if (typeof index !== "number" || !Number.isInteger(index) || index < 0)
36
+ throw new types_1.AdapterError("Chat tool index 無效。", 502);
37
+ const tool = this.calls.get(index) ?? { id: "", name: "", args: "" };
38
+ this.calls.set(index, tool);
39
+ if (typeof call.id === "string") {
40
+ if (tool.id && tool.id !== call.id)
41
+ throw new types_1.AdapterError("工具 ID 在串流中改變。", 502);
42
+ tool.id = call.id;
43
+ }
44
+ if (typeof fn.name === "string")
45
+ tool.name += fn.name;
46
+ if (typeof fn.arguments === "string")
47
+ tool.args += fn.arguments;
48
+ }
49
+ }
50
+ completed(toolKinds = new Map()) {
51
+ return [...this.calls.entries()].map(([index, call]) => {
52
+ if (!call.id || !call.name)
53
+ throw new types_1.AdapterError("上游工具呼叫缺少 ID 或名稱。", 502);
54
+ if (toolKinds.get(call.name) !== "custom") {
55
+ let input;
56
+ try {
57
+ input = JSON.parse(call.args || "{}");
58
+ }
59
+ catch {
60
+ throw new types_1.AdapterError("上游工具參數不是有效 JSON。", 502);
61
+ }
62
+ if (!input || typeof input !== "object" || Array.isArray(input))
63
+ throw new types_1.AdapterError("上游工具參數必須是 JSON 物件。", 502);
64
+ }
65
+ return { index, id: call.id, type: "function", function: { name: call.name, arguments: call.args || "{}" } };
66
+ });
67
+ }
68
+ }
69
+ exports.ChatToolCalls = ChatToolCalls;
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.chatToResponsesStream = chatToResponsesStream;
4
+ const responsesChat_1 = require("../responsesChat");
5
+ const stream_1 = require("./stream");
6
+ const types_1 = require("./types");
7
+ const chat_output_1 = require("./chat-output");
8
+ async function* chatToResponsesStream(response, model, toolKinds, toolIdentity, hideReasoningSummary = false) {
9
+ const output = new responsesChat_1.ResponsesSse(model, toolKinds, toolIdentity, hideReasoningSummary);
10
+ yield output.created();
11
+ let finish;
12
+ const tools = new chat_output_1.ChatToolCalls();
13
+ for await (const frame of (0, stream_1.frames)(response)) {
14
+ if (frame.data === "[DONE]")
15
+ continue;
16
+ const body = frame.data;
17
+ if (body.error)
18
+ throw new types_1.AdapterError("Chat 上游串流回報失敗。", 502);
19
+ for (const value of (0, types_1.list)(body.choices ?? [], "choices")) {
20
+ const choice = (0, types_1.object)(value, "choice"), delta = (0, types_1.object)(choice.delta ?? {}, "delta");
21
+ (0, chat_output_1.validateChatContent)(delta, true);
22
+ if (delta.tool_calls !== undefined)
23
+ tools.add(delta.tool_calls);
24
+ if (choice.finish_reason != null)
25
+ finish = choice.finish_reason;
26
+ }
27
+ const textOnly = { ...body, choices: (0, types_1.list)(body.choices ?? [], "choices").map(value => {
28
+ const choice = (0, types_1.object)(value, "choice"), delta = { ...(0, types_1.object)(choice.delta ?? {}, "delta") };
29
+ delete delta.tool_calls;
30
+ return { ...choice, delta };
31
+ }) };
32
+ yield output.pushChatChunk(`data: ${JSON.stringify(textOnly)}\n`);
33
+ }
34
+ finish = (0, chat_output_1.chatFinishReason)(finish);
35
+ const calls = tools.completed(toolKinds);
36
+ if (finish === "tool_calls" && !calls.length)
37
+ throw new types_1.AdapterError("上游工具呼叫缺少識別資訊。", 502);
38
+ if (calls.length)
39
+ yield output.pushChatChunk(`data: ${JSON.stringify({ choices: [{ delta: { tool_calls: calls } }] })}\n`);
40
+ const terminal = output.finish();
41
+ if (terminal.includes("event: response.failed"))
42
+ throw new types_1.AdapterError("上游工具參數無法轉換。", 502);
43
+ yield terminal;
44
+ }
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ChatAdapter = void 0;
4
+ const responsesChat_1 = require("../responsesChat");
5
+ const types_1 = require("./types");
6
+ const claude_request_1 = require("./claude-request");
7
+ const claude_reasoning_1 = require("./claude-reasoning");
8
+ const claude_response_1 = require("./claude-response");
9
+ const messages_stream_1 = require("./messages-stream");
10
+ const stream_1 = require("./stream");
11
+ const chat_stream_1 = require("./chat-stream");
12
+ const transport_1 = require("./transport");
13
+ const chat_output_1 = require("./chat-output");
14
+ const chat_tools_1 = require("../codexWire/chat-tools");
15
+ class ChatAdapter {
16
+ id = "chat";
17
+ async send(request) {
18
+ const codexTools = request.client === "codex" ? (0, chat_tools_1.codexChatTools)(request.body) : undefined;
19
+ const toolIdentity = (name) => {
20
+ const item = codexTools.restore({ type: "custom_tool_call", name });
21
+ return { name: String(item.name), ...(typeof item.namespace === "string" ? { namespace: item.namespace } : {}) };
22
+ };
23
+ const hideReasoningSummary = request.client === "codex" && typeof request.body.reasoning === "object" && request.body.reasoning !== null && (0, types_1.object)(request.body.reasoning, "reasoning").summary === "none";
24
+ const raw = JSON.stringify({ ...request.body, stream: request.body.stream === true });
25
+ const bodyToSend = request.client === "codex" ? (0, types_1.object)(JSON.parse((0, responsesChat_1.responsesToChatBody)(raw, request.model)), "Chat request") : (0, claude_request_1.claudeToChat)(request.body, request.model);
26
+ const { response, abort } = await (request.client === "codex" ? transport_1.upstreamRequest : claude_reasoning_1.upstreamRequestWithReplay)(request, "/v1/chat/completions", bodyToSend);
27
+ if (!response.ok)
28
+ return response;
29
+ if (request.body.stream) {
30
+ if (request.client === "claude")
31
+ return (0, stream_1.streamResponse)((0, messages_stream_1.chatToMessagesStream)(response, request.model, (0, claude_reasoning_1.thinkingMode)(request.body)), abort);
32
+ return (0, stream_1.streamResponse)((0, chat_tools_1.restoreChatToolStream)((0, chat_stream_1.chatToResponsesStream)(response, request.model, (0, responsesChat_1.toolKindsFromResponses)(raw), toolIdentity, hideReasoningSummary), codexTools.restore), abort);
33
+ }
34
+ return (0, transport_1.jsonResponse)(response, body => {
35
+ if (request.client === "claude")
36
+ return (0, claude_response_1.chatToClaude)(body, request.model, (0, claude_reasoning_1.thinkingMode)(request.body));
37
+ const choice = (0, types_1.object)((0, types_1.list)(body.choices, "choices")[0], "choice");
38
+ const message = (0, types_1.object)(choice.message, "message");
39
+ (0, chat_output_1.validateChatContent)(message, true);
40
+ (0, chat_output_1.chatFinishReason)(choice.finish_reason);
41
+ if (message.tool_calls !== undefined) {
42
+ const tools = new chat_output_1.ChatToolCalls();
43
+ tools.add((0, types_1.list)(message.tool_calls, "tool_calls").map((call, index) => ({ ...(0, types_1.object)(call, "tool call"), index })));
44
+ message.tool_calls = tools.completed((0, responsesChat_1.toolKindsFromResponses)(raw));
45
+ }
46
+ if (choice.finish_reason === "tool_calls" && (!Array.isArray(message.tool_calls) || !message.tool_calls.length))
47
+ throw new types_1.AdapterError("上游工具呼叫缺少識別資訊。", 502);
48
+ const sse = new responsesChat_1.ResponsesSse(request.model, (0, responsesChat_1.toolKindsFromResponses)(raw), toolIdentity, hideReasoningSummary);
49
+ sse.pushChatChunk(`data: ${JSON.stringify({ choices: [{ delta: message, finish_reason: choice.finish_reason }], usage: body.usage })}\n\n`);
50
+ const frames = sse.finish();
51
+ const event = frames.match(/event: response.(?:completed|incomplete)\ndata: ([^\n]+)/);
52
+ if (!event)
53
+ throw new types_1.AdapterError("Chat 回應無法轉換成 Responses。", 502);
54
+ const result = JSON.parse(event[1]).response;
55
+ if (body.usage) {
56
+ const usage = (0, types_1.object)(body.usage, "usage");
57
+ result.usage = { input_tokens: usage.prompt_tokens, output_tokens: usage.completion_tokens, total_tokens: usage.total_tokens };
58
+ }
59
+ return (0, chat_tools_1.restoreChatToolResponse)(result, codexTools.restore);
60
+ });
61
+ }
62
+ }
63
+ exports.ChatAdapter = ChatAdapter;
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.thinkingMode = thinkingMode;
4
+ exports.responsesReasoning = responsesReasoning;
5
+ exports.thinkingBlock = thinkingBlock;
6
+ exports.replayedReasoning = replayedReasoning;
7
+ exports.withoutReplayedThinking = withoutReplayedThinking;
8
+ exports.upstreamRequestWithReplay = upstreamRequestWithReplay;
9
+ const types_1 = require("./types");
10
+ const transport_1 = require("./transport");
11
+ // Reasoning continuity for Claude Code on the Chat and Responses upstreams. Claude Code
12
+ // returns a thinking block unchanged on the next turn, so upstream reasoning is handed to it
13
+ // as a thinking block whose signature carries what the upstream needs to see again.
14
+ const PREFIX = "vpr1.";
15
+ function thinkingMode(body) {
16
+ const thinking = body.thinking && typeof body.thinking === "object" ? body.thinking : {};
17
+ if (thinking.type !== "enabled" && thinking.type !== "adaptive")
18
+ return "off";
19
+ return thinking.display === "omitted" ? "hidden" : "shown";
20
+ }
21
+ function texts(value) {
22
+ return Array.isArray(value) ? value.flatMap(part => part && typeof part === "object" && typeof part.text === "string" && part.text ? [part.text] : []) : [];
23
+ }
24
+ /** A Responses reasoning output item, reduced to the fields the upstream takes back as input. */
25
+ function responsesReasoning(item) {
26
+ const text = [...texts(item.summary), ...texts(item.content)].join("\n\n");
27
+ if (!text && typeof item.encrypted_content !== "string")
28
+ return undefined;
29
+ const kept = { type: "reasoning", summary: Array.isArray(item.summary) ? item.summary : [] };
30
+ for (const key of ["id", "content", "encrypted_content"])
31
+ if (item[key] != null)
32
+ kept[key] = item[key];
33
+ return { item: kept, text };
34
+ }
35
+ function thinkingBlock(reasoning, mode, model) {
36
+ // An item already holds its text, so the text is only stored when there is no item.
37
+ const carried = reasoning.item ? { model, item: reasoning.item } : { model, text: reasoning.text };
38
+ return { type: "thinking", thinking: mode === "shown" ? reasoning.text : "", signature: PREFIX + Buffer.from(JSON.stringify(carried), "utf8").toString("base64url") };
39
+ }
40
+ /** Reads back a thinking block made by `thinkingBlock`. Any other signature cannot be replayed here. */
41
+ function replayedReasoning(block) {
42
+ const signature = block.signature;
43
+ if (block.type === "thinking" && typeof signature === "string" && signature.startsWith(PREFIX)) {
44
+ try {
45
+ const carried = (0, types_1.object)(JSON.parse(Buffer.from(signature.slice(PREFIX.length), "base64url").toString("utf8")), "reasoning");
46
+ // The signature comes back from the client, so only a reasoning item's own fields reach the upstream.
47
+ const item = carried.item === undefined ? undefined : (0, types_1.object)(carried.item, "reasoning item");
48
+ if (item && item.type !== "reasoning")
49
+ throw new Error("not a reasoning item");
50
+ const kept = item && responsesReasoning(item);
51
+ return { text: typeof carried.text === "string" ? carried.text : kept?.text ?? "", ...(kept ? { item: kept.item } : {}), ...(typeof carried.model === "string" ? { model: carried.model } : {}) };
52
+ }
53
+ catch {
54
+ throw new types_1.AdapterError("Claude thinking 的回放資料已損毀。");
55
+ }
56
+ }
57
+ // Replaying it needs its Anthropic signature; dropping it would silently change the transcript.
58
+ throw new types_1.AdapterError(`所選上游協定無法轉換 Claude 內容 ${String(block.type)}。`);
59
+ }
60
+ /** A Messages request without the thinking blocks made here, whose signatures an Anthropic upstream would reject. */
61
+ function withoutReplayedThinking(body) {
62
+ if (!Array.isArray(body.messages))
63
+ return body;
64
+ const replayed = (block) => !!block && typeof block === "object" && block.type === "thinking" && String(block.signature).startsWith(PREFIX);
65
+ const messages = body.messages.flatMap(message => {
66
+ if (!message || !Array.isArray(message.content) || !message.content.some(replayed))
67
+ return [message];
68
+ const content = message.content.filter(block => !replayed(block));
69
+ return content.length ? [{ ...message, content }] : [];
70
+ });
71
+ return { ...body, messages };
72
+ }
73
+ /** The upstream request without anything reasoning replay put in it, or undefined when there is nothing to take out. */
74
+ function withoutReasoning(body) {
75
+ const plain = { ...body };
76
+ let changed = false;
77
+ if (Array.isArray(plain.input)) {
78
+ const input = (0, types_1.list)(plain.input, "input").filter(item => (0, types_1.object)(item, "input item").type !== "reasoning");
79
+ if (input.length !== plain.input.length) {
80
+ plain.input = input;
81
+ changed = true;
82
+ }
83
+ }
84
+ if (Array.isArray(plain.messages) && plain.messages.some(message => (0, types_1.object)(message, "message").reasoning_content !== undefined)) {
85
+ plain.messages = plain.messages.map(message => { const { reasoning_content: _, ...rest } = (0, types_1.object)(message, "message"); return rest; });
86
+ changed = true;
87
+ }
88
+ if (plain.include !== undefined) {
89
+ delete plain.include;
90
+ changed = true;
91
+ }
92
+ if (plain.reasoning && typeof plain.reasoning === "object" && plain.reasoning.summary !== undefined) {
93
+ const { summary: _, ...rest } = plain.reasoning;
94
+ if (Object.keys(rest).length)
95
+ plain.reasoning = rest;
96
+ else
97
+ delete plain.reasoning;
98
+ changed = true;
99
+ }
100
+ return changed ? plain : undefined;
101
+ }
102
+ /**
103
+ * Sends a Claude Code request, and once more without replayed reasoning when the upstream rejects it.
104
+ * Replayed reasoning stays in the history, so one the upstream refuses would fail every later turn.
105
+ */
106
+ async function upstreamRequestWithReplay(request, path, body) {
107
+ const first = await (0, transport_1.upstreamRequest)(request, path, body);
108
+ const plain = first.response.status === 400 || first.response.status === 422 ? withoutReasoning(body) : undefined;
109
+ if (!plain)
110
+ return first;
111
+ const retried = await (0, transport_1.upstreamRequest)(request, path, plain);
112
+ if (!retried.response.ok) {
113
+ retried.abort();
114
+ return first;
115
+ }
116
+ first.abort();
117
+ return retried;
118
+ }
@@ -0,0 +1,190 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.claudeToResponses = claudeToResponses;
4
+ exports.claudeToChat = claudeToChat;
5
+ const node_crypto_1 = require("node:crypto");
6
+ const types_1 = require("./types");
7
+ const claude_reasoning_1 = require("./claude-reasoning");
8
+ // Only recognized fields and content types are read. Unknown ones are not forwarded
9
+ // and do not block the request, so a new Claude Code field cannot break every turn.
10
+ function imageUrl(block) {
11
+ const source = (0, types_1.object)(block.source, "image.source");
12
+ if (source.type === "url")
13
+ return (0, types_1.string)(source.url, "image URL");
14
+ if (source.type === "base64")
15
+ return `data:${(0, types_1.string)(source.media_type, "media_type")};base64,${(0, types_1.string)(source.data, "image data")}`;
16
+ throw new types_1.AdapterError("無法轉換此圖片 source。");
17
+ }
18
+ function contentBlocks(content) {
19
+ if (typeof content === "string")
20
+ return [{ type: "text", text: content }];
21
+ return (0, types_1.list)(content, "Claude content").map(value => (0, types_1.object)(value, "Claude content block"));
22
+ }
23
+ const TOOL_ERROR = "[tool error]";
24
+ function result(block, wire) {
25
+ // The error marker has no equivalent on these upstream protocols; carry it as text, never hide it.
26
+ const failed = block.is_error === true;
27
+ if (block.content === undefined)
28
+ return failed ? TOOL_ERROR : "";
29
+ if (typeof block.content === "string")
30
+ return failed ? `${TOOL_ERROR} ${block.content}` : block.content;
31
+ const parts = contentBlocks(block.content).flatMap(value => part(value, wire, "user"));
32
+ return failed ? [{ type: wire === "chat" ? "text" : "input_text", text: TOOL_ERROR }, ...parts] : parts;
33
+ }
34
+ function part(block, wire, role) {
35
+ if (block.type === "text")
36
+ return [{ type: wire === "chat" ? "text" : role === "assistant" ? "output_text" : "input_text", text: (0, types_1.string)(block.text, "text") }];
37
+ if (block.type === "image" && role === "user") {
38
+ const url = imageUrl(block);
39
+ return [wire === "chat" ? { type: "image_url", image_url: { url } } : { type: "input_image", image_url: url }];
40
+ }
41
+ if (block.type === "thinking" || block.type === "redacted_thinking")
42
+ (0, claude_reasoning_1.replayedReasoning)(block);
43
+ return [];
44
+ }
45
+ function options(body, wire) {
46
+ const output = { stream: body.stream === true };
47
+ for (const key of ["temperature", "top_p"])
48
+ if (body[key] !== undefined)
49
+ output[key] = body[key];
50
+ if (body.max_tokens !== undefined)
51
+ output[wire === "chat" ? "max_tokens" : "max_output_tokens"] = body.max_tokens;
52
+ if (wire === "chat" && body.stop_sequences !== undefined)
53
+ output.stop = body.stop_sequences;
54
+ if (body.metadata !== undefined) {
55
+ // Claude Code's user_id is a JSON blob; OpenAI-style upstreams reject user longer than 64 characters.
56
+ const user = (0, types_1.object)(body.metadata, "metadata").user_id;
57
+ output.user = typeof user === "string" && user.length > 64 ? (0, node_crypto_1.createHash)("sha256").update(user).digest("hex") : user;
58
+ }
59
+ // thinking and context_management have no upstream equivalent. Reasoning depth travels as
60
+ // effort; the budget is never forwarded raw and only picks the tier when no effort is given.
61
+ const thinking = body.thinking === undefined ? {} : (0, types_1.object)(body.thinking, "thinking");
62
+ const effort = body.output_config !== undefined ? (0, types_1.object)(body.output_config, "output_config").effort
63
+ : thinking.type === "enabled" && typeof thinking.budget_tokens === "number" ? thinking.budget_tokens <= 4096 ? "low" : thinking.budget_tokens <= 16384 ? "medium" : "high" : undefined;
64
+ if (effort !== undefined) {
65
+ if (wire === "chat")
66
+ output.reasoning_effort = effort;
67
+ else
68
+ output.reasoning = { effort };
69
+ }
70
+ const mode = (0, claude_reasoning_1.thinkingMode)(body);
71
+ if (wire === "responses" && mode !== "off") {
72
+ // Encrypted reasoning is what a stateless upstream needs back on the next turn.
73
+ output.include = ["reasoning.encrypted_content"];
74
+ if (mode === "shown")
75
+ output.reasoning = { ...(0, types_1.object)(output.reasoning ?? {}, "reasoning"), summary: "auto" };
76
+ }
77
+ if (body.tools !== undefined)
78
+ output.tools = (0, types_1.list)(body.tools, "tools").map(value => {
79
+ const tool = (0, types_1.object)(value, "Claude tool");
80
+ const fn = { name: (0, types_1.string)(tool.name, "tool name"), ...(tool.description !== undefined ? { description: tool.description } : {}),
81
+ // Responses reads an omitted strict as strict mode, which turns optional parameters into required ones.
82
+ ...(tool.strict !== undefined ? { strict: tool.strict } : wire === "responses" ? { strict: false } : {}), parameters: (0, types_1.object)(tool.input_schema, "input_schema") };
83
+ return wire === "chat" ? { type: "function", function: fn } : { type: "function", ...fn };
84
+ });
85
+ if (body.tool_choice !== undefined) {
86
+ const choice = (0, types_1.object)(body.tool_choice, "tool_choice");
87
+ if (choice.disable_parallel_tool_use !== undefined)
88
+ output.parallel_tool_calls = !choice.disable_parallel_tool_use;
89
+ if (choice.type === "tool")
90
+ output.tool_choice = wire === "chat" ? { type: "function", function: { name: (0, types_1.string)(choice.name, "tool name") } } : { type: "function", name: (0, types_1.string)(choice.name, "tool name") };
91
+ else if (choice.type === "any")
92
+ output.tool_choice = "required";
93
+ else if (choice.type === "auto" || choice.type === "none")
94
+ output.tool_choice = choice.type;
95
+ else
96
+ throw new types_1.AdapterError("無法轉換 Claude tool_choice。");
97
+ }
98
+ return output;
99
+ }
100
+ // Claude Code sends mid-conversation system messages. They keep their place in the timeline;
101
+ // Responses takes them as developer because some backends refuse system items in input.
102
+ const ROLES = ["user", "assistant", "system"];
103
+ // Claude Code opens the system prompt with a per-request billing line that is not a model instruction.
104
+ function system(value) {
105
+ return contentBlocks(value).flatMap(block => block.type === "text" ? [(0, types_1.string)(block.text, "text")] : []).join("\n").replace(/^x-anthropic-billing-header:[^\n]*\n*/, "");
106
+ }
107
+ function claudeToResponses(body, model) {
108
+ if (body.stop_sequences !== undefined)
109
+ throw new types_1.AdapterError("Responses 無法表達 Claude stop_sequences。");
110
+ const input = [];
111
+ for (const value of (0, types_1.list)(body.messages, "messages")) {
112
+ const message = (0, types_1.object)(value, "Claude message");
113
+ const role = (0, types_1.string)(message.role, "role");
114
+ if (!ROLES.includes(role))
115
+ throw new types_1.AdapterError("Claude message role 無法轉換。");
116
+ // A reasoning item is only valid in front of the output it led to, so it waits for that output.
117
+ let parts = [], reasoning = [];
118
+ const flush = () => { if (parts.length)
119
+ input.push({ role: role === "system" ? "developer" : role, content: parts }); parts = []; };
120
+ const reasoned = () => { input.push(...reasoning); reasoning = []; };
121
+ for (const block of contentBlocks(message.content)) {
122
+ if (block.type === "thinking" && role === "assistant") {
123
+ // Chat reasoning has no Responses item to hand back, so only the item form is replayed.
124
+ // Encrypted reasoning belongs to the model that made it; another model is not sent it.
125
+ const replayed = (0, claude_reasoning_1.replayedReasoning)(block);
126
+ if (replayed.item && (replayed.model === undefined || replayed.model === model)) {
127
+ flush();
128
+ reasoning.push(replayed.item);
129
+ }
130
+ }
131
+ else if (block.type === "tool_use" && role === "assistant") {
132
+ flush();
133
+ reasoned();
134
+ input.push({ type: "function_call", call_id: (0, types_1.string)(block.id, "tool id"), name: (0, types_1.string)(block.name, "tool name"), arguments: JSON.stringify((0, types_1.object)(block.input, "tool input")) });
135
+ }
136
+ else if (block.type === "tool_result" && role === "user") {
137
+ flush();
138
+ input.push({ type: "function_call_output", call_id: (0, types_1.string)(block.tool_use_id, "tool_use_id"), output: result(block, "responses") });
139
+ }
140
+ else {
141
+ const converted = part(block, "responses", role);
142
+ if (converted.length)
143
+ reasoned();
144
+ parts.push(...converted);
145
+ }
146
+ }
147
+ flush();
148
+ }
149
+ const instructions = body.system === undefined ? "" : system(body.system);
150
+ return { model, input, store: false, ...options(body, "responses"), ...(instructions ? { instructions } : {}) };
151
+ }
152
+ function claudeToChat(body, model) {
153
+ const messages = [];
154
+ const instructions = body.system === undefined ? "" : system(body.system);
155
+ if (instructions)
156
+ messages.push({ role: "system", content: instructions });
157
+ for (const value of (0, types_1.list)(body.messages, "messages")) {
158
+ const message = (0, types_1.object)(value, "Claude message");
159
+ const role = (0, types_1.string)(message.role, "role");
160
+ if (!ROLES.includes(role))
161
+ throw new types_1.AdapterError("Claude message role 無法轉換。");
162
+ let parts = [], calls = [], reasoning = [];
163
+ const flush = () => {
164
+ if (parts.length || calls.length)
165
+ messages.push({ role, content: parts.length ? parts.every(p => p.type === "text") ? parts.map(p => p.text).join("") : parts : null,
166
+ ...(reasoning.length ? { reasoning_content: reasoning.join("\n\n") } : {}), ...(calls.length ? { tool_calls: calls } : {}) });
167
+ parts = [];
168
+ calls = [];
169
+ reasoning = [];
170
+ };
171
+ for (const block of contentBlocks(message.content)) {
172
+ if (block.type === "thinking" && role === "assistant") {
173
+ const { text } = (0, claude_reasoning_1.replayedReasoning)(block);
174
+ if (text)
175
+ reasoning.push(text);
176
+ }
177
+ else if (block.type === "tool_use" && role === "assistant") {
178
+ calls.push({ id: (0, types_1.string)(block.id, "tool id"), type: "function", function: { name: (0, types_1.string)(block.name, "tool name"), arguments: JSON.stringify((0, types_1.object)(block.input, "tool input")) } });
179
+ }
180
+ else if (block.type === "tool_result" && role === "user") {
181
+ flush();
182
+ messages.push({ role: "tool", tool_call_id: (0, types_1.string)(block.tool_use_id, "tool_use_id"), content: result(block, "chat") });
183
+ }
184
+ else
185
+ parts.push(...part(block, "chat", role));
186
+ }
187
+ flush();
188
+ }
189
+ return { model, messages, ...options(body, "chat") };
190
+ }