dsh-plugin-subscriptions 0.4.1 → 0.5.0

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/README.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  English | [中文](README.zh.md)
4
4
 
5
- Use your **ChatGPT (Codex)**, **Claude**, and **Grok (X Premium)** subscriptions as LLM providers in [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) — no API keys. Login happens in the dsh web UI (Settings → Subscriptions); tokens live at `~/.dsh/plugins/subscriptions/auth.json` (mode 0600) and refresh automatically.
5
+ Use your **ChatGPT (Codex)**, **Claude**, and **Grok (X Premium)** subscriptions as LLM providers in [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) — no API keys. Codex and Grok log in via OAuth in the dsh web UI (Settings → Subscriptions); Claude imports credentials directly from an existing Claude Code session (macOS Keychain or `~/.claude/.credentials.json`). Tokens live at `~/.dsh/plugins/subscriptions/auth.json` (mode 0600) and refresh automatically.
6
6
 
7
7
  ## Demo
8
8
 
9
- Settings → **Subscriptions**: per-provider OAuth login/logout, no API keys (account address masked in the screenshot):
9
+ Settings → **Subscriptions**: per-provider login/logout, no API keys. Claude imports credentials from Claude Code; Codex and Grok use OAuth (account address masked in the screenshot):
10
10
 
11
11
  ![Subscriptions settings page](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/subscriptions.png)
12
12
 
@@ -18,6 +18,10 @@ Models that advertise reasoning levels get an **Effort** selector in the same me
18
18
 
19
19
  ![Reasoning effort selector](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/model-effort.png)
20
20
 
21
+ Codex models whose catalog advertises the fast tier (the codex CLI's fast mode) get a **Speed** toggle in the composer's tool row, next to the model selector — Standard or Fast (`service_tier: priority`), per session. The `/fast` slash command offers the same choice as a popup; it errors with an explanation when the current model has no fast tier.
22
+
23
+ ![Speed toggle with the Standard/Fast menu open](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/speed-toggle.png)
24
+
21
25
  The `image_generate` tool renders its result inline in the conversation:
22
26
 
23
27
  ![image_generate renders the image inline](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/image-generate-inline.png)
@@ -35,7 +39,7 @@ The `video_generate` tool plays the generated clip inline:
35
39
  | Route | Subscription | Models |
36
40
  |----------|-------------------|--------|
37
41
  | `codex` | ChatGPT Plus/Pro | live catalog from `chatgpt.com/backend-api/codex/models` |
38
- | `claude` | Claude Pro/Max | claude-opus-4-5, claude-sonnet-4-5, claude-haiku-4-5 |
42
+ | `claude` | Claude Pro/Max | all models available in your subscription (Opus, Sonnet, Haiku, Fable — static catalog, updated with the plugin) |
39
43
  | `grok` | X Premium (xAI) | live catalog from `api.x.ai/v1/models` (chat models only); reasoning efforts from the Grok CLI catalog (`cli-chat-proxy.grok.com/v1/models`) |
40
44
 
41
45
  Only logged-in providers appear in the session model picker; the lists above refresh on login/logout. Vision-capable models declare `['text', 'image']` input modalities, and image content is translated to each provider's wire format.
@@ -101,7 +105,7 @@ Either way, restart `dsh web` afterwards so the new version loads.
101
105
  ## Use
102
106
 
103
107
  1. `dsh web`, open the printed URL.
104
- 2. Settings → **Subscriptions**: click **Log in** on a provider and authorize in the opened tab. If the browser flow can't complete (headless host), expand the manual fallback and paste the callback URL or code.
108
+ 2. Settings → **Subscriptions**: click **Connect** on a provider. For Claude, credentials are imported instantly from Claude Code (you must have run `claude` and logged in at least once). For Codex and Grok, authorize in the opened browser tab; if the browser flow can't complete (headless host), expand the manual fallback and paste the callback URL or code.
105
109
  3. In any session, open the model picker (`/model`) and choose a model under **ChatGPT (Codex)** / **Claude (Subscription)** / **Grok (Subscription)**.
106
110
 
107
111
  Not logged in? The provider stays out of the picker, and requests fail with `MISSING_CREDENTIAL` pointing at the Settings page; nothing else breaks.
@@ -134,7 +138,7 @@ After `pnpm build`, restart `dsh web` to pick up changes.
134
138
  ## Layout
135
139
 
136
140
  - `src/index.ts` — plugin entry: config schema, adapter registration, auth-change re-announce, RPC wiring
137
- - `src/auth/` — PKCE/JWT helpers, token store, OAuth flow engine (temp loopback callback server), `/subscriptions-auth` RPC channel
141
+ - `src/auth/` — PKCE/JWT helpers, token store, OAuth flow engine (temp loopback callback server), Claude Code credential reader (Keychain/file), `/subscriptions-auth` RPC channel
138
142
  - `src/providers/` — per-provider OAuth constants/exchange/refresh + `LlmAdapter`s
139
143
  - `src/translate/` — dsh `Message[]` ⟷ OpenAI Responses / Anthropic Messages wire formats, SSE → `StreamChunk`
140
144
  - `src/tools/` — `x_search`, `image_generate`, and `video_generate`
package/README.zh.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  [English](README.md) | 中文
4
4
 
5
- 把你的 **ChatGPT(Codex)**、**Claude**、**Grok(X Premium)** 订阅当作 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的 LLM provider 使用 —— 不需要 API key。登录在 dsh web 界面完成(设置 → 订阅);token 保存在 `~/.dsh/plugins/subscriptions/auth.json`(权限 0600),过期自动刷新。
5
+ 把你的 **ChatGPT(Codex)**、**Claude**、**Grok(X Premium)** 订阅当作 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的 LLM provider 使用 —— 不需要 API key。Codex 和 Grok 通过 dsh web 界面 OAuth 登录(设置 → 订阅);Claude 直接从已有的 Claude Code 会话导入凭据(macOS Keychain 或 `~/.claude/.credentials.json`)。Token 保存在 `~/.dsh/plugins/subscriptions/auth.json`(权限 0600),过期自动刷新。
6
6
 
7
7
  ## 演示
8
8
 
9
- 设置 → **订阅**:每个 provider OAuth 登录/退出,无需 API key(截图中账号已打码):
9
+ 设置 → **订阅**:每个 provider 的登录/退出,无需 API key。Claude Claude Code 导入凭据;Codex 和 Grok 使用 OAuth(截图中账号已打码):
10
10
 
11
11
  ![订阅设置页](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/subscriptions.png)
12
12
 
@@ -18,6 +18,10 @@
18
18
 
19
19
  ![推理等级选择器](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/model-effort.png)
20
20
 
21
+ 目录声明了 fast tier(即 codex CLI 的 fast 模式)的 Codex 模型,会在输入框工具行(模型选择器旁)多出一个**速度**开关 —— 标准 / 快速(`service_tier: priority`),按会话生效。`/fast` 斜杠命令提供同样的弹窗选择;当前模型不支持快速档时会提示原因。
22
+
23
+ ![速度开关及其标准/快速菜单](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/speed-toggle.png)
24
+
21
25
  `image_generate` 工具生成的图片直接内联显示在对话里:
22
26
 
23
27
  ![image_generate 内联显示生成的图片](https://raw.githubusercontent.com/V1ki/dsh-plugin-subscriptions/main/docs/images/image-generate-inline.png)
@@ -35,7 +39,7 @@
35
39
  | 路由 | 订阅 | 模型 |
36
40
  |----------|------------------|------|
37
41
  | `codex` | ChatGPT Plus/Pro | 从 `chatgpt.com/backend-api/codex/models` 实时获取 |
38
- | `claude` | Claude Pro/Max | claude-opus-4-5claude-sonnet-4-5claude-haiku-4-5 |
42
+ | `claude` | Claude Pro/Max | 订阅内所有可用模型(OpusSonnetHaiku、Fable —— 静态目录,随插件更新) |
39
43
  | `grok` | X Premium (xAI) | 从 `api.x.ai/v1/models` 实时获取(仅对话模型);推理等级来自 Grok CLI 目录(`cli-chat-proxy.grok.com/v1/models`) |
40
44
 
41
45
  只有已登录的 provider 才会出现在会话模型选择器里;登录/退出后列表自动刷新。支持视觉的模型会声明 `['text', 'image']` 输入模态,图片内容会被翻译成各 provider 的 wire 格式。
@@ -101,7 +105,7 @@ GitHub 安装的:重新执行一遍 `add github:V1ki/dsh-plugin-subscriptions`
101
105
  ## 使用
102
106
 
103
107
  1. `dsh web`,打开打印的 URL。
104
- 2. **设置 → 订阅**:点对应 provider 的「登录」,在打开的标签页里授权。无浏览器环境下可展开手动兜底,粘贴回调 URL 或授权码。
108
+ 2. **设置 → 订阅**:点对应 provider 的「连接」。Claude 会即时从 Claude Code 导入凭据(需先运行过 `claude` 并登录)。Codex 和 Grok 在打开的标签页里授权;无浏览器环境下可展开手动兜底,粘贴回调 URL 或授权码。
105
109
  3. 在任意会话里打开模型选择器(`/model`),选择 **ChatGPT (Codex)** / **Claude (Subscription)** / **Grok (Subscription)** 下的模型。
106
110
 
107
111
  未登录时:该 provider 不出现在选择器里;直接请求会报 `MISSING_CREDENTIAL` 并提示去设置页登录,不影响其他功能。
@@ -134,7 +138,7 @@ pnpm test # 编译后跑 node --test 单测
134
138
  ## 目录结构
135
139
 
136
140
  - `src/index.ts` —— 插件入口:配置 schema、adapter 注册、登录态变更通告、RPC 接线
137
- - `src/auth/` —— PKCE/JWT 工具、token 存储、OAuth 流程引擎(临时本地回调服务)、`/subscriptions-auth` RPC 通道
141
+ - `src/auth/` —— PKCE/JWT 工具、token 存储、OAuth 流程引擎(临时本地回调服务)、Claude Code 凭据读取器(Keychain/文件)、`/subscriptions-auth` RPC 通道
138
142
  - `src/providers/` —— 各 provider 的 OAuth 常量/换发/刷新 + `LlmAdapter` 实现
139
143
  - `src/translate/` —— dsh `Message[]` 与 OpenAI Responses / Anthropic Messages 格式互转,SSE → `StreamChunk`
140
144
  - `src/tools/` —— `x_search`、`image_generate` 与 `video_generate`
@@ -0,0 +1,23 @@
1
+ import type { ClaudeSession } from './store.js';
2
+ /** Read the current Claude Code session from its source of truth: macOS Keychain, falling back to the credentials file. */
3
+ export declare function readClaudeCodeCredentials(): ClaudeSession | undefined;
4
+ /**
5
+ * Write a refreshed session back to Claude Code's own credential store, so
6
+ * the `claude` CLI and any other consumer of the same account see the token
7
+ * we just rotated. A stale-blob mismatch (something else rotated it first)
8
+ * is a no-op — the caller already has that other rotation via readClaudeCodeCredentials.
9
+ * @param next - the freshly refreshed session to persist.
10
+ * @param expectedPriorAccessToken - the access token this refresh started from.
11
+ * @returns whether the write-back succeeded.
12
+ */
13
+ export declare function writeBackClaudeCodeCredentials(next: ClaudeSession, expectedPriorAccessToken: string): boolean;
14
+ /**
15
+ * Refresh a Claude session, first checking whether Claude Code's own store
16
+ * already holds a fresher token (rotated by the `claude` CLI or another
17
+ * consumer) before hitting the OAuth endpoint ourselves — and writing our own
18
+ * refresh back to that store so every consumer of the account stays synced.
19
+ * @param session - the session TokenManager wants refreshed.
20
+ * @param doRefresh - the actual OAuth refresh-token grant (network call).
21
+ * @returns the freshest available session.
22
+ */
23
+ export declare function refreshClaudeSynced(session: ClaudeSession, doRefresh: (session: ClaudeSession) => Promise<ClaudeSession>): Promise<ClaudeSession>;
@@ -0,0 +1,167 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ const PRIMARY_SERVICE = 'Claude Code-credentials';
6
+ const DEFAULT_SCOPES = 'user:profile user:inference user:sessions:claude_code user:mcp_servers';
7
+ function toSession(data) {
8
+ if (typeof data.accessToken !== 'string' || typeof data.refreshToken !== 'string' || typeof data.expiresAt !== 'number') {
9
+ return undefined;
10
+ }
11
+ const scopes = Array.isArray(data.scopes) ? data.scopes.join(' ') : typeof data.scopes === 'string' ? data.scopes : DEFAULT_SCOPES;
12
+ return {
13
+ accessToken: data.accessToken,
14
+ refreshToken: data.refreshToken,
15
+ expiresAt: Math.trunc(data.expiresAt),
16
+ scopes,
17
+ ...typeof data.emailAddress === 'string' ? { emailAddress: data.emailAddress } : {},
18
+ ...typeof data.subscriptionType === 'string' ? { subscriptionType: data.subscriptionType } : {},
19
+ };
20
+ }
21
+ function parseBlob(raw) {
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(raw);
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ return toSession(parsed.claudeAiOauth ?? parsed);
30
+ }
31
+ function credentialsFilePath() {
32
+ return join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), '.credentials.json');
33
+ }
34
+ function readKeychainRaw() {
35
+ try {
36
+ return execFileSync('/usr/bin/security', ['find-generic-password', '-s', PRIMARY_SERVICE, '-w'], {
37
+ timeout: 3000,
38
+ encoding: 'utf8',
39
+ stdio: ['pipe', 'pipe', 'pipe'],
40
+ }).trim();
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ function readFileRaw() {
47
+ try {
48
+ return readFileSync(credentialsFilePath(), 'utf8');
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ }
54
+ /** Read the current Claude Code session from its source of truth: macOS Keychain, falling back to the credentials file. */
55
+ export function readClaudeCodeCredentials() {
56
+ if (process.platform === 'darwin') {
57
+ const raw = readKeychainRaw();
58
+ const session = raw !== undefined ? parseBlob(raw) : undefined;
59
+ if (session)
60
+ return session;
61
+ }
62
+ const raw = readFileRaw();
63
+ return raw !== undefined ? parseBlob(raw) : undefined;
64
+ }
65
+ function blobMatches(raw, expectedAccessToken) {
66
+ return parseBlob(raw)?.accessToken === expectedAccessToken;
67
+ }
68
+ function getKeychainAccountName() {
69
+ try {
70
+ const output = execFileSync('/usr/bin/security', ['find-generic-password', '-s', PRIMARY_SERVICE], {
71
+ timeout: 2000,
72
+ encoding: 'utf8',
73
+ stdio: ['pipe', 'pipe', 'pipe'],
74
+ });
75
+ return /"acct"<blob>="([^"]*)"/.exec(output)?.[1];
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ }
81
+ /** Merge fresh tokens into an existing raw blob, preserving unrelated fields. */
82
+ function mergeIntoBlob(existingRaw, next) {
83
+ let parsed;
84
+ try {
85
+ parsed = JSON.parse(existingRaw);
86
+ }
87
+ catch {
88
+ return undefined;
89
+ }
90
+ const target = (parsed.claudeAiOauth ?? parsed);
91
+ target.accessToken = next.accessToken;
92
+ target.refreshToken = next.refreshToken;
93
+ target.expiresAt = next.expiresAt;
94
+ return JSON.stringify(parsed);
95
+ }
96
+ /**
97
+ * Write a refreshed session back to Claude Code's own credential store, so
98
+ * the `claude` CLI and any other consumer of the same account see the token
99
+ * we just rotated. A stale-blob mismatch (something else rotated it first)
100
+ * is a no-op — the caller already has that other rotation via readClaudeCodeCredentials.
101
+ * @param next - the freshly refreshed session to persist.
102
+ * @param expectedPriorAccessToken - the access token this refresh started from.
103
+ * @returns whether the write-back succeeded.
104
+ */
105
+ export function writeBackClaudeCodeCredentials(next, expectedPriorAccessToken) {
106
+ if (process.platform === 'darwin') {
107
+ const raw = readKeychainRaw();
108
+ if (raw === undefined || !blobMatches(raw, expectedPriorAccessToken))
109
+ return false;
110
+ const updated = mergeIntoBlob(raw, next);
111
+ if (updated === undefined)
112
+ return false;
113
+ const account = getKeychainAccountName() ?? PRIMARY_SERVICE;
114
+ try {
115
+ execFileSync('/usr/bin/security', ['add-generic-password', '-s', PRIMARY_SERVICE, '-a', account, '-w', updated, '-U'], {
116
+ timeout: 2000,
117
+ stdio: 'ignore',
118
+ });
119
+ return true;
120
+ }
121
+ catch {
122
+ return false;
123
+ }
124
+ }
125
+ const path = credentialsFilePath();
126
+ let raw;
127
+ try {
128
+ raw = readFileSync(path, 'utf8');
129
+ }
130
+ catch {
131
+ return false;
132
+ }
133
+ if (!blobMatches(raw, expectedPriorAccessToken))
134
+ return false;
135
+ const updated = mergeIntoBlob(raw, next);
136
+ if (updated === undefined)
137
+ return false;
138
+ try {
139
+ const dir = dirname(path);
140
+ if (!existsSync(dir))
141
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
142
+ writeFileSync(path, updated, { encoding: 'utf8', mode: 0o600 });
143
+ chmodSync(path, 0o600);
144
+ return true;
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ }
150
+ /**
151
+ * Refresh a Claude session, first checking whether Claude Code's own store
152
+ * already holds a fresher token (rotated by the `claude` CLI or another
153
+ * consumer) before hitting the OAuth endpoint ourselves — and writing our own
154
+ * refresh back to that store so every consumer of the account stays synced.
155
+ * @param session - the session TokenManager wants refreshed.
156
+ * @param doRefresh - the actual OAuth refresh-token grant (network call).
157
+ * @returns the freshest available session.
158
+ */
159
+ export async function refreshClaudeSynced(session, doRefresh) {
160
+ const fromSource = readClaudeCodeCredentials();
161
+ const base = fromSource !== undefined && fromSource.accessToken !== session.accessToken ? fromSource : session;
162
+ if (base.expiresAt > Date.now() + 60_000)
163
+ return base;
164
+ const next = await doRefresh(base);
165
+ writeBackClaudeCodeCredentials(next, base.accessToken);
166
+ return next;
167
+ }
package/lib/auth/pkce.js CHANGED
@@ -22,7 +22,7 @@ export function createPkce() {
22
22
  * @param bytes - entropy length.
23
23
  * @returns base64url-encoded random bytes.
24
24
  */
25
- export function randomToken(bytes = 16) {
25
+ export function randomToken(bytes = 32) {
26
26
  return base64url(randomBytes(bytes));
27
27
  }
28
28
  /**
package/lib/auth/rpc.d.ts CHANGED
@@ -20,6 +20,22 @@ export interface VideoBytesResult {
20
20
  mediaType: string;
21
21
  dataBase64: string;
22
22
  }
23
+ /** One session's speed choice: standard routing or the fast (priority) tier. */
24
+ export type SpeedTier = 'standard' | 'fast';
25
+ /** `speed` endpoint value: the session's choice plus the visibility list. */
26
+ export interface SpeedState {
27
+ /** The session's current speed tier (default `standard`). */
28
+ tier: SpeedTier;
29
+ /** Codex model ids whose catalog advertises a fast tier. */
30
+ fastModels: string[];
31
+ }
32
+ /** Speed state the RPC handler delegates to (in-memory, per session). */
33
+ export interface SpeedController {
34
+ /** Current speed state: the session's tier and the fast-capable codex models. */
35
+ speed(sessionId: string): Promise<SpeedState>;
36
+ /** Set one session's speed tier. */
37
+ setSpeed(sessionId: string, tier: SpeedTier): Promise<void>;
38
+ }
23
39
  /** Login state of one provider, as rendered by the Settings page. */
24
40
  export interface ProviderStatus {
25
41
  /** Whether a session exists in the store. */
@@ -83,5 +99,6 @@ export interface AuthController {
83
99
  * Register the `/subscriptions-auth` RPC channel when a host connection exists.
84
100
  * @param ctx - the plugin context (headless profiles have no `connection`).
85
101
  * @param controller - the auth operations backing the endpoints.
102
+ * @param speed - the per-session speed-tier state backing the Speed toggle.
86
103
  */
87
- export declare function registerAuthRpc(ctx: Context, controller: AuthController): void;
104
+ export declare function registerAuthRpc(ctx: Context, controller: AuthController, speed: SpeedController): void;
package/lib/auth/rpc.js CHANGED
@@ -42,6 +42,14 @@ function readString(payload, field) {
42
42
  }
43
43
  return value;
44
44
  }
45
+ /** Validate the `setSpeed` endpoint's tier. */
46
+ function readSpeedTier(payload) {
47
+ const tier = payload.tier;
48
+ if (tier !== 'standard' && tier !== 'fast') {
49
+ throw new BadRequest('payload.tier must be "standard" or "fast"');
50
+ }
51
+ return tier;
52
+ }
45
53
  /** Validate the `image` endpoint's payload into a full attachment reference. */
46
54
  function readImageRef(payload) {
47
55
  if (typeof payload !== 'object' || payload === null)
@@ -88,7 +96,13 @@ function readVideoName(payload) {
88
96
  }
89
97
  return name;
90
98
  }
91
- async function dispatch(controller, endpoint, payload, signal) {
99
+ /** Validate the session id both speed endpoints carry. */
100
+ function readSessionId(payload) {
101
+ if (typeof payload !== 'object' || payload === null)
102
+ throw new BadRequest('payload must be an object');
103
+ return readString(payload, 'sessionId');
104
+ }
105
+ async function dispatch(controller, speed, endpoint, payload, signal) {
92
106
  switch (endpoint) {
93
107
  case 'status': {
94
108
  const entries = await Promise.all(PROVIDER_IDS.map(async (provider) => [provider, await controller.status(provider)]));
@@ -113,6 +127,11 @@ async function dispatch(controller, endpoint, payload, signal) {
113
127
  return ok(await controller.readImage(readImageRef(payload), signal));
114
128
  case 'video':
115
129
  return ok(await controller.readVideo(readVideoName(payload), signal));
130
+ case 'speed':
131
+ return ok(await speed.speed(readSessionId(payload)));
132
+ case 'setSpeed':
133
+ await speed.setSpeed(readSessionId(payload), readSpeedTier(payload));
134
+ return ok({ ok: true });
116
135
  default:
117
136
  throw new BadRequest(`unknown /subscriptions-auth endpoint "${endpoint}"`);
118
137
  }
@@ -121,8 +140,9 @@ async function dispatch(controller, endpoint, payload, signal) {
121
140
  * Register the `/subscriptions-auth` RPC channel when a host connection exists.
122
141
  * @param ctx - the plugin context (headless profiles have no `connection`).
123
142
  * @param controller - the auth operations backing the endpoints.
143
+ * @param speed - the per-session speed-tier state backing the Speed toggle.
124
144
  */
125
- export function registerAuthRpc(ctx, controller) {
145
+ export function registerAuthRpc(ctx, controller, speed) {
126
146
  // `connection` is not in this plugin's inject list (headless compositions
127
147
  // lack it), so its startup order is unconstrained: defer registration until
128
148
  // the service exists instead of probing once at apply time.
@@ -130,7 +150,7 @@ export function registerAuthRpc(ctx, controller) {
130
150
  const connection = ctx.get('connection');
131
151
  ctx.effect(() => connection.rpc.handle(SUBSCRIPTIONS_AUTH_CHANNEL, async (endpoint, payload, signal) => {
132
152
  try {
133
- return await dispatch(controller, endpoint, payload, signal);
153
+ return await dispatch(controller, speed, endpoint, payload, signal);
134
154
  }
135
155
  catch (error) {
136
156
  return failure(error);
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Mirror of ImageAttachmentRef (packages/attachment/attachment/src/types.ts);
3
+ * the brand on attachmentId is compile-time only, so string suffices here.
4
+ */
5
+ export interface ImageAttachmentRef {
6
+ attachmentId: string;
7
+ mediaType: string;
8
+ bytes: number;
9
+ width: number;
10
+ height: number;
11
+ name?: string;
12
+ }
13
+ /** Loads a session-authorized durable image URL (resolves to a data/blob URL). */
14
+ export type ImageLoader = (attachment: ImageAttachmentRef) => Promise<string>;
15
+ /** Lightbox strings forwarded to the opened preview. */
16
+ export interface ImageLightboxLabels {
17
+ dialog: string;
18
+ close: string;
19
+ }
20
+ /** Message-image strings the owner resolves from its own locale namespace. */
21
+ export interface MessageImageLabels {
22
+ /** Fallback display name for an unnamed image. */
23
+ image: string;
24
+ /** Thumbnail tooltip inviting the original-image preview. */
25
+ open: string;
26
+ /** Accessible thumbnail label; receives the image's display name. */
27
+ openNamed: (label: string) => string;
28
+ /** Loading placeholder shown until bytes resolve. */
29
+ loading: string;
30
+ /** Retry-control label shown when the load fails. */
31
+ loadFailed: string;
32
+ /** Lightbox strings forwarded to the opened preview. */
33
+ lightbox: ImageLightboxLabels;
34
+ }
35
+ /**
36
+ * Compact history renderer with retryable loading and click-to-open original
37
+ * preview. A lone image renders at its `singleFit` size; an image among
38
+ * several renders as a fixed 64px square tile.
39
+ */
40
+ export declare function MessageImage({ attachment, load, variant, labels }: {
41
+ attachment: ImageAttachmentRef;
42
+ load: ImageLoader;
43
+ variant: 'single' | 'tile';
44
+ labels: MessageImageLabels;
45
+ }): import("react").JSX.Element;
46
+ /** Wrapping image group: a lone image renders large, several render as 64px
47
+ * square tiles (same rule as the platform gallery). */
48
+ export declare function ImageGallery({ images, load, labels }: {
49
+ images: readonly {
50
+ attachment: ImageAttachmentRef;
51
+ }[];
52
+ load: ImageLoader;
53
+ labels: MessageImageLabels;
54
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,112 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * Plugin-local image gallery for generated-image toolviews.
4
+ *
5
+ * Since web-app rc.8, `@deepseek-ai/dsh-client-ui-attachment`'s browser module
6
+ * exports only its cordis plugin surface (`apply`/`inject`) — the React
7
+ * components are no longer package values, so importing `ImageGallery` from it
8
+ * yields `undefined` at runtime and crashes the toolview inside the slot error
9
+ * boundary (the whole call row disappears). This module re-implements the
10
+ * gallery contract the toolview needs (loader-driven thumbnails, retry on
11
+ * failure, click-to-open lightbox) with the same sizing rules as the platform
12
+ * component, keeping this plugin independent of harness component exports.
13
+ */
14
+ import { useCallback, useEffect, useMemo, useState } from 'react';
15
+ /** Display box for a lone image (platform rule): long edge 240px with the
16
+ * rendered aspect ratio clamped to [0.25, 4] — the overflow is cropped by
17
+ * `object-fit: cover` — and never upscaled past the image's natural size. The
18
+ * crop anchor keeps the top of very tall images and the left of very wide
19
+ * ones, where the informative content usually starts. */
20
+ function singleFit(attachment) {
21
+ const natural = attachment.width / attachment.height;
22
+ const ratio = Math.min(4, Math.max(0.25, natural));
23
+ const box = ratio >= 1 ? { width: 240, height: 240 / ratio } : { width: 240 * ratio, height: 240 };
24
+ const scale = Math.min(1, attachment.width / box.width, attachment.height / box.height);
25
+ return {
26
+ width: Math.max(1, Math.round(box.width * scale)),
27
+ height: Math.max(1, Math.round(box.height * scale)),
28
+ objectPosition: natural < 0.25 ? 'center top' : natural > 4 ? 'left center' : 'center',
29
+ };
30
+ }
31
+ const styles = {
32
+ gallery: { display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-start' },
33
+ frame: {
34
+ display: 'grid', placeItems: 'center', overflow: 'hidden', padding: 0,
35
+ border: '1px solid var(--dsw-alias-border-l2-darkmode-thin)', borderRadius: 8,
36
+ background: 'var(--dsw-alias-interactive-bg-hover-solid)', cursor: 'zoom-in',
37
+ },
38
+ tile: { width: 64, height: 64 },
39
+ img: { width: '100%', height: '100%', objectFit: 'cover', display: 'block' },
40
+ loading: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)', padding: '0 8px' },
41
+ error: {
42
+ fontSize: 12, color: 'var(--dsw-alias-state-error-primary)', cursor: 'pointer',
43
+ border: '1px solid var(--dsw-alias-border-l2-darkmode-thin)', borderRadius: 8,
44
+ background: 'transparent', padding: '6px 10px',
45
+ },
46
+ overlay: {
47
+ position: 'fixed', inset: 0, zIndex: 1000, display: 'grid', placeItems: 'center',
48
+ background: 'rgba(0, 0, 0, 0.72)', padding: 24,
49
+ },
50
+ overlayImg: { maxWidth: '92vw', maxHeight: '92vh', objectFit: 'contain', borderRadius: 4 },
51
+ close: {
52
+ position: 'absolute', top: 12, right: 12, width: 32, height: 32, display: 'grid',
53
+ placeItems: 'center', border: 'none', borderRadius: '50%', cursor: 'pointer',
54
+ background: 'rgba(255, 255, 255, 0.16)', color: '#fff', fontSize: 16, lineHeight: 1,
55
+ },
56
+ };
57
+ /**
58
+ * Full-viewport original-image preview: backdrop or close-control click and
59
+ * Escape all dismiss; the image itself is inert so a click on it does not
60
+ * fall through to the backdrop dismissal.
61
+ */
62
+ function ImageLightbox({ src, alt, labels, onClose }) {
63
+ useEffect(() => {
64
+ const onKey = (event) => { if (event.key === 'Escape')
65
+ onClose(); };
66
+ window.addEventListener('keydown', onKey);
67
+ return () => { window.removeEventListener('keydown', onKey); };
68
+ }, [onClose]);
69
+ return (_jsxs("div", { role: "dialog", "aria-label": labels.dialog, style: styles.overlay, onClick: onClose, children: [_jsx("img", { src: src, alt: alt, style: styles.overlayImg, onClick: event => { event.stopPropagation(); } }), _jsx("button", { type: "button", "aria-label": labels.close, style: styles.close, onClick: onClose, children: "\u00D7" })] }));
70
+ }
71
+ /**
72
+ * Compact history renderer with retryable loading and click-to-open original
73
+ * preview. A lone image renders at its `singleFit` size; an image among
74
+ * several renders as a fixed 64px square tile.
75
+ */
76
+ export function MessageImage({ attachment, load, variant, labels }) {
77
+ const [src, setSrc] = useState(null);
78
+ const [error, setError] = useState(false);
79
+ const [open, setOpen] = useState(false);
80
+ // Retry re-arms the one load effect below, so every attempt — first load or
81
+ // retry — runs under the same liveness guard and the same reset.
82
+ const [attempt, setAttempt] = useState(0);
83
+ const retry = useCallback(() => { setAttempt(a => a + 1); }, []);
84
+ const close = useCallback(() => { setOpen(false); }, []);
85
+ const fit = useMemo(() => (variant === 'single' ? singleFit(attachment) : undefined), [attachment, variant]);
86
+ useEffect(() => {
87
+ let live = true;
88
+ setError(false);
89
+ setSrc(null);
90
+ void load(attachment).then((url) => { if (live)
91
+ setSrc(url); }).catch(() => { if (live)
92
+ setError(true); });
93
+ return () => { live = false; };
94
+ }, [attachment, load, attempt]);
95
+ const label = attachment.name ?? labels.image;
96
+ if (error) {
97
+ return _jsx("button", { type: "button", style: styles.error, onClick: retry, children: labels.loadFailed });
98
+ }
99
+ const box = fit === undefined ? styles.tile : { width: fit.width, height: fit.height };
100
+ return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", style: { ...styles.frame, ...box }, title: labels.open, "aria-label": labels.openNamed(label), onClick: () => { if (src !== null)
101
+ setOpen(true); }, children: src === null
102
+ ? _jsx("span", { style: styles.loading, children: labels.loading })
103
+ : _jsx("img", { src: src, alt: label, style: { ...styles.img, objectPosition: fit?.objectPosition } }) }), open && src !== null && _jsx(ImageLightbox, { src: src, alt: label, labels: labels.lightbox, onClose: close })] }));
104
+ }
105
+ /** Wrapping image group: a lone image renders large, several render as 64px
106
+ * square tiles (same rule as the platform gallery). */
107
+ export function ImageGallery({ images, load, labels }) {
108
+ if (images.length === 0)
109
+ return null;
110
+ const variant = images.length === 1 ? 'single' : 'tile';
111
+ return (_jsx("div", { style: styles.gallery, children: images.map((image, index) => (_jsx(MessageImage, { attachment: image.attachment, load: load, variant: variant, labels: labels }, `${image.attachment.attachmentId}:${index}`))) }));
112
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client';
2
2
  import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client';
3
- import type { ImageLoader } from '@deepseek-ai/dsh-client-ui-attachment';
3
+ import type { ImageLoader } from './ImageGallery.js';
4
4
  import type { SubscriptionsKey } from './locales.js';
5
5
  /** Mirror of ui-tool's ToolCallOwnerProps (see the module header). */
6
6
  interface ToolCallOwnerProps {
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { IconSparkle16 } from '@deepseek-ai/dsh-client-ui-primitives';
3
- import { ImageGallery } from '@deepseek-ai/dsh-client-ui-attachment';
3
+ import { ImageGallery } from './ImageGallery.js';
4
4
  import { en } from './locales.js';
5
5
  /** Logical RPC channel served by the node half of this plugin. */
6
6
  const SUBSCRIPTIONS_AUTH_CHANNEL = '/subscriptions-auth';
@@ -133,5 +133,5 @@ export function ImageGenerateToolview(props) {
133
133
  loadFailed: t('imageLoadFailed'),
134
134
  lightbox: { dialog: t('imagePreview'), close: t('imageClose') },
135
135
  };
136
- return (_jsxs("div", { style: styles.container, children: [_jsxs("div", { style: styles.row, children: [_jsx("span", { style: styles.icon, children: _jsx(IconSparkle16, { size: 14 }) }), _jsx("span", { style: styles.title, children: title })] }), !settled && _jsx("p", { style: styles.subtle, children: t('generating') }), settled && block.isError && text !== '' && (_jsx("p", { style: styles.error, children: text.split('\n', 1)[0] })), settled && !block.isError && images.length > 0 && load !== undefined && (_jsx(ImageGallery, { images: images, load: load, align: "start", labels: labels })), settled && !block.isError && images.length === 0 && text !== '' && (_jsx("p", { style: styles.output, children: text }))] }));
136
+ return (_jsxs("div", { style: styles.container, children: [_jsxs("div", { style: styles.row, children: [_jsx("span", { style: styles.icon, children: _jsx(IconSparkle16, { size: 14 }) }), _jsx("span", { style: styles.title, children: title })] }), !settled && _jsx("p", { style: styles.subtle, children: t('generating') }), settled && block.isError && text !== '' && (_jsx("p", { style: styles.error, children: text.split('\n', 1)[0] })), settled && !block.isError && images.length > 0 && load !== undefined && (_jsx(ImageGallery, { images: images, load: load, labels: labels })), settled && !block.isError && images.length === 0 && text !== '' && (_jsx("p", { style: styles.output, children: text }))] }));
137
137
  }
@@ -0,0 +1,48 @@
1
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
2
+ import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client';
3
+ /** One session's speed choice: standard routing or the fast (priority) tier. */
4
+ export type SpeedTier = 'standard' | 'fast';
5
+ /** `speed` endpoint value, mirrored from the node half. */
6
+ export interface SpeedState {
7
+ tier: SpeedTier;
8
+ fastModels: string[];
9
+ }
10
+ /** What {@link SpeedSelect} renders from: visibility plus the current tier. */
11
+ export interface SpeedSelectState {
12
+ visible: boolean;
13
+ tier: SpeedTier;
14
+ }
15
+ /** Injected dependencies of {@link SpeedSelect} (slot `inject`, session-bound). */
16
+ export interface SpeedSelectInjected {
17
+ /** Load the session's speed state; `visible` false keeps the control hidden. */
18
+ loadSpeed: () => Promise<SpeedSelectState>;
19
+ /** Set the session's speed tier; resolves false when the write failed. */
20
+ setSpeed: (tier: SpeedTier) => Promise<boolean>;
21
+ }
22
+ /**
23
+ * Props delivered by the slot outlet: the framework session kit and InputZone
24
+ * owner share (unused — everything arrives session-bound through the inject
25
+ * face), the injected callbacks, and the locale seat.
26
+ */
27
+ export type SpeedSelectProps = PropsRuntime<'conversation.input.right'> & Partial<SpeedSelectInjected> & Partial<PropsLocale<'settings.subscriptions'>>;
28
+ /**
29
+ * The `loadSpeed` half of the inject face: the plugin's own speed state plus
30
+ * the host's current model selection (the visibility gate). A model-RPC
31
+ * failure throws rather than answering "hidden" — the caller keeps its last
32
+ * known state, so a transient failure never locks the toggle away.
33
+ *
34
+ * `sessionId` is a plain string: slot and command contexts brand it through
35
+ * different dsh-session copies, and only the API-client boundary needs one.
36
+ */
37
+ export declare function createSpeedLoader(connection: ConnectionHandle, sessionId: string): SpeedSelectInjected['loadSpeed'];
38
+ /** The `setSpeed` half of the inject face: boolean outcome for the component's busy state. */
39
+ export declare function createSpeedSetter(connection: ConnectionHandle, sessionId: string): SpeedSelectInjected['setSpeed'];
40
+ /**
41
+ * The composer Speed control: a trigger reading `速度 · 快速`/`速度 · 标准`
42
+ * that opens a two-row menu (standard/fast with descriptions, check mark on
43
+ * the current tier). The host pushes nothing on a model switch, so the
44
+ * control re-reads on a slow poll with a single-flight guard; a failed read
45
+ * keeps the last known state, so a transient RPC failure can never lock the
46
+ * toggle away (the earlier mount-only load had no recovery path).
47
+ */
48
+ export declare function SpeedSelect({ loadSpeed, setSpeed, t }: SpeedSelectProps): import("react").JSX.Element | null;