dsh-llm-local-token 1.3.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 bigborad
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,153 @@
1
+ # dsh-llm-local-token
2
+
3
+ A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugin that serves LLM
4
+ calls with the OAuth tokens your **local CLIs already hold** — no separate API key, no extra
5
+ login. If you are signed in to the Codex CLI or to Claude Code, those subscriptions become
6
+ usable model routes inside DSH.
7
+
8
+ | Provider route | Credential source | Endpoint |
9
+ | --- | --- | --- |
10
+ | `openai-codex` | `~/.codex/auth.json` (ChatGPT OAuth, shared with the `codex` CLI) | `https://chatgpt.com/backend-api` |
11
+ | `anthropic` | `~/.claude/.credentials.json`, else the macOS Keychain item `Claude Code-credentials` | `https://api.anthropic.com` |
12
+
13
+ Both routes appear in the model picker as soon as the plugin loads. A route whose credential is
14
+ missing is skipped instead of failing the boot.
15
+
16
+ <table>
17
+ <tr>
18
+ <td align="center" width="50%"><sub>Both subscriptions as routes in the model picker</sub><br><img src="https://raw.githubusercontent.com/tianxia--/dsh-llm-local-token/main/docs/model-routes.png" alt="The DSH model picker listing OpenAI Codex (local token) and Claude (local token) groups" width="330"></td>
19
+ <td align="center" width="50%"><sub>Subscription usage, read from provider rate-limit headers</sub><br><img src="https://raw.githubusercontent.com/tianxia--/dsh-llm-local-token/main/docs/subscription-usage.png" alt="Subscription usage popover showing Claude and OpenAI Codex quota windows" width="400"></td>
20
+ </tr>
21
+ </table>
22
+
23
+ ## Why it exists
24
+
25
+ DSH resolves a provider's key through its credential seam, which expects an API key. Personal
26
+ Codex / Claude subscriptions are OAuth-only, so the keys simply do not exist. This plugin
27
+ resolves the token per request from the file the CLI maintains, refreshes it when it is close to
28
+ expiry, and hands it to the pi-ai engine that DSH already ships.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ dsh plugin --profile web add dsh-llm-local-token
34
+
35
+ # or straight from git
36
+ dsh plugin --profile web add https://github.com/tianxia--/dsh-llm-local-token.git
37
+ ```
38
+
39
+ Then restart `dsh` — that is the whole install. The package declares a profile
40
+ bundle (`dsh.bundle.patch` → [`cordis.patch.yml`](cordis.patch.yml)), so DSH
41
+ inserts the loader row for you; you do **not** have to hand-edit the profile's
42
+ own `cordis.patch.yml`.
43
+
44
+ <details>
45
+ <summary>Enabling it by hand instead</summary>
46
+
47
+ If you vendored the plugin, or you want to pin its `config` in your own patch
48
+ layer, append the row yourself to `~/.dsh/profiles/web/cordis.patch.yml`. Your
49
+ profile's layer is applied after every bundle layer, so restating the id here
50
+ also lets you override the bundle's defaults:
51
+
52
+ ```yaml
53
+ - insert:
54
+ - id: llm-local-token
55
+ name: dsh-llm-local-token
56
+ ```
57
+
58
+ </details>
59
+
60
+ To make it the default model:
61
+
62
+ ```yaml
63
+ # ~/.dsh/settings.yaml
64
+ agent-default-model:
65
+ provider: openai-codex
66
+ model: gpt-5.6-terra
67
+ reasoningEffort: medium
68
+ ```
69
+
70
+ ## Configuration
71
+
72
+ All keys are optional; the defaults match a stock CLI install.
73
+
74
+ | Key | Default | Meaning |
75
+ | --- | --- | --- |
76
+ | `codexAuthPath` | `$CODEX_HOME/auth.json`, else `~/.codex/auth.json` | Codex credential file |
77
+ | `claudeAuthPath` | `~/.claude/.credentials.json` | Legacy Claude Code credential file |
78
+ | `claudeKeychainService` | `Claude Code-credentials` | macOS Keychain service holding the Claude OAuth payload |
79
+ | `requireClaude` | `false` | Fail activation when no Claude credential is found, instead of skipping the route |
80
+ | `codexTransport` | `"sse"` | Streaming transport for the Codex route: `sse` / `websocket` / `websocket-cached` / `auto`. **The quota badge depends on `sse`**: pi-ai's default `auto` streams over WebSocket, and the `x-codex-*` quota headers exist only on the SSE response, so the badge stays empty under WS. Set `auto` to prefer WebSocket and accept no Codex quota data. |
81
+
82
+ ## Subscription usage badge
83
+
84
+ Both providers return their quota state in response headers, so the plugin reads it for free —
85
+ no polling, no extra endpoint hits. A badge appears in the composer bar next to the context
86
+ ring; click it for the breakdown.
87
+
88
+ | Provider | Headers read | Shown |
89
+ | --- | --- | --- |
90
+ | `openai-codex` | `x-codex-primary-*`, `x-codex-secondary-*`, `x-codex-plan-type`, `x-codex-credits-balance` | plan, used % per window, reset countdown, credit balance |
91
+ | `anthropic` | `anthropic-ratelimit-unified-{5h,7d}-{utilization,reset,status}` | used % for the 5-hour and 7-day windows, reset countdown |
92
+
93
+ The badge is green under 60%, amber under 85%, red above. Usage is whatever the **last real
94
+ request** reported, so a freshly started host shows "no data yet" until you send one message.
95
+ The browser half polls `GET /llm-local-token/usage` every 15s; that route only reads the
96
+ in-memory snapshot.
97
+
98
+ The badge shows **only the provider serving the currently selected model**: pick Codex and you see
99
+ Codex's windows, switch to Claude and it swaps — the two are never mixed into one number. When the
100
+ selected model belongs to another adapter (a plain API key, another plugin) the badge hides itself,
101
+ because that quota is not this plugin's to report. The popover still lists every route, with the
102
+ active one first and marked "current" and the rest dimmed. The selection comes from
103
+ `ctx.modelDirectories`; a composition without that service (non-Web) falls back to the previous
104
+ union-of-all-routes view.
105
+
106
+ ## Requirements
107
+
108
+ - Node.js **22.13+** (DSH's own floor; `--use-system-ca` needs it too)
109
+ - `dsh-base` in the profile — it already provides `@deepseek-ai/dsh-llm-pi-ai` and `@earendil-works/pi-ai`
110
+ - A signed-in CLI: `codex login` for the Codex route; Claude Code for the Anthropic route
111
+ - The Claude Keychain lookup is macOS-only. On Linux/Windows only the file store is consulted.
112
+
113
+ ## Token handling
114
+
115
+ - Read per request, never cached in memory beyond the call
116
+ - Refreshed when less than 5 minutes of life remain, then **written back to the same file** the
117
+ CLI reads, so the CLI stays logged in (single-flight: concurrent requests trigger one refresh)
118
+ - Written atomically with `0600` permissions
119
+ - Never logged, never sent anywhere except the provider endpoint
120
+
121
+ ## Troubleshooting
122
+
123
+ ### `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`
124
+
125
+ Your traffic goes through a TLS-inspecting proxy (Zscaler, Netskope, corporate MITM). Node does
126
+ not trust its root CA even when the OS does. Start DSH with either:
127
+
128
+ ```bash
129
+ node --use-system-ca … # trust the OS store (Node 22.13+)
130
+ NODE_EXTRA_CA_CERTS=/path/to/root-ca.pem dsh … # or point at the proxy's root cert
131
+ ```
132
+
133
+ ### `Provider is not configured: openai-codex`
134
+
135
+ Means the pi-ai provider refused an API-key override. This plugin already attaches an api-key
136
+ auth method to the OAuth-only Codex provider; seeing this error again implies a pi-ai version
137
+ whose `resolveProviderAuth` changed — open an issue with your `@earendil-works/pi-ai` version.
138
+
139
+ ### The model list shows no Codex/Claude entries
140
+
141
+ Check the boot log for `llm-local-token: registered …`. If it names only `openai-codex`, no
142
+ Claude credential was found (expected when Claude Code was never used on this machine).
143
+
144
+ ## Caveats
145
+
146
+ - Uses your **personal subscription quota** (ChatGPT Plus/Pro, Claude Pro/Max). Respect the
147
+ provider's terms; this is not a way to share one seat across a team.
148
+ - `chatgpt.com/backend-api` is the Codex client's own endpoint, not a documented public API. It
149
+ can change without notice; pin the pi-ai version if you need stability.
150
+
151
+ ## License
152
+
153
+ MIT
package/README.zh.md ADDED
@@ -0,0 +1,141 @@
1
+ # dsh-llm-local-token
2
+
3
+ 一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 插件:让 DSH 的 LLM 请求
4
+ 直接复用你**本机 CLI 已有的 OAuth 登录态**,不需要另配 API key,也不用重新登录。只要你登录过
5
+ Codex CLI 或 Claude Code,这些订阅就会变成 DSH 里可选的模型路由。
6
+
7
+ | Provider 路由 | 凭据来源 | 端点 |
8
+ | --- | --- | --- |
9
+ | `openai-codex` | `~/.codex/auth.json`(ChatGPT OAuth,与 `codex` CLI 共用) | `https://chatgpt.com/backend-api` |
10
+ | `anthropic` | `~/.claude/.credentials.json`,否则读 macOS Keychain 的 `Claude Code-credentials` | `https://api.anthropic.com` |
11
+
12
+ 插件加载后模型直接出现在模型选择器里。缺少凭据的路由会被跳过,不会导致启动失败。
13
+
14
+ <table>
15
+ <tr>
16
+ <td align="center" width="50%"><sub>两份订阅都成了模型选择器里的路由</sub><br><img src="https://raw.githubusercontent.com/tianxia--/dsh-llm-local-token/main/docs/model-routes.png" alt="DSH 模型选择器中的 OpenAI Codex (local token) 与 Claude (local token) 分组" width="330"></td>
17
+ <td align="center" width="50%"><sub>订阅用量,来自 provider 的 rate-limit 响应头</sub><br><img src="https://raw.githubusercontent.com/tianxia--/dsh-llm-local-token/main/docs/subscription-usage.png" alt="订阅用量弹层,显示 Claude 与 OpenAI Codex 的配额窗口" width="400"></td>
18
+ </tr>
19
+ </table>
20
+
21
+ ## 为什么需要它
22
+
23
+ DSH 通过凭据服务解析 provider 的 key,而个人版 Codex / Claude 订阅是 OAuth-only 的,根本没有
24
+ API key。这个插件在每次请求时从 CLI 维护的文件里解析 token,临近过期自动刷新,然后交给 DSH 自带
25
+ 的 pi-ai 引擎发请求。
26
+
27
+ ## 安装
28
+
29
+ ```bash
30
+ dsh plugin --profile web add dsh-llm-local-token
31
+
32
+ # 或直接从 git 安装
33
+ dsh plugin --profile web add https://github.com/tianxia--/dsh-llm-local-token.git
34
+ ```
35
+
36
+ 然后重启 `dsh`——安装就到这里。本包声明了 profile bundle(`dsh.bundle.patch` →
37
+ [`cordis.patch.yml`](cordis.patch.yml)),DSH 会替你插入那条 loader 行,**不需要**
38
+ 手工编辑 profile 自己的 `cordis.patch.yml`。
39
+
40
+ <details>
41
+ <summary>改为手工启用</summary>
42
+
43
+ 如果你是把插件源码复制进来用,或者想在自己的补丁层里固定它的 `config`,那就自己往
44
+ `~/.dsh/profiles/web/cordis.patch.yml` 追加这一行。profile 自己的补丁层在所有 bundle
45
+ 层之后生效,所以在这里重述同一个 id 也可以覆盖 bundle 的默认值:
46
+
47
+ ```yaml
48
+ - insert:
49
+ - id: llm-local-token
50
+ name: dsh-llm-local-token
51
+ ```
52
+
53
+ </details>
54
+
55
+ 想设为默认模型:
56
+
57
+ ```yaml
58
+ # ~/.dsh/settings.yaml
59
+ agent-default-model:
60
+ provider: openai-codex
61
+ model: gpt-5.6-terra
62
+ reasoningEffort: medium
63
+ ```
64
+
65
+ ## 配置项
66
+
67
+ 全部可选,默认值对应标准 CLI 安装。
68
+
69
+ | 键 | 默认 | 说明 |
70
+ | --- | --- | --- |
71
+ | `codexAuthPath` | `$CODEX_HOME/auth.json`,否则 `~/.codex/auth.json` | Codex 凭据文件 |
72
+ | `claudeAuthPath` | `~/.claude/.credentials.json` | 旧版 Claude Code 凭据文件 |
73
+ | `claudeKeychainService` | `Claude Code-credentials` | 存放 Claude OAuth 数据的 Keychain 服务名 |
74
+ | `requireClaude` | `false` | 为 `true` 时找不到 Claude 凭据就启动失败(而不是跳过) |
75
+ | `codexTransport` | `"sse"` | Codex 路由的流式通道:`sse` / `websocket` / `websocket-cached` / `auto`。**额度徽标依赖 `sse`**:pi-ai 默认的 `auto` 会走 WebSocket,而 `x-codex-*` 额度响应头只存在于 SSE 响应上,走 WS 时徽标永远是「暂无数据」。想要 WebSocket 就设成 `auto`,代价是没有 Codex 额度数据。 |
76
+
77
+ ## 订阅用量徽标
78
+
79
+ 两家 provider 都在响应头里返回额度状态,插件顺带读取即可 —— 不轮询、不额外调接口。输入框工具条上
80
+ (上下文圆环旁边)会出现一个徽标,点开看明细。
81
+
82
+ | Provider | 读取的响应头 | 展示内容 |
83
+ | --- | --- | --- |
84
+ | `openai-codex` | `x-codex-primary-*`、`x-codex-secondary-*`、`x-codex-plan-type`、`x-codex-credits-balance` | 套餐、各窗口已用百分比、重置倒计时、点数余额 |
85
+ | `anthropic` | `anthropic-ratelimit-unified-{5h,7d}-{utilization,reset,status}` | 5 小时与 7 天窗口的已用百分比、重置倒计时 |
86
+
87
+ 低于 60% 显示绿色,低于 85% 琥珀色,更高显示红色。数值来自**最近一次真实请求**,所以刚启动时会显示
88
+ 「暂无数据」,发一条消息即可。浏览器端每 15 秒轮询 `GET /llm-local-token/usage`,该路由只读内存快照。
89
+
90
+ 徽标**只显示当前选中模型所属 provider** 的用量:选 Codex 就是 Codex 的窗口,切到 Claude 就换成
91
+ Claude 的,不会把两家的数字混在一起。选中的模型由别的 adapter 提供(普通 API key、其他插件)时徽标
92
+ 直接隐藏 —— 那份额度不属于本插件。展开的弹层仍列出所有路由,当前那条排在最前并标注「当前」,其余淡显。
93
+ 当前选中项来自 `ctx.modelDirectories`;组合里没有该服务时(非 Web)退回旧的「全部路由合并」显示。
94
+
95
+ ## 环境要求
96
+
97
+ - Node.js **22.13+**(DSH 本身的底线,`--use-system-ca` 也需要)
98
+ - profile 里有 `dsh-base`(它已自带 `dsh-llm-pi-ai` 与 `@earendil-works/pi-ai`)
99
+ - 已登录的 CLI:Codex 路由需 `codex login`;Anthropic 路由需用过 Claude Code
100
+ - Claude 的 Keychain 读取仅限 macOS;其它系统只查文件
101
+
102
+ ## token 处理
103
+
104
+ - 每次请求实时读取,不在内存中长期保留
105
+ - 剩余有效期不足 5 分钟时自动刷新,并**写回 CLI 读取的同一文件**,因此不会破坏 CLI 的登录态
106
+ (单飞机制:并发请求只触发一次刷新)
107
+ - 原子写入,权限 `0600`
108
+ - 不打日志、不上传,只发往对应的 provider 端点
109
+
110
+ ## 故障排查
111
+
112
+ ### `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`
113
+
114
+ 说明你的流量经过 TLS 解密代理(Zscaler、Netskope 等企业 MITM)。即使系统信任其根证书,Node 也不
115
+ 信任。启动 DSH 时二选一:
116
+
117
+ ```bash
118
+ node --use-system-ca … # 信任系统证书库(Node 22.13+)
119
+ NODE_EXTRA_CA_CERTS=/path/to/root-ca.pem dsh … # 或直接指向代理根证书
120
+ ```
121
+
122
+ ### `Provider is not configured: openai-codex`
123
+
124
+ pi-ai 拒绝了 apiKey 覆盖。本插件已为 OAuth-only 的 Codex provider 附加了 api-key 认证方法;若仍
125
+ 报此错,说明 pi-ai 的 `resolveProviderAuth` 行为有变——请附上 `@earendil-works/pi-ai` 版本反馈。
126
+
127
+ ### 模型列表里看不到 Codex / Claude
128
+
129
+ 看启动日志里的 `llm-local-token: registered …`。如果只列出 `openai-codex`,说明没找到 Claude
130
+ 凭据(这台机器没用过 Claude Code 时属正常)。
131
+
132
+ ## 注意事项
133
+
134
+ - 消耗的是你**个人订阅额度**(ChatGPT Plus/Pro、Claude Pro/Max),请遵守服务条款,不要用它把一个
135
+ 账号共享给整个团队。
136
+ - `chatgpt.com/backend-api` 是 Codex 客户端自用端点,不是公开 API,可能随时变化;需要稳定性就锁
137
+ 定 pi-ai 版本。
138
+
139
+ ## 许可
140
+
141
+ MIT
@@ -0,0 +1,16 @@
1
+ # The dsh-llm-local-token bundle patch.
2
+ #
3
+ # DSH resolves this file through the `dsh.bundle.patch` manifest field in
4
+ # package.json, never through code. Declaring it makes this package a profile
5
+ # bundle, so `dsh plugin add dsh-llm-local-token` is enough to enable the
6
+ # plugin: the row below is inserted for you and no hand-editing of the
7
+ # profile's own cordis.patch.yml is required.
8
+ #
9
+ # The row carries no `config`, so every option keeps the default documented in
10
+ # the README. A patch replaces a row's whole `config` rather than merging into
11
+ # it, so your profile's cordis.patch.yml can restate this id to configure the
12
+ # plugin, and that later layer wins.
13
+
14
+ - insert:
15
+ - id: llm-local-token
16
+ name: dsh-llm-local-token
@@ -0,0 +1,93 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { homedir, userInfo } from "node:os";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+ /** The Keychain path exists on macOS only; other platforms use the file store. */
9
+ const HAS_KEYCHAIN = process.platform === "darwin";
10
+ const SERVICE = "Claude Code-credentials";
11
+ const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
12
+ const CLIENT_ID = Buffer.from("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl", "base64").toString("utf8");
13
+ const REFRESH_SKEW_MS = 5 * 60 * 1000;
14
+
15
+ export const defaultClaudeAuthPath = () => join(homedir(), ".claude", ".credentials.json");
16
+
17
+ function parseSecurityPassword(stdout, stderr) {
18
+ const combined = [stdout, stderr].filter(Boolean).join("\n");
19
+ const line = combined.split("\n").find((entry) => entry.startsWith("password: "));
20
+ if (!line) throw new Error("security output did not contain a password line");
21
+ let raw = line.slice("password: ".length);
22
+ if (raw.startsWith('"') && raw.endsWith('"')) raw = raw.slice(1, -1);
23
+ return raw;
24
+ }
25
+
26
+ async function readKeychainJson(service = SERVICE) {
27
+ const { stdout, stderr } = await execFileAsync("security", ["find-generic-password", "-s", service, "-g"], { maxBuffer: 1024 * 1024 });
28
+ return JSON.parse(parseSecurityPassword(stdout, stderr));
29
+ }
30
+
31
+ async function writeKeychainJson(data, service = SERVICE, account = userInfo().username) {
32
+ await execFileAsync("security", ["add-generic-password", "-U", "-a", account, "-s", service, "-w", JSON.stringify(data)], { maxBuffer: 1024 * 1024 });
33
+ }
34
+
35
+ async function refreshClaudeKeychainToken(data, options = {}) {
36
+ const current = data?.claudeAiOauth;
37
+ const refreshToken = current?.refreshToken;
38
+ if (!refreshToken) throw new Error("Claude Keychain entry has no claudeAiOauth.refreshToken");
39
+ const response = await fetch(TOKEN_URL, {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: JSON.stringify({ grant_type: "refresh_token", client_id: CLIENT_ID, refresh_token: refreshToken }),
43
+ });
44
+ const text = await response.text();
45
+ if (!response.ok) throw new Error(`Claude token refresh failed (${response.status}): ${text.slice(0, 300)}`);
46
+ const json = JSON.parse(text);
47
+ const next = {
48
+ ...data,
49
+ claudeAiOauth: {
50
+ ...current,
51
+ accessToken: json.access_token,
52
+ refreshToken: json.refresh_token ?? current.refreshToken,
53
+ expiresAt: Date.now() + (json.expires_in ?? 3600) * 1000 - REFRESH_SKEW_MS,
54
+ },
55
+ };
56
+ await writeKeychainJson(next, options.service, options.account);
57
+ return next.claudeAiOauth.accessToken;
58
+ }
59
+
60
+ async function readLegacyClaudeCredentialsFile(path) {
61
+ const raw = await readFile(path, "utf8");
62
+ const creds = JSON.parse(raw);
63
+ const first = creds?.tokens?.[0];
64
+ const token = first?.accessToken ?? first?.authToken ?? creds?.accessToken;
65
+ return typeof token === "string" && token.length > 0 ? token : undefined;
66
+ }
67
+
68
+ export async function resolveClaudeAccessToken(options = {}) {
69
+ // Old Claude Code builds wrote ~/.claude/.credentials.json. Prefer it when present.
70
+ const filePath = options.filePath ?? defaultClaudeAuthPath();
71
+ try {
72
+ const token = await readLegacyClaudeCredentialsFile(filePath);
73
+ if (token) return token;
74
+ } catch (error) {
75
+ if (error?.code !== "ENOENT") throw error;
76
+ }
77
+
78
+ // New Claude Code builds store the account OAuth payload in macOS Keychain.
79
+ // Elsewhere the file above is the only store, so say so plainly rather than
80
+ // shelling out to a tool that does not exist.
81
+ if (!HAS_KEYCHAIN) {
82
+ throw new Error(`llm-local-token: no Claude credentials at ${filePath} (Keychain lookup is macOS-only on ${process.platform})`);
83
+ }
84
+ const service = options.service ?? SERVICE;
85
+ const data = await readKeychainJson(service);
86
+ const oauth = data?.claudeAiOauth;
87
+ const token = oauth?.accessToken;
88
+ if (typeof token !== "string" || token.length === 0) throw new Error(`Claude Keychain service "${service}" has no claudeAiOauth.accessToken`);
89
+ if (typeof oauth.expiresAt === "number" && oauth.expiresAt - Date.now() <= REFRESH_SKEW_MS) {
90
+ return refreshClaudeKeychainToken(data, { service, account: options.account });
91
+ }
92
+ return token;
93
+ }
package/lib/client.js ADDED
@@ -0,0 +1,347 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-llm-local-token",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ const jsxRuntime = require("react/jsx-runtime");
8
+ const react = require("react");
9
+ const runtime = require("@deepseek-ai/dsh-client-runtime/client");
10
+ const jsx = jsxRuntime.jsx;
11
+ const jsxs = jsxRuntime.jsxs;
12
+
13
+ const NS = "llm.localToken";
14
+ const ROUTE = "/llm-local-token/usage";
15
+ /** Poll cadence while the composer is mounted. Usage only moves on requests. */
16
+ const POLL_MS = 15000;
17
+
18
+ const CSS = ".ltk_wrap{position:relative;display:inline-flex}" +
19
+ ".ltk_btn{display:inline-flex;align-items:center;gap:5px;height:24px;padding:0 8px;font:inherit;font-size:11px;font-variant-numeric:tabular-nums;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}" +
20
+ ".ltk_btn:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-3)}" +
21
+ ".ltk_dot{width:6px;height:6px;border-radius:50%;flex:none}" +
22
+ ".ltk_pop{position:fixed;z-index:220;width:280px;max-width:calc(100vw - 32px);padding:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-specific-menu,var(--dsw-alias-bg-layer-2));box-shadow:0 12px 32px rgba(0,0,0,.28)}" +
23
+ ".ltk_title{margin:0 0 8px;font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary)}" +
24
+ ".ltk_prov{padding-top:8px;margin-top:8px;border-top:1px solid var(--dsw-alias-border-l2)}" +
25
+ ".ltk_prov:first-of-type{padding-top:0;margin-top:0;border-top:none}" +
26
+ ".ltk_provDim{opacity:.5}" +
27
+ ".ltk_cur{padding:0 6px;border-radius:999px;font-size:10px;line-height:16px;background:var(--dsw-alias-button-contrast-fill,var(--dsw-alias-bg-module-platform));color:var(--dsw-alias-label-primary-inverted)}" +
28
+ ".ltk_provHead{display:flex;align-items:center;gap:6px;margin-bottom:6px}" +
29
+ ".ltk_provName{flex:1;font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary)}" +
30
+ ".ltk_plan{padding:0 6px;border-radius:999px;font-size:10px;line-height:16px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)}" +
31
+ ".ltk_row{margin-top:6px}" +
32
+ ".ltk_rowTop{display:flex;align-items:baseline;gap:6px;font-size:11px;color:var(--dsw-alias-label-tertiary)}" +
33
+ ".ltk_rowLabel{flex:1}" +
34
+ ".ltk_pct{font-variant-numeric:tabular-nums;font-weight:600;color:var(--dsw-alias-label-primary)}" +
35
+ ".ltk_bar{height:4px;margin-top:3px;border-radius:999px;background:var(--dsw-alias-bg-module-platform);overflow:hidden}" +
36
+ ".ltk_fill{height:100%;border-radius:999px}" +
37
+ ".ltk_hint{margin:8px 0 0;font-size:11px;color:var(--dsw-alias-label-tertiary);line-height:1.5}";
38
+
39
+ if (typeof document !== "undefined" && document.querySelector('style[data-plugin-css="dsh-llm-local-token"]') === null) {
40
+ const tag = document.createElement("style");
41
+ tag.dataset.plugin = "dsh-llm-local-token";
42
+ tag.dataset.pluginCss = "dsh-llm-local-token";
43
+ tag.textContent = CSS;
44
+ document.head.appendChild(tag);
45
+ }
46
+
47
+ const en = {
48
+ label: "Quota", title: "Subscription usage",
49
+ "window.primary": "Primary", "window.secondary": "Secondary",
50
+ "window.5h": "5 hours", "window.7d": "7 days",
51
+ empty: "No data yet — send one message to read your quota.",
52
+ resets: "resets {when}", credits: "credits: {balance}",
53
+ current: "current",
54
+ "unit.d": "{n} days", "unit.h": "{n} hours", "unit.m": "{n} min",
55
+ };
56
+ const zh = {
57
+ label: "额度", title: "订阅用量",
58
+ "window.primary": "主窗口", "window.secondary": "次窗口",
59
+ "window.5h": "5 小时", "window.7d": "7 天",
60
+ empty: "暂无数据 —— 发一条消息即可读取额度。",
61
+ resets: "{when}重置", credits: "点数余额:{balance}",
62
+ current: "当前",
63
+ "unit.d": "{n} 天", "unit.h": "{n} 小时", "unit.m": "{n} 分钟",
64
+ };
65
+
66
+ /** Green under 60%, amber under 85%, red above. */
67
+ function tone(used) {
68
+ if (used >= 0.85) return "hsl(4 72% 55%)";
69
+ if (used >= 0.6) return "hsl(38 85% 52%)";
70
+ return "hsl(150 55% 45%)";
71
+ }
72
+
73
+ /**
74
+ * Name one window by its length, so "10080 minutes" reads as "7 天".
75
+ *
76
+ * The duration is the fact a reader acts on and the only one comparable
77
+ * across providers; Codex's `primary`/`secondary` is opaque vendor
78
+ * vocabulary that hides a length the response already stated. So the
79
+ * provider's own label survives only as the fallback for a window that
80
+ * reports no length at all.
81
+ */
82
+ function windowLabel(t, entry) {
83
+ const minutes = entry.windowMinutes ?? 0;
84
+ if (minutes >= 1440) return t("unit.d", { n: String(Math.round(minutes / 1440)) });
85
+ if (minutes >= 60) return t("unit.h", { n: String(Math.round(minutes / 60)) });
86
+ if (minutes > 0) return t("unit.m", { n: String(minutes) });
87
+ const known = t("window." + entry.label);
88
+ return known === "window." + entry.label ? entry.label : known;
89
+ }
90
+
91
+ /** Compact relative time for a reset instant. */
92
+ function resetIn(iso) {
93
+ const at = Date.parse(iso ?? "");
94
+ if (!Number.isFinite(at)) return "";
95
+ const seconds = Math.max(0, Math.round((at - Date.now()) / 1000));
96
+ if (seconds < 3600) return String(Math.round(seconds / 60)) + "m";
97
+ if (seconds < 86400) return String(Math.round(seconds / 3600)) + "h";
98
+ return String(Math.round(seconds / 86400)) + "d";
99
+ }
100
+
101
+ function UsageRow({ t, entry }) {
102
+ const percent = Math.round(entry.used * 100);
103
+ return jsxs("div", {
104
+ className: "ltk_row",
105
+ children: [
106
+ jsxs("div", {
107
+ className: "ltk_rowTop",
108
+ children: [
109
+ jsx("span", { className: "ltk_rowLabel", children: windowLabel(t, entry) }),
110
+ jsx("span", { className: "ltk_pct", children: percent + "%" }),
111
+ entry.resetAt === undefined ? null : jsx("span", { children: t("resets", { when: resetIn(entry.resetAt) }) }),
112
+ ],
113
+ }),
114
+ jsx("div", {
115
+ className: "ltk_bar",
116
+ children: jsx("div", { className: "ltk_fill", style: { width: Math.max(2, percent) + "%", background: tone(entry.used) } }),
117
+ }),
118
+ ],
119
+ });
120
+ }
121
+
122
+ /** The quota badge that sits beside the composer's context ring. */
123
+ function UsageBadge(props) {
124
+ const { t } = props;
125
+ const state = props.useLocalTokenUsage((snapshot) => snapshot);
126
+ // The provider of the model this session will actually use. The hook only
127
+ // exists when the host composed model selection (Web); its presence is
128
+ // fixed at registration time, so this call order never changes at runtime.
129
+ const activeProvider = typeof props.useModelSelection === "function"
130
+ ? props.useModelSelection((snapshot) => snapshot.current?.provider ?? null)
131
+ : null;
132
+ const wrapRef = react.useRef(null);
133
+ const [pos, setPos] = react.useState(null);
134
+ react.useEffect(() => {
135
+ props.start();
136
+ props.ensureSelection();
137
+ return () => props.stop();
138
+ }, []);
139
+ react.useEffect(() => {
140
+ if (!state.open) return undefined;
141
+ const measure = () => {
142
+ const rect = wrapRef.current?.getBoundingClientRect();
143
+ if (rect === undefined) return;
144
+ const margin = 16;
145
+ const width = Math.min(280, window.innerWidth - margin * 2);
146
+ setPos({
147
+ left: Math.max(margin, Math.min(Math.round(rect.left), window.innerWidth - width - margin)),
148
+ top: Math.max(margin, Math.round(rect.top) - 12),
149
+ width,
150
+ transform: "translateY(-100%)",
151
+ });
152
+ };
153
+ measure();
154
+ const closeOutside = (event) => {
155
+ if (event.target instanceof Node && wrapRef.current?.contains(event.target) !== true) props.toggle();
156
+ };
157
+ const closeOnEscape = (event) => { if (event.key === "Escape") props.toggle(); };
158
+ window.addEventListener("resize", measure);
159
+ window.addEventListener("scroll", measure, true);
160
+ document.addEventListener("pointerdown", closeOutside);
161
+ document.addEventListener("keydown", closeOnEscape);
162
+ return () => {
163
+ window.removeEventListener("resize", measure);
164
+ window.removeEventListener("scroll", measure, true);
165
+ document.removeEventListener("pointerdown", closeOutside);
166
+ document.removeEventListener("keydown", closeOnEscape);
167
+ };
168
+ }, [state.open]);
169
+
170
+ const withData = state.providers.filter((entry) => entry.usage !== null);
171
+ /** The route serving the selected model, when this plugin owns it. */
172
+ const active = activeProvider === null
173
+ ? undefined
174
+ : state.providers.find((entry) => entry.provider === activeProvider);
175
+ /**
176
+ * The selected model belongs to some other adapter (a plain API key, a
177
+ * different plugin): this badge owns no quota fact about it, so it says
178
+ * nothing rather than showing a number from an unrelated subscription.
179
+ * Before the first poll the route list is empty, which is "not known yet".
180
+ */
181
+ const foreign = activeProvider !== null && state.providers.length > 0 && active === undefined;
182
+ // Selection unknown (no model-selection service): fall back to every route.
183
+ const windows = active !== undefined
184
+ ? (active.usage === null ? [] : active.usage.windows)
185
+ : withData.flatMap((entry) => entry.usage.windows);
186
+ const headline = windows.map((w) => Math.round(w.used * 100)).slice(0, 2);
187
+ const worst = windows.reduce((max, w) => Math.max(max, w.used), 0);
188
+ // Active route first; the others stay visible but recede.
189
+ const ordered = active === undefined
190
+ ? state.providers
191
+ : [active, ...state.providers.filter((entry) => entry !== active)];
192
+
193
+ // No local-token route at all (also the render before the first poll
194
+ // answers): the badge has nothing to say, so it does not exist.
195
+ if (state.providers.length === 0 || foreign) return null;
196
+
197
+ return jsxs("span", {
198
+ ref: wrapRef,
199
+ className: "ltk_wrap",
200
+ children: [
201
+ jsxs("button", {
202
+ type: "button",
203
+ className: "ltk_btn",
204
+ title: state.diag === undefined
205
+ ? t("title")
206
+ : t("title") + " — pid " + state.diag.pid + ", observed " + state.diag.observed,
207
+ onClick: () => props.toggle(),
208
+ children: [
209
+ jsx("span", { className: "ltk_dot", style: { background: windows.length === 0 ? "var(--dsw-alias-label-tertiary)" : tone(worst) } }),
210
+ jsx("span", { children: headline.length === 0 ? t("label") : headline.map((p) => p + "%").join(" · ") }),
211
+ ],
212
+ }),
213
+ !state.open ? null : jsxs("div", {
214
+ className: "ltk_pop",
215
+ style: pos === null ? { left: "16px", bottom: "72px" } : { left: pos.left + "px", top: pos.top + "px", width: pos.width + "px", transform: pos.transform },
216
+ children: [
217
+ jsx("p", { className: "ltk_title", children: t("title") }),
218
+ ...ordered.map((entry) => jsxs("div", {
219
+ className: activeProvider !== null && entry.provider !== activeProvider ? "ltk_prov ltk_provDim" : "ltk_prov",
220
+ children: [
221
+ jsxs("div", {
222
+ className: "ltk_provHead",
223
+ children: [
224
+ jsx("span", { className: "ltk_provName", children: entry.displayName }),
225
+ entry.provider !== activeProvider ? null : jsx("span", { className: "ltk_cur", children: t("current") }),
226
+ entry.usage?.plan === undefined ? null : jsx("span", { className: "ltk_plan", children: entry.usage.plan }),
227
+ ],
228
+ }),
229
+ entry.usage === null
230
+ ? jsx("p", { className: "ltk_hint", children: t("empty") })
231
+ : jsxs("div", {
232
+ children: [
233
+ ...entry.usage.windows.map((w) => jsx(UsageRow, { t, entry: w }, w.label)),
234
+ entry.usage.credits === undefined || entry.usage.creditsUnlimited === true
235
+ ? null
236
+ : jsx("p", { className: "ltk_hint", children: t("credits", { balance: String(entry.usage.credits) }) }),
237
+ ],
238
+ }),
239
+ ],
240
+ }, entry.provider)),
241
+ ],
242
+ }),
243
+ ],
244
+ });
245
+ }
246
+
247
+ /** Polls the host route; the snapshot only changes when a request happened. */
248
+ class UsageController {
249
+ /** @param directories - `ctx.modelDirectories`, or undefined off Web. */
250
+ constructor(directories) {
251
+ this.directories = directories;
252
+ this.state = { open: false, providers: [], diag: undefined };
253
+ this.store = runtime.createSnapshotStore({ ...this.state });
254
+ this.timer = undefined;
255
+ this.started = false;
256
+ }
257
+
258
+ publish(patch) {
259
+ this.state = { ...this.state, ...patch };
260
+ this.store.set({ ...this.state });
261
+ }
262
+
263
+ async poll() {
264
+ try {
265
+ const response = await fetch(ROUTE, { headers: { Accept: "application/json" } });
266
+ if (!response.ok) return;
267
+ const body = await response.json();
268
+ this.publish({ providers: body.providers ?? [], diag: body.diag });
269
+ } catch (_failure) {
270
+ // Route absent (plugin not loaded on this host): stay silent.
271
+ }
272
+ }
273
+
274
+ /**
275
+ * The session's shared model-selection store, or undefined when the host
276
+ * composed no model selection or does not know this session. Both cases
277
+ * degrade to the every-route view rather than failing the registration.
278
+ */
279
+ directoryFor(sessionId) {
280
+ if (this.directories === undefined || sessionId === undefined) return undefined;
281
+ try {
282
+ return this.directories.directoryFor(sessionId);
283
+ } catch (_unknownSession) {
284
+ return undefined;
285
+ }
286
+ }
287
+
288
+ inject(sessionId) {
289
+ const directory = this.directoryFor(sessionId);
290
+ return {
291
+ hooks: directory === undefined
292
+ ? { localTokenUsage: this.store }
293
+ : { localTokenUsage: this.store, modelSelection: directory.store },
294
+ /**
295
+ * `current` is null until something loads the directory. The composer
296
+ * model seat normally does, but ask once so a fresh session shows the
297
+ * right route instead of the union view.
298
+ */
299
+ ensureSelection: () => {
300
+ if (directory === undefined) return;
301
+ if (directory.store.getSnapshot().current !== null) return;
302
+ directory.load().catch(() => {});
303
+ },
304
+ start: () => {
305
+ if (this.started) return;
306
+ this.started = true;
307
+ this.poll();
308
+ this.timer = setInterval(() => this.poll(), POLL_MS);
309
+ },
310
+ stop: () => {
311
+ if (this.timer !== undefined) clearInterval(this.timer);
312
+ this.timer = undefined;
313
+ this.started = false;
314
+ },
315
+ toggle: () => {
316
+ const open = !this.state.open;
317
+ this.publish({ open });
318
+ if (open) this.poll();
319
+ },
320
+ };
321
+ }
322
+ }
323
+
324
+ const inject = ["slots", "locale"];
325
+
326
+ function apply(ctx) {
327
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "llm-local-token: dictionaries");
328
+ // Optional on purpose: read the service rather than declaring it in
329
+ // `inject`, so a composition without model selection still loads this
330
+ // plugin (the badge then reports every route, as it always did).
331
+ const controller = new UsageController(ctx.get("modelDirectories"));
332
+ // The quota badge is a clickable control, so it belongs in the input
333
+ // tool row (a list slot), beside the send button.
334
+ ctx.slots.inject("conversation.input.right", () => ctx.slots.register({
335
+ name: "conversation.input.right",
336
+ id: "local-token-usage",
337
+ order: 60,
338
+ locale: NS,
339
+ inject: (sessionId) => controller.inject(sessionId),
340
+ }, UsageBadge));
341
+ }
342
+
343
+ exports.apply = apply;
344
+ exports.inject = inject;
345
+ return module.exports;
346
+ }
347
+ });
package/lib/index.js ADDED
@@ -0,0 +1,188 @@
1
+ // DSH (DeepSeek Harness) plugin: serve LLM calls through the OAuth tokens
2
+ // your local CLIs already hold, instead of a separately configured API key.
3
+ //
4
+ // - openai-codex -> reads ~/.codex/auth.json (ChatGPT/Codex OAuth), refreshes
5
+ // the access token automatically, and streams against
6
+ // https://chatgpt.com/backend-api via the pi-ai engine.
7
+ // - anthropic -> reads Claude Code OAuth from ~/.claude/.credentials.json
8
+ // (legacy) or macOS Keychain service Claude Code-credentials
9
+ // (current), then streams against https://api.anthropic.com.
10
+ //
11
+ // Both providers appear in the model picker once this plugin is loaded.
12
+ import { LlmError } from "@deepseek-ai/dsh-llm";
13
+ import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
14
+ import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";
15
+ import { openaiCodexProvider } from "@earendil-works/pi-ai/providers/openai-codex";
16
+ import { defaultCodexAuthPath, resolveCodexAccessToken } from "./token-store.js";
17
+ import { defaultClaudeAuthPath, resolveClaudeAccessToken } from "./claude-keychain.js";
18
+ import { withUsageProbe } from "./usage.js";
19
+
20
+ /** Plugin identity used by the cordis loader entry. */
21
+ export const name = "llm-local-token";
22
+ /** Register only after the llm service exists. */
23
+ export const inject = ["llm"];
24
+ /** Route prefix serving the quota snapshots to the browser. */
25
+ const USAGE_PREFIX = "/llm-local-token";
26
+
27
+ function sendJson(res, status, value) {
28
+ const body = JSON.stringify(value);
29
+ res.writeHead(status, {
30
+ "Content-Type": "application/json; charset=utf-8",
31
+ "Cache-Control": "no-store",
32
+ "Content-Length": Buffer.byteLength(body),
33
+ });
34
+ res.end(body);
35
+ }
36
+
37
+ /**
38
+ * Attach an api-key auth method to a provider that pi-ai ships as OAuth-only
39
+ * (the openai-codex provider). pi-ai's `resolveProviderAuth` honours an
40
+ * `apiKey` override only when `provider.auth.apiKey` exists; without it the
41
+ * request dies with "Provider is not configured". The harness resolves the
42
+ * local token itself and hands it over as the request's apiKey, so this method
43
+ * only needs to pass that key through to the wire.
44
+ */
45
+ function withApiKeyAuth(provider, name) {
46
+ return {
47
+ ...provider,
48
+ auth: {
49
+ ...provider.auth,
50
+ apiKey: {
51
+ name,
52
+ resolve: async ({ credential }) => ({
53
+ auth: credential?.key === void 0 ? {} : { apiKey: credential.key },
54
+ source: name,
55
+ }),
56
+ },
57
+ },
58
+ };
59
+ }
60
+
61
+ /** Transport values pi-ai's profile vocabulary accepts. */
62
+ const TRANSPORTS = ["sse", "websocket", "websocket-cached", "auto"];
63
+
64
+ /**
65
+ * Build one adapter profile in the shape PiAiAdapter expects.
66
+ * @param transport - optional streaming transport preference; undefined leaves
67
+ * pi-ai's own default ("auto") in charge.
68
+ */
69
+ function profileOf(provider, displayName, piProvider, transport) {
70
+ return {
71
+ provider,
72
+ displayName,
73
+ piProvider,
74
+ retryPolicy: undefined,
75
+ streamIdleTimeoutMs: 300_000,
76
+ configuredMaxTokens: new Map(),
77
+ ...(transport === undefined ? {} : { transport }),
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Plugin entry. Builds the local-token routes, registers one pi-ai adapter
83
+ * serving them, and exposes the providers to the model picker.
84
+ */
85
+ export async function apply(ctx, config = {}) {
86
+ const routes = [];
87
+ /** Latest quota snapshot per provider id, replaced on every observed response. */
88
+ const usage = new Map();
89
+ /**
90
+ * Diagnostics for "the badge shows no numbers": which process/apply owns this
91
+ * route, how many responses the probe observed, and when the last one landed.
92
+ * Cheap to keep and the only way to tell a stale host from a broken probe.
93
+ */
94
+ const diag = { pid: process.pid, appliedAt: new Date().toISOString(), observed: 0, lastAt: null, lastProvider: null };
95
+ const record = (snapshot) => {
96
+ usage.set(snapshot.provider, snapshot);
97
+ diag.observed += 1;
98
+ diag.lastAt = snapshot.at;
99
+ diag.lastProvider = snapshot.provider;
100
+ };
101
+
102
+ // ── Codex route: local ~/.codex/auth.json (ChatGPT OAuth) ────────────────
103
+ //
104
+ // Transport matters for the quota badge. Under pi-ai's default "auto" the
105
+ // Codex provider streams over WebSocket, and the `x-codex-*` quota headers
106
+ // exist only on the SSE response — so the usage probe observes nothing and the
107
+ // badge stays empty forever. "sse" keeps it as fresh as the Claude route; set
108
+ // `codexTransport: "auto"` to prefer WebSocket and accept no quota data.
109
+ const codexAuthPath = config.codexAuthPath ?? defaultCodexAuthPath();
110
+ const requestedTransport = config.codexTransport ?? "sse";
111
+ const codexTransport = TRANSPORTS.includes(requestedTransport) ? requestedTransport : "sse";
112
+ if (codexTransport !== requestedTransport) {
113
+ ctx.logger.info(`llm-local-token: ignoring unknown codexTransport "${requestedTransport}"; using "sse"`);
114
+ }
115
+ routes.push({
116
+ provider: "openai-codex",
117
+ displayName: "OpenAI Codex (local token)",
118
+ piProvider: withUsageProbe(withApiKeyAuth(openaiCodexProvider(), "Codex local token"), record),
119
+ resolveApiKey: async () => resolveCodexAccessToken(codexAuthPath),
120
+ transport: codexTransport,
121
+ });
122
+
123
+ // ── Claude route: local Claude Code credentials (legacy file or Keychain) ─
124
+ const claudeAuthPath = config.claudeAuthPath ?? defaultClaudeAuthPath();
125
+ const claudeKeychainService = config.claudeKeychainService ?? "Claude Code-credentials";
126
+ try {
127
+ // Resolve once at startup to decide whether to register the route. Requests
128
+ // resolve again so token refreshes/Keychain updates are observed.
129
+ await resolveClaudeAccessToken({ filePath: claudeAuthPath, service: claudeKeychainService, account: config.claudeKeychainAccount });
130
+ routes.push({
131
+ provider: "anthropic",
132
+ displayName: "Claude (local token)",
133
+ piProvider: withUsageProbe(anthropicProvider(), record),
134
+ resolveApiKey: async () => resolveClaudeAccessToken({ filePath: claudeAuthPath, service: claudeKeychainService, account: config.claudeKeychainAccount }),
135
+ });
136
+ } catch (error) {
137
+ if (config.requireClaude === true) throw error;
138
+ ctx.logger.info(`llm-local-token: Claude local token not usable (${String(error?.message ?? error).slice(0, 160)}); skipping Claude provider`);
139
+ }
140
+
141
+ const profiles = () => new Map(routes.map((route) => [
142
+ route.provider,
143
+ profileOf(route.provider, route.displayName, route.piProvider, route.transport),
144
+ ]));
145
+
146
+ const adapter = new PiAiAdapter({
147
+ profiles,
148
+ resolveApiKey: async (provider) => {
149
+ const route = routes.find((entry) => entry.provider === provider);
150
+ if (route === void 0) {
151
+ throw new LlmError(`llm-local-token does not own provider "${provider}"`, "NO_ADAPTER");
152
+ }
153
+ try {
154
+ return await route.resolveApiKey();
155
+ } catch (error) {
156
+ throw new LlmError(error?.message ?? String(error), "MISSING_CREDENTIAL", { cause: error });
157
+ }
158
+ },
159
+ resolveAttachments: () => ctx.get("attachments"),
160
+ });
161
+
162
+ ctx.llm.registerAdapter(routes.map((route) => route.provider), adapter);
163
+
164
+ // Quota snapshots observed on real responses, newest per provider.
165
+ ctx.inject(["webServer"], (wctx) => {
166
+ wctx.effect(() => wctx.webServer.register({
167
+ kind: "prefix",
168
+ path: USAGE_PREFIX,
169
+ handler: (req, res) => {
170
+ const path = new URL(req.url ?? "/", "http://localhost").pathname.slice(USAGE_PREFIX.length).replace(/^\/+/, "");
171
+ if (req.method !== "GET" || (path !== "usage" && path !== "")) {
172
+ return sendJson(res, 404, { error: `unknown route "${path}"` });
173
+ }
174
+ return sendJson(res, 200, {
175
+ providers: routes.map((route) => ({
176
+ provider: route.provider,
177
+ displayName: route.displayName,
178
+ usage: usage.get(route.provider) ?? null,
179
+ })),
180
+ diag,
181
+ fetchedAt: new Date().toISOString(),
182
+ });
183
+ },
184
+ }), "llm-local-token: usage route");
185
+ wctx.logger.info(`llm-local-token: usage route mounted at ${USAGE_PREFIX}/usage`);
186
+ });
187
+ ctx.logger.info(`llm-local-token: registered ${routes.map((route) => route.provider).join(", ")} (codex auth: ${codexAuthPath}, codex transport: ${codexTransport})`);
188
+ }
@@ -0,0 +1,127 @@
1
+ // Codex / ChatGPT OAuth token store backed by the Codex CLI's local
2
+ // credential file (~/.codex/auth.json). The same file the `codex` CLI
3
+ // maintains, so a token refreshed here stays valid for the CLI and vice versa.
4
+ import { readFile, rename, writeFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+
8
+ /** The public Codex OAuth client id (matches the pi-ai engine and the CLI). */
9
+ const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
10
+ const TOKEN_URL = "https://auth.openai.com/oauth/token";
11
+ /** Refresh when the access token has less than this much life left. */
12
+ const REFRESH_SKEW_MS = 5 * 60 * 1000;
13
+ /** In-flight refresh promise, shared by concurrent callers. */
14
+ let refreshing = null;
15
+
16
+ /** Resolve the Codex credential file path honouring $CODEX_HOME. */
17
+ export function defaultCodexAuthPath() {
18
+ return process.env.CODEX_HOME
19
+ ? join(process.env.CODEX_HOME, "auth.json")
20
+ : join(homedir(), ".codex", "auth.json");
21
+ }
22
+
23
+ /** Decode a JWT's `exp` claim in milliseconds, or undefined. */
24
+ function jwtExpMs(token) {
25
+ try {
26
+ const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8"));
27
+ return typeof payload.exp === "number" ? payload.exp * 1000 : undefined;
28
+ } catch {
29
+ return undefined;
30
+ }
31
+ }
32
+
33
+ function isFresh(token) {
34
+ const exp = jwtExpMs(token);
35
+ return exp === undefined || exp - Date.now() > REFRESH_SKEW_MS;
36
+ }
37
+
38
+ /**
39
+ * Read the current Codex auth document.
40
+ * @param path - credential file path.
41
+ * @returns the parsed JSON document.
42
+ */
43
+ export async function readCodexAuth(path = defaultCodexAuthPath()) {
44
+ const raw = await readFile(path, "utf8");
45
+ return JSON.parse(raw);
46
+ }
47
+
48
+ /**
49
+ * Atomically persist a refreshed auth document, preserving the original
50
+ * file's permission bits where possible (the file holds secrets).
51
+ */
52
+ async function writeCodexAuth(path, next) {
53
+ const tmp = `${path}.dsh-${process.pid}-${Date.now()}.tmp`;
54
+ await writeFile(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
55
+ try {
56
+ await rename(tmp, path);
57
+ } catch (error) {
58
+ try {
59
+ await import("node:fs/promises").then(({ unlink }) => unlink(tmp));
60
+ } catch {}
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Refresh the ChatGPT OAuth token pair and persist it back to the Codex
67
+ * credential file. Refreshing shares one in-flight promise so concurrent LLM
68
+ * requests trigger a single refresh.
69
+ * @param path - credential file path.
70
+ * @returns the fresh access token.
71
+ */
72
+ export async function refreshCodexToken(path = defaultCodexAuthPath()) {
73
+ if (refreshing) return refreshing;
74
+ refreshing = (async () => {
75
+ const data = await readCodexAuth(path);
76
+ const refreshToken = data?.tokens?.refresh_token;
77
+ if (!refreshToken) {
78
+ throw new Error(`local-token: no tokens.refresh_token in ${path} — re-login with the Codex CLI (` + 'codex login' + `)`);
79
+ }
80
+ const response = await fetch(TOKEN_URL, {
81
+ method: "POST",
82
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
83
+ body: new URLSearchParams({
84
+ grant_type: "refresh_token",
85
+ refresh_token: refreshToken,
86
+ client_id: CLIENT_ID,
87
+ }),
88
+ });
89
+ if (!response.ok) {
90
+ const text = await response.text().catch(() => "");
91
+ throw new Error(`local-token: token refresh failed (${response.status}) ${text.slice(0, 200)}`);
92
+ }
93
+ const json = await response.json();
94
+ const next = {
95
+ ...data,
96
+ tokens: {
97
+ id_token: json.id_token ?? data.tokens.id_token,
98
+ access_token: json.access_token,
99
+ refresh_token: json.refresh_token ?? data.tokens.refresh_token,
100
+ account_id: data.tokens.account_id,
101
+ },
102
+ last_refresh: new Date().toISOString(),
103
+ };
104
+ await writeCodexAuth(path, next);
105
+ return next.tokens.access_token;
106
+ })().finally(() => {
107
+ refreshing = null;
108
+ });
109
+ return refreshing;
110
+ }
111
+
112
+ /**
113
+ * Resolve the access token for an LLM request, refreshing when it is close to
114
+ * expiry. Throws when no credential exists so the caller reports a clear
115
+ * MISSING_CREDENTIAL-style failure instead of sending an unauthenticated call.
116
+ * @param path - credential file path.
117
+ * @returns the bearer access token.
118
+ */
119
+ export async function resolveCodexAccessToken(path = defaultCodexAuthPath()) {
120
+ const data = await readCodexAuth(path);
121
+ const token = data?.tokens?.access_token;
122
+ if (!token) {
123
+ throw new Error(`local-token: no tokens.access_token in ${path} — run the Codex CLI (` + 'codex login' + `) to sign in first`);
124
+ }
125
+ if (isFresh(token)) return token;
126
+ return refreshCodexToken(path);
127
+ }
package/lib/usage.js ADDED
@@ -0,0 +1,137 @@
1
+ // Live quota snapshots, read from the rate-limit headers each provider already
2
+ // returns. Nothing is polled: a snapshot is whatever the most recent real
3
+ // request reported, which is also the only moment the numbers can change.
4
+
5
+ /** Parse a numeric header, or undefined when absent/unparsable. */
6
+ function num(headers, name) {
7
+ const raw = headers?.[name];
8
+ if (raw === undefined || raw === null || String(raw).trim().length === 0) return undefined;
9
+ const value = Number(raw);
10
+ return Number.isFinite(value) ? value : undefined;
11
+ }
12
+
13
+ function str(headers, name) {
14
+ const raw = headers?.[name];
15
+ return raw === undefined || raw === null ? undefined : String(raw).trim() || undefined;
16
+ }
17
+
18
+ /** Lower-case every header name so lookups do not depend on the transport. */
19
+ export function normalizeHeaders(headers) {
20
+ const flat = {};
21
+ if (headers === undefined || headers === null) return flat;
22
+ const entries = typeof headers.entries === "function" ? [...headers.entries()] : Object.entries(headers);
23
+ for (const [key, value] of entries) flat[String(key).toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
24
+ return flat;
25
+ }
26
+
27
+ /**
28
+ * One usage window in the shape the panel renders: a 0..1 fraction, the window
29
+ * length, and when it resets.
30
+ */
31
+ function window_(label, usedPercent, windowMinutes, resetAt, status) {
32
+ if (usedPercent === undefined) return undefined;
33
+ return {
34
+ label,
35
+ used: Math.max(0, Math.min(usedPercent / 100, 1)),
36
+ ...(windowMinutes === undefined ? {} : { windowMinutes }),
37
+ ...(resetAt === undefined ? {} : { resetAt: new Date(resetAt * 1000).toISOString() }),
38
+ ...(status === undefined ? {} : { status }),
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Read the Codex/ChatGPT quota headers (`x-codex-*`).
44
+ * @param headers - normalized response headers.
45
+ * @returns the snapshot, or undefined when this response carried no quota data.
46
+ */
47
+ export function codexUsage(headers) {
48
+ const primary = window_(
49
+ "primary",
50
+ num(headers, "x-codex-primary-used-percent"),
51
+ num(headers, "x-codex-primary-window-minutes"),
52
+ num(headers, "x-codex-primary-reset-at"),
53
+ );
54
+ const secondary = window_(
55
+ "secondary",
56
+ num(headers, "x-codex-secondary-used-percent"),
57
+ num(headers, "x-codex-secondary-window-minutes"),
58
+ num(headers, "x-codex-secondary-reset-at"),
59
+ );
60
+ if (primary === undefined && secondary === undefined) return undefined;
61
+ const credits = num(headers, "x-codex-credits-balance");
62
+ return {
63
+ provider: "openai-codex",
64
+ plan: str(headers, "x-codex-plan-type"),
65
+ activeLimit: str(headers, "x-codex-active-limit"),
66
+ windows: [primary, secondary].filter((entry) => entry !== undefined && (entry.windowMinutes ?? 0) > 0),
67
+ ...(credits === undefined ? {} : { credits, creditsUnlimited: str(headers, "x-codex-credits-unlimited") === "True" }),
68
+ at: new Date().toISOString(),
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Read the Anthropic unified rate-limit headers (`anthropic-ratelimit-unified-*`).
74
+ * Utilization arrives as a 0..1 fraction there, unlike Codex's percent.
75
+ * @param headers - normalized response headers.
76
+ * @returns the snapshot, or undefined when this response carried no quota data.
77
+ */
78
+ export function anthropicUsage(headers) {
79
+ const windows = [];
80
+ for (const [name, minutes] of [["5h", 300], ["7d", 10080]]) {
81
+ const used = num(headers, `anthropic-ratelimit-unified-${name}-utilization`);
82
+ if (used === undefined) continue;
83
+ windows.push({
84
+ label: name,
85
+ used: Math.max(0, Math.min(used, 1)),
86
+ windowMinutes: minutes,
87
+ ...(num(headers, `anthropic-ratelimit-unified-${name}-reset`) === undefined
88
+ ? {}
89
+ : { resetAt: new Date(num(headers, `anthropic-ratelimit-unified-${name}-reset`) * 1000).toISOString() }),
90
+ ...(str(headers, `anthropic-ratelimit-unified-${name}-status`) === undefined
91
+ ? {}
92
+ : { status: str(headers, `anthropic-ratelimit-unified-${name}-status`) }),
93
+ });
94
+ }
95
+ if (windows.length === 0) return undefined;
96
+ return {
97
+ provider: "anthropic",
98
+ ...(str(headers, "anthropic-ratelimit-unified-status") === undefined ? {} : { activeLimit: str(headers, "anthropic-ratelimit-unified-status") }),
99
+ windows,
100
+ at: new Date().toISOString(),
101
+ };
102
+ }
103
+
104
+ /** Provider id to header reader. */
105
+ const READERS = { "openai-codex": codexUsage, anthropic: anthropicUsage };
106
+
107
+ /**
108
+ * Wrap a pi-ai provider so every response's quota headers reach `record`.
109
+ *
110
+ * The engine calls `onResponse` with the raw status and headers before the
111
+ * stream is consumed, and a caller-supplied hook is preserved, so this is a
112
+ * pure observation: no request shape changes and no failure path is added.
113
+ * @param provider - the pi-ai provider to wrap.
114
+ * @param record - receives one parsed snapshot per response that carries quota headers.
115
+ * @returns the wrapped provider.
116
+ */
117
+ export function withUsageProbe(provider, record) {
118
+ const read = READERS[provider.id];
119
+ if (read === undefined) return provider;
120
+ const observe = (options) => ({
121
+ ...options,
122
+ onResponse: async (response, model) => {
123
+ try {
124
+ const snapshot = read(normalizeHeaders(response?.headers));
125
+ if (snapshot !== undefined) record(snapshot);
126
+ } catch {
127
+ // Usage reporting must never break a model call.
128
+ }
129
+ return options?.onResponse?.(response, model);
130
+ },
131
+ });
132
+ return {
133
+ ...provider,
134
+ stream: (model, context, options) => provider.stream(model, context, observe(options)),
135
+ streamSimple: (model, context, options) => provider.streamSimple(model, context, observe(options)),
136
+ };
137
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "dsh-llm-local-token",
3
+ "version": "1.3.1",
4
+ "description": "DeepSeek Harness plugin: serve LLM calls with the OAuth tokens your local Codex CLI and Claude Code already hold, instead of a separately configured API key.",
5
+ "keywords": [
6
+ "deepseek-harness",
7
+ "dsh",
8
+ "dsh-plugin",
9
+ "cordis",
10
+ "llm",
11
+ "codex",
12
+ "claude",
13
+ "oauth"
14
+ ],
15
+ "license": "MIT",
16
+ "type": "module",
17
+ "main": "lib/index.js",
18
+ "exports": {
19
+ ".": "./lib/index.js",
20
+ "./package.json": "./package.json",
21
+ "./client": "./lib/client.js",
22
+ "./cordis.patch.yml": "./cordis.patch.yml"
23
+ },
24
+ "files": [
25
+ "lib/",
26
+ "cordis.patch.yml",
27
+ "README.md",
28
+ "README.zh.md",
29
+ "LICENSE"
30
+ ],
31
+ "engines": {
32
+ "node": ">=22.13.0"
33
+ },
34
+ "peerDependencies": {
35
+ "@deepseek-ai/cordis": "^4.0.1",
36
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
37
+ "@deepseek-ai/dsh-llm-pi-ai": "^0.1.0-rc.6",
38
+ "@deepseek-ai/schemastery": "^3.18.1",
39
+ "@earendil-works/pi-ai": "^0.82.1"
40
+ },
41
+ "peerDependenciesMeta": {
42
+ "@deepseek-ai/cordis": {
43
+ "optional": true
44
+ },
45
+ "@deepseek-ai/schemastery": {
46
+ "optional": true
47
+ }
48
+ },
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/tianxia--/dsh-llm-local-token.git"
52
+ },
53
+ "bugs": {
54
+ "url": "https://github.com/tianxia--/dsh-llm-local-token/issues"
55
+ },
56
+ "homepage": "https://github.com/tianxia--/dsh-llm-local-token#readme",
57
+ "dsh": {
58
+ "bundle": {
59
+ "patch": "./cordis.patch.yml"
60
+ },
61
+ "client": {
62
+ "inject": [
63
+ "slots",
64
+ "locale"
65
+ ],
66
+ "platform": "web"
67
+ }
68
+ }
69
+ }