@mangoc/dsh-sub2api-sync 0.2.4 → 0.2.6

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
@@ -122,7 +122,8 @@ Routes take effect on the next request. The route suffix is a SHA-256 truncation
122
122
  ## Model Experience
123
123
 
124
124
  - **Context blocks (short form)**: one provider per sub2api group, one relay key per provider, model catalog discovered from the instance; per-model `compat` inherits the user template. Reasoning efforts (`reasoningEfforts`) follow an explicit template when set (including `false`), and otherwise auto-fill per model from the models.dev enrichment.
125
- - **Reasoning-effort auto-fill**: the models.dev `reasoning_options` effort enumeration maps onto pi-ai's `reasoningEfforts` (`none` → `off: null`, same-name passthrough otherwise). Merging is first-party first: a vendor-official entry (openai/anthropic/google/zhipuai/zai/deepseek/moonshotai/xai/alibaba, vendor-governed cloud hosting) wins outright — including when it carries no effort data (budget-only claude/gemini), which silences aggregator-invented vocabularies; with no first-party entry, aggregator sources intersect. Toggle/budget-only reasoning (claude, gemini, deepseek-v3.2, kimi-k2.x) has no effort-string wire shape and stays template/`compat`-owned.
125
+ - **Reasoning-effort auto-fill**: the models.dev `reasoning_options` effort enumeration maps onto pi-ai's `reasoningEfforts` (`none` → `off: null`, same-name passthrough otherwise). Merging is first-party first: a vendor-official entry (openai/anthropic/google/zhipuai/zai/deepseek/moonshotai/xai/alibaba, vendor-governed cloud hosting) wins outright — including when it carries no effort data (budget-only older Claude/Gemini entries), which silences aggregator-invented vocabularies; with no first-party entry, aggregator sources intersect. Toggle/budget-only reasoning (older Claude, Gemini, deepseek-v3.2, kimi-k2.x) has no effort-string wire shape and stays template/`compat`-owned.
126
+ - **Native Claude reasoning efforts**: for confirmed adaptive-thinking Claude models with catalog effort metadata (such as Opus 4.6/4.7/4.8 and Sonnet 4.6), syncing over `anthropic-messages` also writes per-model `compat.forceAdaptiveThinking: true`. This SDK switch sends Claude's native `thinking: { type: "adaptive" }` and `output_config: { effort: "max" }` when Max is selected: no OpenAI protocol bridge and no Max-to-token-budget downgrade. Explicit template compat values (including `false`) win; disabled reasoning, legacy/unknown models, missing catalog effort metadata, and other protocols do not auto-enable it. Update the plugin and sync again to repair existing managed routes; subsequent syncs remain idempotent. The usage log records the declared effort, not a measurement of actual thinking.
126
127
  - **Display-name enrichment**: the models.dev `name` lands on each pi-ai model entry's `name` field — the display name the model selector and the model-catalog editor (显示名称 column) show. The models.dev " (latest)" family marker is stripped, a name repeating the id is dropped, merging is first-party first (official name overrides, aggregator names fill absences), and a name can be an id's only enriched fact.
127
128
  - Model ids come verbatim from the instance; the provider display name is the `providerNameTemplate` rendering of the key name (fallback `Group <id>`).
128
129
 
package/README.zh.md CHANGED
@@ -92,7 +92,8 @@ dsh plugin --profile web add ./packages/llm/sub2api-sync
92
92
 
93
93
  - 每分组一个 provider、一把 relay key;模型目录来自实例发现;逐模型 `compat` 继承用户模板;推理档位 `reasoningEfforts` 模板显式设置优先,未设置时按模型自动填充(见下);
94
94
  - **模型元数据补全(C4)**:同步时按模型 id 从 models.dev 目录补全**显示名称**(`name`,即模型目录编辑器「显示名称」列)/`contextWindow`/`maxTokens`/图片输入/推理档位——`enrichModelMeta` 开启(缺省)时联网拉取(24h 进程内缓存),失败自动降级到**随包内置快照**(`data/models-dev.snapshot.json`,`pnpm --filter @mangoc/dsh-sub2api-sync models:refresh` 重新生成);关闭则只用内置快照(离线确定);两者都未命中的模型保持路由级缺省 128000/8192/纯文本;
95
- - **推理档位自动填充**:取 models.dev 条目的 `reasoning_options` 中 `effort` 枚举,映射为 pi-ai 的 `reasoningEfforts`(`none` → `off: null`,其余同名直传)。合并按 **first-party 优先**:官方厂商条目(openai/anthropic/google/zhipuai/zai/deepseek/moonshotai/xai/alibaba 等及官方云托管)存在即胜出——官方条目无 effort 数据(如 claude/gemini 的 budget-only)同样压制聚合商的虚报词表;无官方条目时聚合商来源取**交集**。优先级:**模板显式设置(含 `false`)> 每模型自动填充 > 不写字段**;显示名 = `providerNameTemplate` 渲染的 key 名。
95
+ - **推理档位自动填充**:取 models.dev 条目的 `reasoning_options` 中 `effort` 枚举,映射为 pi-ai 的 `reasoningEfforts`(`none` → `off: null`,其余同名直传)。合并按 **first-party 优先**:官方厂商条目(openai/anthropic/google/zhipuai/zai/deepseek/moonshotai/xai/alibaba 等及官方云托管)存在即胜出——官方条目无 effort 数据(如旧版 Claude/Gemini 的 budget-only 条目)同样压制聚合商的虚报词表;无官方条目时聚合商来源取**交集**。优先级:**模板显式设置(含 `false`)> 每模型自动填充 > 不写字段**;显示名 = `providerNameTemplate` 渲染的 key 名。
96
+ - **Claude 原生推理档位**:对已确认支持 adaptive thinking、且目录有 effort 元数据的 Claude 模型(如 Opus 4.6/4.7/4.8、Sonnet 4.6),仅在 `anthropic-messages` 协议下逐模型自动补齐 `compat.forceAdaptiveThinking: true`。该开关让 SDK 直接发送 Claude 原生的 `thinking: { type: "adaptive" }` 与 `output_config: { effort: "max" }`(以选择 Max 为例),不经过 OpenAI 协议转换,也不将 Max 降成 token budget。用户模板中同名 compat 字段(包括显式 `false`)优先;关闭 reasoning、旧模型、未知别名/新版本、缺少目录 effort 元数据或其他协议都不会自动开启。更新插件并重新同步即可修复已有受管路由,重复同步不重复写入;无需修改 sub2api 的记录值。注意:用量日志的 effort 是请求声明档位,不是实际思考量的测量值。
96
97
  - **显示名称富化**:取 models.dev 条目的 `name` 写入 pi-ai 模型条目的 `name` 字段(模型选择器与「已自定义模型目录」编辑器的显示名);剥离 models.dev 的 " (latest)" 家族标记,名称与模型 id 相同(无信息量)时不写;合并同样 first-party 优先(官方名称覆盖,聚合商名称仅补缺),纯名称条目也参与富化。
97
98
 
98
99
  ## Known Limitations and Deferred Work
@@ -104,7 +105,7 @@ dsh plugin --profile web add ./packages/llm/sub2api-sync
104
105
  - **客户端半部只允许基线外部模块**(React / Cordis / ui-primitives / ui-slots / client-store / ui-dockkit)——客户端代码不得值导入 `config.ts`(会把 schemastery 拽进浏览器 bundle,动态模块表无此行,加载即静默失败);`test/client-bundle.test.ts` 是产物守卫。
105
106
  - 实例前置 Cloudflare 时,需在 zone 放行 `/api/v1/*` 的宿主侧调用(WAF skip 规则)。
106
107
  - 元数据补全会向 models.dev 发起一次公开目录请求(不带任何凭据与对话内容);`enrichModelMeta: false` 可完全离线(仅内置快照)。
107
- - 推理档位自动填充只覆盖 models.dev 提供 `effort` 枚举的模型(OpenAI gpt-5 系/o3、GLM 5.x、Qwen3.8、DeepSeek V4 系等);`toggle`/`budget_tokens` 型推理(claude 系、gemini 系、deepseek-v3.2、kimi-k2.x 等)的线上形态不是 effort 字符串,`reasoningEfforts` 无法表达,仍由模板/`compat` 管辖。
108
+ - 推理档位自动填充只覆盖 models.dev 提供 `effort` 枚举的模型(OpenAI gpt-5 系/o3、GLM 5.x、Qwen3.8、DeepSeek V4 系等);`toggle`/`budget_tokens` 型推理(旧版 Claude、Gemini、deepseek-v3.2、kimi-k2.x 等)的线上形态不是 effort 字符串,`reasoningEfforts` 无法表达,仍由模板/`compat` 管辖。
108
109
  - 用量查询(`GET /v1/usage`)是已决策的非目标。
109
110
  - 与社区插件 `GodD6366/dsh-sub2api` 同装:路由名不冲突(本插件 `sub2api-<10-hex>` 摘要形态),各自管理条目与凭据引用。
110
111
 
package/lib/client.cjs CHANGED
@@ -132,6 +132,8 @@ const zh = {
132
132
  keysTitle: "Keys",
133
133
  createKey: "创建 key",
134
134
  keyGroupLabel: "所属分组",
135
+ keyGroupSearch: "搜索分组…",
136
+ keyGroupSearchEmpty: "没有匹配的分组",
135
137
  keyNameLabel: "Key 名称",
136
138
  keyNameHint: "默认与分组同名",
137
139
  keyGroupPlaceholder: "选择分组",
@@ -246,6 +248,8 @@ const en = {
246
248
  keysTitle: "Keys",
247
249
  createKey: "Create key",
248
250
  keyGroupLabel: "Group",
251
+ keyGroupSearch: "Search groups…",
252
+ keyGroupSearchEmpty: "No groups match",
249
253
  keyNameLabel: "Key name",
250
254
  keyNameHint: "Defaults to the group name",
251
255
  keyGroupPlaceholder: "Pick a group",
@@ -725,30 +729,135 @@ const CSS = `
725
729
  flex: 1 1 220px;
726
730
  }
727
731
 
728
- /* Menu anchor wrapper: give the picker trigger the same field-column
729
- footprint as the sibling Input rows. */
730
- .s2s-menuAnchor {
732
+ /* Group picker (create-key dialog): the trigger takes the same field-column
733
+ footprint as the sibling Input rows, painted like an input rather than a
734
+ button so both rows read as one form. */
735
+ .s2s-groupPicker {
731
736
  display: flex;
732
737
  flex: 1 1 220px;
733
738
  min-width: 0;
734
739
  }
735
740
 
736
- .s2s-menuAnchor > * {
737
- width: 100%;
738
- }
739
-
740
- /* Select-style trigger (group picker): label left, chevron right. */
741
- .s2s-selectTrigger {
741
+ .s2s-selectField {
742
742
  display: flex;
743
743
  align-items: center;
744
744
  justify-content: space-between;
745
745
  gap: 8px;
746
746
  width: 100%;
747
+ min-height: 34px;
748
+ padding: 6px 10px;
749
+ box-sizing: border-box;
750
+ border: 0.5px solid var(--dsw-alias-border-l2);
751
+ border-radius: var(--dsw-radius-md);
752
+ background: transparent;
753
+ color: inherit;
754
+ font: inherit;
755
+ text-align: left;
756
+ cursor: pointer;
757
+ user-select: none;
758
+ }
759
+
760
+ .s2s-selectField:disabled {
761
+ opacity: 0.55;
762
+ cursor: default;
763
+ }
764
+
765
+ .s2s-selectField:focus-visible {
766
+ outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
767
+ outline-offset: 2px;
768
+ }
769
+
770
+ .s2s-selectPlaceholder {
771
+ color: var(--dsw-alias-label-tertiary);
772
+ overflow: hidden;
773
+ text-overflow: ellipsis;
774
+ white-space: nowrap;
775
+ }
776
+
777
+ .s2s-selectValue {
778
+ min-width: 0;
779
+ overflow: hidden;
780
+ text-overflow: ellipsis;
781
+ white-space: nowrap;
747
782
  }
748
783
 
749
784
  .s2s-selectChevron {
750
785
  flex: none;
751
786
  opacity: 0.55;
787
+ transition: transform var(--ds-transition-duration) var(--ds-ease-in-out);
788
+ }
789
+
790
+ .s2s-selectChevronOpen {
791
+ transform: rotate(180deg);
792
+ }
793
+
794
+ /* The options panel: fixed-positioned from the trigger (escapes the modal
795
+ card's overflow clip), width-matched to it, search pinned on top, options
796
+ scrolling below. */
797
+ .s2s-groupPanel {
798
+ display: flex;
799
+ flex-direction: column;
800
+ box-sizing: border-box;
801
+ z-index: 10;
802
+ overflow: hidden;
803
+ }
804
+
805
+ .s2s-groupSearch {
806
+ flex: none;
807
+ padding: 8px 8px 6px;
808
+ }
809
+
810
+ .s2s-groupSearch > * {
811
+ width: 100%;
812
+ }
813
+
814
+ .s2s-groupList {
815
+ flex: 1 1 auto;
816
+ min-height: 0;
817
+ overflow-y: auto;
818
+ overscroll-behavior: contain;
819
+ padding: 0 8px 8px;
820
+ }
821
+
822
+ .s2s-groupEmpty {
823
+ margin: 0;
824
+ padding: 10px 4px;
825
+ color: var(--dsw-alias-label-tertiary);
826
+ text-align: center;
827
+ }
828
+
829
+ .s2s-groupOption {
830
+ display: flex;
831
+ align-items: center;
832
+ gap: 8px;
833
+ width: 100%;
834
+ min-height: 32px;
835
+ padding: 6px 10px;
836
+ box-sizing: border-box;
837
+ border: 0;
838
+ border-radius: var(--dsw-radius-md);
839
+ background: transparent;
840
+ color: inherit;
841
+ font: inherit;
842
+ text-align: left;
843
+ cursor: pointer;
844
+ }
845
+
846
+ .s2s-groupOption[data-active='true'] {
847
+ background: var(--dsw-alias-interactive-bg-hover);
848
+ }
849
+
850
+ .s2s-groupOptionName {
851
+ flex: 1 1 auto;
852
+ min-width: 0;
853
+ overflow: hidden;
854
+ text-overflow: ellipsis;
855
+ white-space: nowrap;
856
+ }
857
+
858
+ .s2s-groupOptionCheck {
859
+ flex: none;
860
+ color: var(--dsw-alias-state-business-primary);
752
861
  }
753
862
 
754
863
  /* The keys-card search input: fixed-width slot between the title and the
@@ -1178,6 +1287,164 @@ function protocolForPlatform(platform) {
1178
1287
  }
1179
1288
  }
1180
1289
 
1290
+ //#endregion
1291
+ //#region src/client/section/GroupPicker.tsx
1292
+ /** Design cap for the panel; the viewport clamp never exceeds it. */
1293
+ const PANEL_MAX_HEIGHT = 280;
1294
+ /** Distance between the trigger's bottom edge and the panel. */
1295
+ const PANEL_GAP = 6;
1296
+ function GroupPicker({ groups, value, placeholder, searchPlaceholder, emptyLabel, disabled = false, onSelect }) {
1297
+ const [open, setOpen] = (0, react.useState)(false);
1298
+ const [filter, setFilter] = (0, react.useState)("");
1299
+ const [activeIndex, setActiveIndex] = (0, react.useState)(0);
1300
+ const [triggerWidth, setTriggerWidth] = (0, react.useState)(0);
1301
+ const rootRef = (0, react.useRef)(null);
1302
+ const triggerRef = (0, react.useRef)(null);
1303
+ const panelRef = (0, react.useRef)(null);
1304
+ const listRef = (0, react.useRef)(null);
1305
+ const selected = value === "" ? undefined : groups.find((group) => group.id === value);
1306
+ const filtered = (0, react.useMemo)(() => {
1307
+ const needle = filter.trim().toLowerCase();
1308
+ if (needle === "") return groups;
1309
+ return groups.filter((group) => group.name.toLowerCase().includes(needle));
1310
+ }, [groups, filter]);
1311
+ const position = (0, __deepseek_ai_dsh_client_ui_primitives.useAnchoredPosition)({
1312
+ open,
1313
+ anchorRef: triggerRef,
1314
+ panelRef,
1315
+ side: "bottom",
1316
+ align: "start",
1317
+ gap: PANEL_GAP,
1318
+ margin: 24
1319
+ });
1320
+ const maxHeight = (0, __deepseek_ai_dsh_client_ui_primitives.useAnchoredMaxHeight)(panelRef, PANEL_MAX_HEIGHT, open ? filtered.length : 0);
1321
+ (0, __deepseek_ai_dsh_client_ui_primitives.useDismissOnOutsidePointer)(rootRef, open, setOpen, panelRef);
1322
+ (0, react.useEffect)(() => {
1323
+ if (!open) return;
1324
+ setFilter("");
1325
+ setActiveIndex(0);
1326
+ setTriggerWidth(triggerRef.current?.getBoundingClientRect().width ?? 0);
1327
+ const timer$1 = window.setTimeout(() => {
1328
+ panelRef.current?.querySelector("input")?.focus();
1329
+ }, 0);
1330
+ return () => window.clearTimeout(timer$1);
1331
+ }, [open]);
1332
+ (0, react.useEffect)(() => {
1333
+ if (!open) return;
1334
+ const onResize = () => {
1335
+ setTriggerWidth(triggerRef.current?.getBoundingClientRect().width ?? 0);
1336
+ };
1337
+ window.addEventListener("resize", onResize);
1338
+ return () => window.removeEventListener("resize", onResize);
1339
+ }, [open]);
1340
+ (0, react.useEffect)(() => {
1341
+ const list = listRef.current;
1342
+ if (list === null) return;
1343
+ list.querySelector("[data-active='true']")?.scrollIntoView({ block: "nearest" });
1344
+ }, [activeIndex, filter]);
1345
+ const close = () => {
1346
+ setOpen(false);
1347
+ triggerRef.current?.focus();
1348
+ };
1349
+ const pick = (group) => {
1350
+ onSelect(group);
1351
+ close();
1352
+ };
1353
+ const onKeyDown = (event) => {
1354
+ if (event.key === "Escape") {
1355
+ event.stopPropagation();
1356
+ close();
1357
+ return;
1358
+ }
1359
+ if (event.key === "ArrowDown") {
1360
+ event.preventDefault();
1361
+ if (filtered.length > 0) setActiveIndex((index) => Math.min(index + 1, filtered.length - 1));
1362
+ return;
1363
+ }
1364
+ if (event.key === "ArrowUp") {
1365
+ event.preventDefault();
1366
+ setActiveIndex((index) => Math.max(index - 1, 0));
1367
+ return;
1368
+ }
1369
+ if (event.key === "Enter") {
1370
+ event.preventDefault();
1371
+ const group = filtered[activeIndex];
1372
+ if (group !== undefined) pick(group);
1373
+ }
1374
+ };
1375
+ return (0, react_jsx_runtime.jsxs)("div", {
1376
+ className: "s2s-groupPicker",
1377
+ ref: rootRef,
1378
+ children: [(0, react_jsx_runtime.jsxs)("button", {
1379
+ type: "button",
1380
+ ref: triggerRef,
1381
+ className: "s2s-selectField",
1382
+ disabled,
1383
+ "aria-haspopup": "listbox",
1384
+ "aria-expanded": open,
1385
+ onClick: () => {
1386
+ setOpen((next) => !next);
1387
+ },
1388
+ children: [(0, react_jsx_runtime.jsx)("span", {
1389
+ className: selected === undefined ? "s2s-selectPlaceholder" : "s2s-selectValue",
1390
+ children: selected === undefined ? placeholder : selected.name
1391
+ }), (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, {
1392
+ size: 14,
1393
+ className: `s2s-selectChevron${open ? " s2s-selectChevronOpen" : ""}`
1394
+ })]
1395
+ }), open && (0, react_jsx_runtime.jsxs)(__deepseek_ai_dsh_client_ui_primitives.MenuSurface, {
1396
+ ref: panelRef,
1397
+ className: "s2s-groupPanel",
1398
+ role: "listbox",
1399
+ style: position === null ? { visibility: "hidden" } : {
1400
+ ...position,
1401
+ ...triggerWidth > 0 ? { width: `${triggerWidth}px` } : {},
1402
+ maxHeight: `${maxHeight}px`
1403
+ },
1404
+ onKeyDown,
1405
+ children: [(0, react_jsx_runtime.jsx)("div", {
1406
+ className: "s2s-groupSearch",
1407
+ children: (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Input, {
1408
+ type: "text",
1409
+ value: filter,
1410
+ placeholder: searchPlaceholder,
1411
+ onChange: (event) => {
1412
+ setFilter(event.target.value);
1413
+ setActiveIndex(0);
1414
+ }
1415
+ })
1416
+ }), (0, react_jsx_runtime.jsxs)("div", {
1417
+ className: "s2s-groupList",
1418
+ ref: listRef,
1419
+ children: [filtered.length === 0 && (0, react_jsx_runtime.jsx)("p", {
1420
+ className: "s2s-groupEmpty",
1421
+ children: emptyLabel
1422
+ }), filtered.map((group, index) => (0, react_jsx_runtime.jsxs)("button", {
1423
+ type: "button",
1424
+ role: "option",
1425
+ "aria-selected": group.id === value,
1426
+ "data-active": index === activeIndex || undefined,
1427
+ className: "s2s-groupOption",
1428
+ onMouseEnter: () => {
1429
+ setActiveIndex(index);
1430
+ },
1431
+ onClick: () => {
1432
+ pick(group);
1433
+ },
1434
+ children: [(0, react_jsx_runtime.jsx)("span", {
1435
+ className: "s2s-groupOptionName",
1436
+ children: group.name
1437
+ }), group.id === value && (0, react_jsx_runtime.jsx)("span", {
1438
+ className: "s2s-groupOptionCheck",
1439
+ "aria-hidden": "true",
1440
+ children: "✓"
1441
+ })]
1442
+ }, group.id))]
1443
+ })]
1444
+ })]
1445
+ });
1446
+ }
1447
+
1181
1448
  //#endregion
1182
1449
  //#region src/client/section/KeysCard.tsx
1183
1450
  const PAGE_SIZE = 5;
@@ -1194,7 +1461,6 @@ function KeysCard({ api, config, groups, t }) {
1194
1461
  const [loading, setLoading] = (0, react.useState)(false);
1195
1462
  const [listError, setListError] = (0, react.useState)(undefined);
1196
1463
  const [creating, setCreating] = (0, react.useState)(false);
1197
- const [groupMenuOpen, setGroupMenuOpen] = (0, react.useState)(false);
1198
1464
  const [groupId, setGroupId] = (0, react.useState)("");
1199
1465
  const [name$1, setName] = (0, react.useState)("");
1200
1466
  const [nameTouched, setNameTouched] = (0, react.useState)(false);
@@ -1273,6 +1539,9 @@ else nextSet.delete(keyId);
1273
1539
  setGroupId(gid);
1274
1540
  if (!nameTouched) setName(groupNameOf(gid));
1275
1541
  };
1542
+ const selectGroup = (group) => {
1543
+ pickGroup(group.id);
1544
+ };
1276
1545
  const closeCreate = () => {
1277
1546
  setCreating(false);
1278
1547
  setGroupId("");
@@ -1461,36 +1730,14 @@ else nextSet.delete(keyId);
1461
1730
  children: [(0, react_jsx_runtime.jsx)("span", {
1462
1731
  className: "s2s-fieldLabel",
1463
1732
  children: t("keyGroupLabel")
1464
- }), (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Menu, {
1465
- open: groupMenuOpen,
1466
- anchor: (0, react_jsx_runtime.jsx)("span", {
1467
- className: "s2s-menuAnchor",
1468
- children: (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Button, {
1469
- variant: "outline",
1470
- onClick: () => {
1471
- setGroupMenuOpen(true);
1472
- },
1473
- children: (0, react_jsx_runtime.jsxs)("span", {
1474
- className: "s2s-selectTrigger",
1475
- children: [(0, react_jsx_runtime.jsx)("span", { children: groupId === "" ? t("keyGroupPlaceholder") : groupNameOf(groupId) }), (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, {
1476
- size: 14,
1477
- className: "s2s-selectChevron"
1478
- })]
1479
- })
1480
- })
1481
- }),
1482
- items: groups.map((group) => ({
1483
- id: String(group.id),
1484
- label: group.name
1485
- })),
1486
- selectedId: groupId === "" ? undefined : String(groupId),
1487
- onSelect: (id) => {
1488
- pickGroup(Number(id));
1489
- setGroupMenuOpen(false);
1490
- },
1491
- onClose: () => {
1492
- setGroupMenuOpen(false);
1493
- }
1733
+ }), (0, react_jsx_runtime.jsx)(GroupPicker, {
1734
+ groups,
1735
+ value: groupId,
1736
+ placeholder: t("keyGroupPlaceholder"),
1737
+ searchPlaceholder: t("keyGroupSearch"),
1738
+ emptyLabel: t("keyGroupSearchEmpty"),
1739
+ disabled: busy,
1740
+ onSelect: selectGroup
1494
1741
  })]
1495
1742
  }), (0, react_jsx_runtime.jsxs)("div", {
1496
1743
  className: "s2s-inline",
package/lib/client.js CHANGED
@@ -138,6 +138,8 @@ const zh = {
138
138
  keysTitle: "Keys",
139
139
  createKey: "创建 key",
140
140
  keyGroupLabel: "所属分组",
141
+ keyGroupSearch: "搜索分组…",
142
+ keyGroupSearchEmpty: "没有匹配的分组",
141
143
  keyNameLabel: "Key 名称",
142
144
  keyNameHint: "默认与分组同名",
143
145
  keyGroupPlaceholder: "选择分组",
@@ -252,6 +254,8 @@ const en = {
252
254
  keysTitle: "Keys",
253
255
  createKey: "Create key",
254
256
  keyGroupLabel: "Group",
257
+ keyGroupSearch: "Search groups…",
258
+ keyGroupSearchEmpty: "No groups match",
255
259
  keyNameLabel: "Key name",
256
260
  keyNameHint: "Defaults to the group name",
257
261
  keyGroupPlaceholder: "Pick a group",
@@ -731,30 +735,135 @@ const CSS = `
731
735
  flex: 1 1 220px;
732
736
  }
733
737
 
734
- /* Menu anchor wrapper: give the picker trigger the same field-column
735
- footprint as the sibling Input rows. */
736
- .s2s-menuAnchor {
738
+ /* Group picker (create-key dialog): the trigger takes the same field-column
739
+ footprint as the sibling Input rows, painted like an input rather than a
740
+ button so both rows read as one form. */
741
+ .s2s-groupPicker {
737
742
  display: flex;
738
743
  flex: 1 1 220px;
739
744
  min-width: 0;
740
745
  }
741
746
 
742
- .s2s-menuAnchor > * {
743
- width: 100%;
744
- }
745
-
746
- /* Select-style trigger (group picker): label left, chevron right. */
747
- .s2s-selectTrigger {
747
+ .s2s-selectField {
748
748
  display: flex;
749
749
  align-items: center;
750
750
  justify-content: space-between;
751
751
  gap: 8px;
752
752
  width: 100%;
753
+ min-height: 34px;
754
+ padding: 6px 10px;
755
+ box-sizing: border-box;
756
+ border: 0.5px solid var(--dsw-alias-border-l2);
757
+ border-radius: var(--dsw-radius-md);
758
+ background: transparent;
759
+ color: inherit;
760
+ font: inherit;
761
+ text-align: left;
762
+ cursor: pointer;
763
+ user-select: none;
764
+ }
765
+
766
+ .s2s-selectField:disabled {
767
+ opacity: 0.55;
768
+ cursor: default;
769
+ }
770
+
771
+ .s2s-selectField:focus-visible {
772
+ outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
773
+ outline-offset: 2px;
774
+ }
775
+
776
+ .s2s-selectPlaceholder {
777
+ color: var(--dsw-alias-label-tertiary);
778
+ overflow: hidden;
779
+ text-overflow: ellipsis;
780
+ white-space: nowrap;
781
+ }
782
+
783
+ .s2s-selectValue {
784
+ min-width: 0;
785
+ overflow: hidden;
786
+ text-overflow: ellipsis;
787
+ white-space: nowrap;
753
788
  }
754
789
 
755
790
  .s2s-selectChevron {
756
791
  flex: none;
757
792
  opacity: 0.55;
793
+ transition: transform var(--ds-transition-duration) var(--ds-ease-in-out);
794
+ }
795
+
796
+ .s2s-selectChevronOpen {
797
+ transform: rotate(180deg);
798
+ }
799
+
800
+ /* The options panel: fixed-positioned from the trigger (escapes the modal
801
+ card's overflow clip), width-matched to it, search pinned on top, options
802
+ scrolling below. */
803
+ .s2s-groupPanel {
804
+ display: flex;
805
+ flex-direction: column;
806
+ box-sizing: border-box;
807
+ z-index: 10;
808
+ overflow: hidden;
809
+ }
810
+
811
+ .s2s-groupSearch {
812
+ flex: none;
813
+ padding: 8px 8px 6px;
814
+ }
815
+
816
+ .s2s-groupSearch > * {
817
+ width: 100%;
818
+ }
819
+
820
+ .s2s-groupList {
821
+ flex: 1 1 auto;
822
+ min-height: 0;
823
+ overflow-y: auto;
824
+ overscroll-behavior: contain;
825
+ padding: 0 8px 8px;
826
+ }
827
+
828
+ .s2s-groupEmpty {
829
+ margin: 0;
830
+ padding: 10px 4px;
831
+ color: var(--dsw-alias-label-tertiary);
832
+ text-align: center;
833
+ }
834
+
835
+ .s2s-groupOption {
836
+ display: flex;
837
+ align-items: center;
838
+ gap: 8px;
839
+ width: 100%;
840
+ min-height: 32px;
841
+ padding: 6px 10px;
842
+ box-sizing: border-box;
843
+ border: 0;
844
+ border-radius: var(--dsw-radius-md);
845
+ background: transparent;
846
+ color: inherit;
847
+ font: inherit;
848
+ text-align: left;
849
+ cursor: pointer;
850
+ }
851
+
852
+ .s2s-groupOption[data-active='true'] {
853
+ background: var(--dsw-alias-interactive-bg-hover);
854
+ }
855
+
856
+ .s2s-groupOptionName {
857
+ flex: 1 1 auto;
858
+ min-width: 0;
859
+ overflow: hidden;
860
+ text-overflow: ellipsis;
861
+ white-space: nowrap;
862
+ }
863
+
864
+ .s2s-groupOptionCheck {
865
+ flex: none;
866
+ color: var(--dsw-alias-state-business-primary);
758
867
  }
759
868
 
760
869
  /* The keys-card search input: fixed-width slot between the title and the
@@ -1184,6 +1293,164 @@ function protocolForPlatform(platform) {
1184
1293
  }
1185
1294
  }
1186
1295
 
1296
+ //#endregion
1297
+ //#region src/client/section/GroupPicker.tsx
1298
+ /** Design cap for the panel; the viewport clamp never exceeds it. */
1299
+ const PANEL_MAX_HEIGHT = 280;
1300
+ /** Distance between the trigger's bottom edge and the panel. */
1301
+ const PANEL_GAP = 6;
1302
+ function GroupPicker({ groups, value, placeholder, searchPlaceholder, emptyLabel, disabled = false, onSelect }) {
1303
+ const [open, setOpen] = (0, react.useState)(false);
1304
+ const [filter, setFilter] = (0, react.useState)("");
1305
+ const [activeIndex, setActiveIndex] = (0, react.useState)(0);
1306
+ const [triggerWidth, setTriggerWidth] = (0, react.useState)(0);
1307
+ const rootRef = (0, react.useRef)(null);
1308
+ const triggerRef = (0, react.useRef)(null);
1309
+ const panelRef = (0, react.useRef)(null);
1310
+ const listRef = (0, react.useRef)(null);
1311
+ const selected = value === "" ? undefined : groups.find((group) => group.id === value);
1312
+ const filtered = (0, react.useMemo)(() => {
1313
+ const needle = filter.trim().toLowerCase();
1314
+ if (needle === "") return groups;
1315
+ return groups.filter((group) => group.name.toLowerCase().includes(needle));
1316
+ }, [groups, filter]);
1317
+ const position = (0, __deepseek_ai_dsh_client_ui_primitives.useAnchoredPosition)({
1318
+ open,
1319
+ anchorRef: triggerRef,
1320
+ panelRef,
1321
+ side: "bottom",
1322
+ align: "start",
1323
+ gap: PANEL_GAP,
1324
+ margin: 24
1325
+ });
1326
+ const maxHeight = (0, __deepseek_ai_dsh_client_ui_primitives.useAnchoredMaxHeight)(panelRef, PANEL_MAX_HEIGHT, open ? filtered.length : 0);
1327
+ (0, __deepseek_ai_dsh_client_ui_primitives.useDismissOnOutsidePointer)(rootRef, open, setOpen, panelRef);
1328
+ (0, react.useEffect)(() => {
1329
+ if (!open) return;
1330
+ setFilter("");
1331
+ setActiveIndex(0);
1332
+ setTriggerWidth(triggerRef.current?.getBoundingClientRect().width ?? 0);
1333
+ const timer$1 = window.setTimeout(() => {
1334
+ panelRef.current?.querySelector("input")?.focus();
1335
+ }, 0);
1336
+ return () => window.clearTimeout(timer$1);
1337
+ }, [open]);
1338
+ (0, react.useEffect)(() => {
1339
+ if (!open) return;
1340
+ const onResize = () => {
1341
+ setTriggerWidth(triggerRef.current?.getBoundingClientRect().width ?? 0);
1342
+ };
1343
+ window.addEventListener("resize", onResize);
1344
+ return () => window.removeEventListener("resize", onResize);
1345
+ }, [open]);
1346
+ (0, react.useEffect)(() => {
1347
+ const list = listRef.current;
1348
+ if (list === null) return;
1349
+ list.querySelector("[data-active='true']")?.scrollIntoView({ block: "nearest" });
1350
+ }, [activeIndex, filter]);
1351
+ const close = () => {
1352
+ setOpen(false);
1353
+ triggerRef.current?.focus();
1354
+ };
1355
+ const pick = (group) => {
1356
+ onSelect(group);
1357
+ close();
1358
+ };
1359
+ const onKeyDown = (event) => {
1360
+ if (event.key === "Escape") {
1361
+ event.stopPropagation();
1362
+ close();
1363
+ return;
1364
+ }
1365
+ if (event.key === "ArrowDown") {
1366
+ event.preventDefault();
1367
+ if (filtered.length > 0) setActiveIndex((index) => Math.min(index + 1, filtered.length - 1));
1368
+ return;
1369
+ }
1370
+ if (event.key === "ArrowUp") {
1371
+ event.preventDefault();
1372
+ setActiveIndex((index) => Math.max(index - 1, 0));
1373
+ return;
1374
+ }
1375
+ if (event.key === "Enter") {
1376
+ event.preventDefault();
1377
+ const group = filtered[activeIndex];
1378
+ if (group !== undefined) pick(group);
1379
+ }
1380
+ };
1381
+ return (0, react_jsx_runtime.jsxs)("div", {
1382
+ className: "s2s-groupPicker",
1383
+ ref: rootRef,
1384
+ children: [(0, react_jsx_runtime.jsxs)("button", {
1385
+ type: "button",
1386
+ ref: triggerRef,
1387
+ className: "s2s-selectField",
1388
+ disabled,
1389
+ "aria-haspopup": "listbox",
1390
+ "aria-expanded": open,
1391
+ onClick: () => {
1392
+ setOpen((next) => !next);
1393
+ },
1394
+ children: [(0, react_jsx_runtime.jsx)("span", {
1395
+ className: selected === undefined ? "s2s-selectPlaceholder" : "s2s-selectValue",
1396
+ children: selected === undefined ? placeholder : selected.name
1397
+ }), (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, {
1398
+ size: 14,
1399
+ className: `s2s-selectChevron${open ? " s2s-selectChevronOpen" : ""}`
1400
+ })]
1401
+ }), open && (0, react_jsx_runtime.jsxs)(__deepseek_ai_dsh_client_ui_primitives.MenuSurface, {
1402
+ ref: panelRef,
1403
+ className: "s2s-groupPanel",
1404
+ role: "listbox",
1405
+ style: position === null ? { visibility: "hidden" } : {
1406
+ ...position,
1407
+ ...triggerWidth > 0 ? { width: `${triggerWidth}px` } : {},
1408
+ maxHeight: `${maxHeight}px`
1409
+ },
1410
+ onKeyDown,
1411
+ children: [(0, react_jsx_runtime.jsx)("div", {
1412
+ className: "s2s-groupSearch",
1413
+ children: (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Input, {
1414
+ type: "text",
1415
+ value: filter,
1416
+ placeholder: searchPlaceholder,
1417
+ onChange: (event) => {
1418
+ setFilter(event.target.value);
1419
+ setActiveIndex(0);
1420
+ }
1421
+ })
1422
+ }), (0, react_jsx_runtime.jsxs)("div", {
1423
+ className: "s2s-groupList",
1424
+ ref: listRef,
1425
+ children: [filtered.length === 0 && (0, react_jsx_runtime.jsx)("p", {
1426
+ className: "s2s-groupEmpty",
1427
+ children: emptyLabel
1428
+ }), filtered.map((group, index) => (0, react_jsx_runtime.jsxs)("button", {
1429
+ type: "button",
1430
+ role: "option",
1431
+ "aria-selected": group.id === value,
1432
+ "data-active": index === activeIndex || undefined,
1433
+ className: "s2s-groupOption",
1434
+ onMouseEnter: () => {
1435
+ setActiveIndex(index);
1436
+ },
1437
+ onClick: () => {
1438
+ pick(group);
1439
+ },
1440
+ children: [(0, react_jsx_runtime.jsx)("span", {
1441
+ className: "s2s-groupOptionName",
1442
+ children: group.name
1443
+ }), group.id === value && (0, react_jsx_runtime.jsx)("span", {
1444
+ className: "s2s-groupOptionCheck",
1445
+ "aria-hidden": "true",
1446
+ children: "✓"
1447
+ })]
1448
+ }, group.id))]
1449
+ })]
1450
+ })]
1451
+ });
1452
+ }
1453
+
1187
1454
  //#endregion
1188
1455
  //#region src/client/section/KeysCard.tsx
1189
1456
  const PAGE_SIZE = 5;
@@ -1200,7 +1467,6 @@ function KeysCard({ api, config, groups, t }) {
1200
1467
  const [loading, setLoading] = (0, react.useState)(false);
1201
1468
  const [listError, setListError] = (0, react.useState)(undefined);
1202
1469
  const [creating, setCreating] = (0, react.useState)(false);
1203
- const [groupMenuOpen, setGroupMenuOpen] = (0, react.useState)(false);
1204
1470
  const [groupId, setGroupId] = (0, react.useState)("");
1205
1471
  const [name$1, setName] = (0, react.useState)("");
1206
1472
  const [nameTouched, setNameTouched] = (0, react.useState)(false);
@@ -1279,6 +1545,9 @@ else nextSet.delete(keyId);
1279
1545
  setGroupId(gid);
1280
1546
  if (!nameTouched) setName(groupNameOf(gid));
1281
1547
  };
1548
+ const selectGroup = (group) => {
1549
+ pickGroup(group.id);
1550
+ };
1282
1551
  const closeCreate = () => {
1283
1552
  setCreating(false);
1284
1553
  setGroupId("");
@@ -1467,36 +1736,14 @@ else nextSet.delete(keyId);
1467
1736
  children: [(0, react_jsx_runtime.jsx)("span", {
1468
1737
  className: "s2s-fieldLabel",
1469
1738
  children: t("keyGroupLabel")
1470
- }), (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Menu, {
1471
- open: groupMenuOpen,
1472
- anchor: (0, react_jsx_runtime.jsx)("span", {
1473
- className: "s2s-menuAnchor",
1474
- children: (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.Button, {
1475
- variant: "outline",
1476
- onClick: () => {
1477
- setGroupMenuOpen(true);
1478
- },
1479
- children: (0, react_jsx_runtime.jsxs)("span", {
1480
- className: "s2s-selectTrigger",
1481
- children: [(0, react_jsx_runtime.jsx)("span", { children: groupId === "" ? t("keyGroupPlaceholder") : groupNameOf(groupId) }), (0, react_jsx_runtime.jsx)(__deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, {
1482
- size: 14,
1483
- className: "s2s-selectChevron"
1484
- })]
1485
- })
1486
- })
1487
- }),
1488
- items: groups.map((group) => ({
1489
- id: String(group.id),
1490
- label: group.name
1491
- })),
1492
- selectedId: groupId === "" ? undefined : String(groupId),
1493
- onSelect: (id) => {
1494
- pickGroup(Number(id));
1495
- setGroupMenuOpen(false);
1496
- },
1497
- onClose: () => {
1498
- setGroupMenuOpen(false);
1499
- }
1739
+ }), (0, react_jsx_runtime.jsx)(GroupPicker, {
1740
+ groups,
1741
+ value: groupId,
1742
+ placeholder: t("keyGroupPlaceholder"),
1743
+ searchPlaceholder: t("keyGroupSearch"),
1744
+ emptyLabel: t("keyGroupSearchEmpty"),
1745
+ disabled: busy,
1746
+ onSelect: selectGroup
1500
1747
  })]
1501
1748
  }), (0, react_jsx_runtime.jsxs)("div", {
1502
1749
  className: "s2s-inline",
package/lib/index.js CHANGED
@@ -300,8 +300,38 @@ const THINKING_LEVELS = [
300
300
  "xhigh",
301
301
  "max"
302
302
  ];
303
+ /**
304
+ * Native adaptive-thinking entries confirmed by pi-ai 1.1.0's Anthropic
305
+ * catalog. Keep this allowlist conservative: an effort enum alone is NOT proof
306
+ * of adaptive support (Opus 4.5 has native effort but still uses budget thinking,
307
+ * and aggregators publish effort enums even for older Claude models).
308
+ * Unknown releases/aliases stay explicit-template owned rather than guessed.
309
+ */
310
+ const ADAPTIVE_CLAUDE_MODELS = new Set([
311
+ "claude-opus-4-6",
312
+ "claude-opus-4-7",
313
+ "claude-opus-4-8",
314
+ "claude-opus-5",
315
+ "claude-opus-5-5",
316
+ "claude-sonnet-4-6",
317
+ "claude-sonnet-5",
318
+ "claude-sonnet-5-5",
319
+ "claude-haiku-5-5",
320
+ "claude-fable-5",
321
+ "claude-fable-5-1"
322
+ ]);
323
+ /** Pair catalog effort metadata with its native Anthropic wire mode. */
324
+ function modelCompat(input, model) {
325
+ const template = input.template?.compat;
326
+ const canonicalId = model.id.toLowerCase().replaceAll(".", "-").replace(/(?:-(?:fast|think|thinking|\d{8})|@(?:default|eu))$/, "");
327
+ const adaptive = input.protocol === "anthropic-messages" && input.template?.reasoningEfforts !== false && model.reasoningEfforts !== undefined && ADAPTIVE_CLAUDE_MODELS.has(canonicalId);
328
+ if (!adaptive && template === undefined) return undefined;
329
+ return {
330
+ ...adaptive ? { forceAdaptiveThinking: true } : {},
331
+ ...template
332
+ };
333
+ }
303
334
  function buildProviderProfile(input) {
304
- const compat = input.template?.compat;
305
335
  const reasoning = input.template?.reasoningEfforts;
306
336
  const override = input.retryPolicy ?? {};
307
337
  const retryPolicy = {
@@ -319,15 +349,18 @@ function buildProviderProfile(input) {
319
349
  apiKeyEnv: input.apiKeyEnv,
320
350
  api: input.protocol,
321
351
  baseURL: baseURLForProtocol(input.instance, input.protocol),
322
- models: input.models.map((model) => ({
323
- id: model.id,
324
- ...model.name !== undefined ? { name: model.name } : {},
325
- ...model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {},
326
- ...model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {},
327
- ...model.imageInput === true ? { input: ["text", "image"] } : {},
328
- ...compat !== undefined ? { compat: { ...compat } } : {},
329
- ...reasoning === false ? { reasoningEfforts: false } : reasoning !== undefined ? { reasoningEfforts: { ...reasoning } } : model.reasoningEfforts !== undefined ? { reasoningEfforts: { ...model.reasoningEfforts } } : {}
330
- })),
352
+ models: input.models.map((model) => {
353
+ const compat = modelCompat(input, model);
354
+ return {
355
+ id: model.id,
356
+ ...model.name !== undefined ? { name: model.name } : {},
357
+ ...model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {},
358
+ ...model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {},
359
+ ...model.imageInput === true ? { input: ["text", "image"] } : {},
360
+ ...compat !== undefined ? { compat } : {},
361
+ ...reasoning === false ? { reasoningEfforts: false } : reasoning !== undefined ? { reasoningEfforts: { ...reasoning } } : model.reasoningEfforts !== undefined ? { reasoningEfforts: { ...model.reasoningEfforts } } : {}
362
+ };
363
+ }),
331
364
  ...input.template?.reasoning !== undefined ? { reasoning: input.template.reasoning } : {},
332
365
  defaultContextWindow: input.defaultContextWindow ?? 128e3,
333
366
  defaultMaxTokens: input.defaultMaxTokens ?? 8192,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mangoc/dsh-sub2api-sync",
3
3
  "description": "Sync one sub2api instance's groups, keys, and models into dsh-llm-pi-ai provider routes: panel-driven login (password / OAuth), one relay key one provider.",
4
- "version": "0.2.4",
4
+ "version": "0.2.6",
5
5
  "license": "MIT",
6
6
  "author": "Shuai-S",
7
7
  "homepage": "https://github.com/Shuai-S/dsh-sub2api-sync#readme",