dsh-keys-manager 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -175,6 +175,12 @@ var zh = {
175
175
  accountProjected: "\u670D\u52A1\u4E2D",
176
176
  accountReauth: "\u91CD\u65B0\u8BA4\u8BC1",
177
177
  accountActivate: "\u8BBE\u4E3A\u5F53\u524D",
178
+ testKey: "\u6D4B\u8BD5\u8FDE\u63A5",
179
+ testRunning: "\u6D4B\u8BD5\u4E2D\u2026",
180
+ testPassed: "\u8FDE\u63A5\u6B63\u5E38 \xB7 {ms} ms",
181
+ testFailed: "\u8FDE\u63A5\u5931\u8D25\uFF1A{error}",
182
+ testTimedOut: "\u6D4B\u8BD5\u8D85\u65F6\uFF0C\u672A\u6536\u5230\u7ED3\u679C",
183
+ testFailedUnknown: "\u6D4B\u8BD5\u672A\u5B8C\u6210\uFF0C\u672A\u8FD4\u56DE\u539F\u56E0",
178
184
  loginTitle: "\u8D26\u6237\u767B\u5F55",
179
185
  loginPending: "\u7B49\u5F85\u6388\u6743\u2026",
180
186
  loginBrowserOpen: "\u5728\u6D4F\u89C8\u5668\u4E2D\u6253\u5F00",
@@ -185,7 +191,19 @@ var zh = {
185
191
  loginReady: "\u767B\u5F55\u6210\u529F\u3002",
186
192
  deleteAccountTitle: "\u5220\u9664\u8D26\u6237\u201C{alias}\u201D\uFF1F",
187
193
  deleteAccountBody: "\u8FD9\u4F1A\u4ECE\u51ED\u8BC1\u5E93\u4E2D\u5220\u9664\u8BE5\u8D26\u6237\u4FDD\u5B58\u7684 OAuth \u6388\u6743\u3002\u5982\u679C\u5B83\u662F\u5F53\u524D\u8D26\u6237\uFF0C\u8BE5 Provider \u8DEF\u7531\u5C06\u505C\u7528\u5B83\u5E76\u56DE\u843D\u5230 API-key \u6C60\uFF1BProvider \u4ECD\u4FDD\u6301\u63A5\u7BA1\u72B6\u6001\u3002",
188
- deleteAccountBodyNoKeys: "\u8FD9\u4F1A\u4ECE\u51ED\u8BC1\u5E93\u4E2D\u5220\u9664\u8BE5\u8D26\u6237\u4FDD\u5B58\u7684 OAuth \u6388\u6743\u3002\u8BE5\u8DEF\u7531\u6CA1\u6709 API-key \u6C60\uFF0C\u5220\u9664\u540E\u5C06\u505C\u6B62\u670D\u52A1\uFF0C\u76F4\u5230\u91CD\u65B0\u767B\u5F55\u8D26\u6237\u3002"
194
+ deleteAccountBodyNoKeys: "\u8FD9\u4F1A\u4ECE\u51ED\u8BC1\u5E93\u4E2D\u5220\u9664\u8BE5\u8D26\u6237\u4FDD\u5B58\u7684 OAuth \u6388\u6743\u3002\u8BE5\u8DEF\u7531\u6CA1\u6709 API-key \u6C60\uFF0C\u5220\u9664\u540E\u5C06\u505C\u6B62\u670D\u52A1\uFF0C\u76F4\u5230\u91CD\u65B0\u767B\u5F55\u8D26\u6237\u3002",
195
+ // Transport accounts (llm-omp-transports/<route>): managed beside the
196
+ // pi-ai pool, so the copy never promises a key-pool fallback.
197
+ transportAccounts: "\u4F20\u8F93\u8D26\u6237",
198
+ transportTag: "\u4F20\u8F93",
199
+ transportSignIn: "\u767B\u5F55",
200
+ transportExpires: "\u6709\u6548\u671F\u81F3 {time}",
201
+ transportStatusActive: "\u6B63\u5E38",
202
+ transportStatusExpired: "\u4EE4\u724C\u5DF2\u8FC7\u671F",
203
+ transportStatusNeedsReauth: "\u9700\u91CD\u65B0\u767B\u5F55",
204
+ transportStatusSignedOut: "\u672A\u767B\u5F55",
205
+ transportRemoveTitle: "\u79FB\u9664 {name} \u4F20\u8F93\u8D26\u6237\uFF1F",
206
+ transportRemoveBody: "\u8FD9\u4F1A\u4ECE\u51ED\u8BC1\u5E93\u4E2D\u5220\u9664\u8BE5\u4F20\u8F93\u8DEF\u7531\u4FDD\u5B58\u7684\u6388\u6743\uFF0C\u5E76\u53D6\u6D88\u4ECD\u5728\u8FDB\u884C\u7684\u767B\u5F55\uFF1B\u8DEF\u7531\u81EA\u8EAB\u4E0D\u4F1A\u91CD\u5EFA\u5B83\u3002\u8981\u6062\u590D\u670D\u52A1\u9700\u8981\u91CD\u65B0\u767B\u5F55\u3002"
189
207
  };
190
208
  var en = {
191
209
  nav: "Keys Manager",
@@ -323,6 +341,12 @@ var en = {
323
341
  accountProjected: "Serving provider",
324
342
  accountReauth: "Re-authenticate",
325
343
  accountActivate: "Set active",
344
+ testKey: "Test",
345
+ testRunning: "Testing\u2026",
346
+ testPassed: "Test passed \xB7 {ms} ms",
347
+ testFailed: "Test failed: {error}",
348
+ testTimedOut: "The test timed out without a result",
349
+ testFailedUnknown: "The test did not complete and reported no reason",
326
350
  loginTitle: "Account sign-in",
327
351
  loginPending: "Waiting for authorization\u2026",
328
352
  loginBrowserOpen: "Open in browser",
@@ -333,7 +357,18 @@ var en = {
333
357
  loginReady: "Signed in.",
334
358
  deleteAccountTitle: "Delete account \u201C{alias}\u201D?",
335
359
  deleteAccountBody: "This removes the account\u2019s stored OAuth grant from the credential store. If it is the active account, the provider route stops serving it and falls back to its API-key pool; the provider stays managed.",
336
- deleteAccountBodyNoKeys: "This removes the account\u2019s stored OAuth grant from the credential store. This route has no API-key pool, so it stops serving requests until an account is signed in again."
360
+ deleteAccountBodyNoKeys: "This removes the account\u2019s stored OAuth grant from the credential store. This route has no API-key pool, so it stops serving requests until an account is signed in again.",
361
+ // Transport accounts — same values as locale/en.json.
362
+ transportAccounts: "Transport accounts",
363
+ transportTag: "Transport",
364
+ transportSignIn: "Sign in",
365
+ transportExpires: "Expires {time}",
366
+ transportStatusActive: "Active",
367
+ transportStatusExpired: "Token expired",
368
+ transportStatusNeedsReauth: "Sign in again",
369
+ transportStatusSignedOut: "Not signed in",
370
+ transportRemoveTitle: "Remove the {name} transport account?",
371
+ transportRemoveBody: "This removes the grant stored for this transport route from the credential store and cancels a sign-in still in progress; the route does not recreate it. Sign in again to restore service."
337
372
  };
338
373
  var LOCALE_DICTS = { zh, en };
339
374
 
@@ -584,6 +619,18 @@ function messageOf(error) {
584
619
  if (error instanceof Error && error.message.length > 0) return error.message;
585
620
  return String(error);
586
621
  }
622
+ var TEST_KEY_DEADLINE_MS = 3e4;
623
+ async function withDeadline(work, ms, label) {
624
+ let timer;
625
+ const deadline = new Promise((_, reject) => {
626
+ timer = setTimeout(() => reject(new Error(`${label} did not answer within ${ms / 1e3}s`)), ms);
627
+ });
628
+ try {
629
+ return await Promise.race([work, deadline]);
630
+ } finally {
631
+ if (timer !== void 0) clearTimeout(timer);
632
+ }
633
+ }
587
634
  function createStore(ctx) {
588
635
  let snapshot = {
589
636
  status: "idle",
@@ -595,6 +642,10 @@ function createStore(ctx) {
595
642
  let loadSequence = 0;
596
643
  let mutationTail = Promise.resolve();
597
644
  let queuedMutations = 0;
645
+ let activeTests = 0;
646
+ let testGeneration = 0;
647
+ let mutationGeneration = 0;
648
+ let stateDropped = false;
598
649
  const listeners = /* @__PURE__ */ new Set();
599
650
  const emit = () => {
600
651
  for (const listener of [...listeners]) {
@@ -614,6 +665,7 @@ function createStore(ctx) {
614
665
  update({ busy: method, error: null });
615
666
  const run = async () => {
616
667
  ++loadSequence;
668
+ mutationGeneration = testGeneration;
617
669
  try {
618
670
  const result = await rpc(method, args);
619
671
  if (!result.ok) throw new Error(result.error.message);
@@ -627,6 +679,7 @@ function createStore(ctx) {
627
679
  return { ok: false, error: message };
628
680
  } finally {
629
681
  queuedMutations -= 1;
682
+ if (queuedMutations === 0) refreshDroppedState();
630
683
  }
631
684
  };
632
685
  const next = mutationTail.then(run, run);
@@ -635,10 +688,21 @@ function createStore(ctx) {
635
688
  };
636
689
  const stateOutcome = (value) => {
637
690
  const state = value;
691
+ const borrowed = activeTests > 0 || testGeneration !== mutationGeneration;
692
+ if (borrowed) {
693
+ if (queuedMutations === 1) update({ busy: null, error: null });
694
+ else update({ error: null });
695
+ stateDropped = true;
696
+ return { ok: true, value: state };
697
+ }
638
698
  if (queuedMutations === 1) update({ status: "ready", state, now: Date.now(), busy: null, error: null });
639
699
  else update({ status: "ready", state, now: Date.now(), error: null });
640
700
  return { ok: true, value: state };
641
701
  };
702
+ const refreshDroppedState = () => {
703
+ if (!stateDropped || activeTests > 0 || snapshot.busy !== null) return;
704
+ void store.load({ keepError: true });
705
+ };
642
706
  const rpcRead = async (method, args) => {
643
707
  try {
644
708
  return await rpc(method, args);
@@ -649,7 +713,14 @@ function createStore(ctx) {
649
713
  const attemptOutcome = (value) => {
650
714
  return { ok: true, value: value ?? null };
651
715
  };
652
- return {
716
+ const testOutcome = (value) => {
717
+ const result = value ?? {};
718
+ return {
719
+ ok: true,
720
+ value: { ok: result.ok === true, latencyMs: result.latencyMs ?? 0, ...result.error === void 0 ? {} : { error: result.error } }
721
+ };
722
+ };
723
+ const store = {
653
724
  getSnapshot: () => snapshot,
654
725
  subscribe(listener) {
655
726
  listeners.add(listener);
@@ -658,12 +729,18 @@ function createStore(ctx) {
658
729
  async load(options = {}) {
659
730
  if (snapshot.busy !== null) return;
660
731
  const request = ++loadSequence;
732
+ const generation = testGeneration;
661
733
  update({ busy: "load", error: options.keepError === true ? snapshot.error : null });
662
734
  try {
663
735
  const result = await rpc("getState", {});
664
736
  if (request !== loadSequence) return;
737
+ if (activeTests > 0 || testGeneration !== generation) {
738
+ update({ busy: null });
739
+ return;
740
+ }
665
741
  if (!result.ok) throw new Error(result.error.message);
666
742
  update({ status: "ready", state: result.value, now: Date.now(), busy: null, error: null });
743
+ stateDropped = false;
667
744
  } catch (error) {
668
745
  if (request !== loadSequence) return;
669
746
  update({ status: snapshot.state !== null ? snapshot.status : "error", busy: null, error: messageOf(error) });
@@ -694,6 +771,36 @@ function createStore(ctx) {
694
771
  removeAccount(provider, accountId) {
695
772
  return send("removeAccount", { provider, accountId }, stateOutcome);
696
773
  },
774
+ async testKey(provider, keyId) {
775
+ activeTests += 1;
776
+ ++testGeneration;
777
+ const request = rpc("testKey", { provider, keyId });
778
+ let released = false;
779
+ const release = () => {
780
+ if (released) return;
781
+ released = true;
782
+ activeTests -= 1;
783
+ testGeneration += 1;
784
+ refreshDroppedState();
785
+ };
786
+ void request.then(release, release);
787
+ try {
788
+ const result = await withDeadline(request, TEST_KEY_DEADLINE_MS, "testKey");
789
+ if (!result.ok) throw new Error(result.error.message);
790
+ return testOutcome(result.value);
791
+ } catch (error) {
792
+ return { ok: false, error: messageOf(error) };
793
+ }
794
+ },
795
+ beginTransportLogin(route) {
796
+ return send("beginTransportLogin", { provider: route }, attemptOutcome);
797
+ },
798
+ activateTransportAccount(route) {
799
+ return send("activateTransportAccount", { provider: route }, stateOutcome);
800
+ },
801
+ removeTransportAccount(route) {
802
+ return send("removeTransportAccount", { provider: route }, stateOutcome);
803
+ },
697
804
  tick() {
698
805
  update({ now: Date.now() });
699
806
  if (snapshot.status === "ready" && snapshot.busy === null) void this.load({ keepError: true });
@@ -702,6 +809,7 @@ function createStore(ctx) {
702
809
  update({ error: null });
703
810
  }
704
811
  };
812
+ return store;
705
813
  }
706
814
 
707
815
  // src/client/ui.ts
@@ -791,6 +899,30 @@ function statusKey(status) {
791
899
  return "keyStatusUnmanaged";
792
900
  }
793
901
  }
902
+ function transportStatusKey(status) {
903
+ switch (status) {
904
+ case "active":
905
+ return "transportStatusActive";
906
+ case "expired":
907
+ return "transportStatusExpired";
908
+ case "needs-reauth":
909
+ return "transportStatusNeedsReauth";
910
+ default:
911
+ return "transportStatusSignedOut";
912
+ }
913
+ }
914
+ function transportStatusTone(status) {
915
+ switch (status) {
916
+ case "active":
917
+ return "success";
918
+ case "expired":
919
+ return "warn";
920
+ case "needs-reauth":
921
+ return "error";
922
+ default:
923
+ return "idle";
924
+ }
925
+ }
794
926
  function statusTone(status) {
795
927
  switch (status) {
796
928
  case "active":
@@ -836,6 +968,7 @@ function Icon({ name, size = 16, className }) {
836
968
  if (name === "warning") return h("svg", common, h("path", { d: "M12 3l9.5 17h-19L12 3zM12 9v5M12 17.2v.1" }));
837
969
  if (name === "shield") return h("svg", common, h("path", { d: "M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6l8-3zM9 12l2 2 4-4" }));
838
970
  if (name === "user") return h("svg", common, h("circle", { cx: 12, cy: 8, r: 3.6 }), h("path", { d: "M5 20a7 7 0 0114 0" }));
971
+ if (name === "activity") return h("svg", common, h("path", { d: "M3 12h4l2.5-7 4 14 2.5-7h5" }));
839
972
  return h("svg", common);
840
973
  }
841
974
  function Button({ variant = "secondary", size = "normal", type = "button", disabled, onClick, children, title }) {
@@ -1039,6 +1172,35 @@ function KeyForm({ provider, onDone, autoFocus = false }) {
1039
1172
  )
1040
1173
  );
1041
1174
  }
1175
+ var ROW_TEST_STYLE = { gridColumn: "1 / -1", marginTop: "-2px" };
1176
+ var ROW_TEST_DEADLINE_MS = 3e4;
1177
+ async function requestRowTest(store, provider, keyId) {
1178
+ let timer;
1179
+ const deadline = new Promise((resolve) => {
1180
+ timer = setTimeout(() => resolve({ kind: "failed", timedOut: true }), ROW_TEST_DEADLINE_MS);
1181
+ });
1182
+ const answer = store.testKey(provider, keyId).then((outcome) => {
1183
+ if (!outcome.ok) return { kind: "failed", error: outcome.error };
1184
+ if (outcome.value.ok) return { kind: "passed", latencyMs: outcome.value.latencyMs };
1185
+ return { kind: "failed", ...outcome.value.error === void 0 ? {} : { error: outcome.value.error } };
1186
+ });
1187
+ try {
1188
+ return await Promise.race([answer, deadline]);
1189
+ } finally {
1190
+ if (timer !== void 0) clearTimeout(timer);
1191
+ }
1192
+ }
1193
+ function testLine(test, t) {
1194
+ if (test === null) return null;
1195
+ if (test.kind === "running") {
1196
+ return h("div", { className: "km-key-time km-muted", role: "status", style: ROW_TEST_STYLE }, t("testRunning"));
1197
+ }
1198
+ if (test.kind === "passed") {
1199
+ return h("div", { className: "km-key-time", role: "status", style: ROW_TEST_STYLE }, t("testPassed", { ms: test.latencyMs ?? 0 }));
1200
+ }
1201
+ const detail = test.error !== void 0 && test.error.length > 0 ? test.error : test.timedOut === true ? t("testTimedOut") : t("testFailedUnknown");
1202
+ return h("div", { className: "km-key-time km-warn", role: "status", style: ROW_TEST_STYLE }, t("testFailed", { error: detail }));
1203
+ }
1042
1204
  function KeyRow({ provider, keyRecord, now }) {
1043
1205
  const key = keyRecord;
1044
1206
  const runtime = useRuntime();
@@ -1050,6 +1212,7 @@ function KeyRow({ provider, keyRecord, now }) {
1050
1212
  const [confirm, setConfirm] = import_react2.default.useState(false);
1051
1213
  const [busy, setBusy] = import_react2.default.useState(false);
1052
1214
  const [error, setError] = import_react2.default.useState(null);
1215
+ const [test, setTest] = import_react2.default.useState(null);
1053
1216
  const rootRef = import_react2.default.useRef(null);
1054
1217
  import_react2.default.useEffect(() => {
1055
1218
  setAlias(key.alias);
@@ -1094,6 +1257,10 @@ function KeyRow({ provider, keyRecord, now }) {
1094
1257
  if (!result.ok) setError(result.error);
1095
1258
  else setConfirm(false);
1096
1259
  }
1260
+ async function runTest() {
1261
+ setTest({ kind: "running" });
1262
+ setTest(await requestRowTest(runtime.store, provider.provider, key.id));
1263
+ }
1097
1264
  const cold = key.status === "cooling" && key.cooldownUntil > now;
1098
1265
  const meta = key.status === "missing" ? t("keyStatusMissing") : key.totalUses > 0 ? t("uses", { n: key.totalUses }) : "";
1099
1266
  return h(
@@ -1169,6 +1336,16 @@ function KeyRow({ provider, keyRecord, now }) {
1169
1336
  setMenu(false);
1170
1337
  setEditing(true);
1171
1338
  } }, h(Icon, { name: "key", size: 15 }), t("rename")),
1339
+ h("button", {
1340
+ type: "button",
1341
+ className: "km-menu-item",
1342
+ role: "menuitem",
1343
+ onClick: () => {
1344
+ setMenu(false);
1345
+ void runTest();
1346
+ },
1347
+ disabled: busy || test?.kind === "running"
1348
+ }, h(Icon, { name: "activity", size: 15 }), t("testKey")),
1172
1349
  h("button", {
1173
1350
  type: "button",
1174
1351
  className: "km-menu-item",
@@ -1187,6 +1364,7 @@ function KeyRow({ provider, keyRecord, now }) {
1187
1364
  ) : null
1188
1365
  ),
1189
1366
  error !== null ? h("div", { className: "km-key-error", role: "alert" }, error) : null,
1367
+ testLine(test, t),
1190
1368
  confirm ? h(Modal, {
1191
1369
  title: t("deleteTitle", { alias: key.alias }),
1192
1370
  onClose: () => setConfirm(false),
@@ -1208,6 +1386,7 @@ function AccountRow({ provider, keyRecord, now, onReauthenticate }) {
1208
1386
  const [confirm, setConfirm] = import_react2.default.useState(false);
1209
1387
  const [busy, setBusy] = import_react2.default.useState(false);
1210
1388
  const [error, setError] = import_react2.default.useState(null);
1389
+ const [test, setTest] = import_react2.default.useState(null);
1211
1390
  const rootRef = import_react2.default.useRef(null);
1212
1391
  import_react2.default.useEffect(() => {
1213
1392
  if (!menu) return void 0;
@@ -1239,6 +1418,10 @@ function AccountRow({ provider, keyRecord, now, onReauthenticate }) {
1239
1418
  if (!result.ok) setError(result.error);
1240
1419
  else setConfirm(false);
1241
1420
  }
1421
+ async function runTest() {
1422
+ setTest({ kind: "running" });
1423
+ setTest(await requestRowTest(runtime.store, provider.provider, key.id));
1424
+ }
1242
1425
  const cold = key.status === "cooling" && key.cooldownUntil > now;
1243
1426
  const metaParts = [
1244
1427
  key.totalUses > 0 ? t("uses", { n: key.totalUses }) : null,
@@ -1313,6 +1496,16 @@ function AccountRow({ provider, keyRecord, now, onReauthenticate }) {
1313
1496
  // active again is a no-op, so the entry stays disabled.
1314
1497
  disabled: busy || key.projected === true
1315
1498
  }, h(Icon, { name: "check", size: 15 }), t("accountActivate")),
1499
+ h("button", {
1500
+ type: "button",
1501
+ className: "km-menu-item",
1502
+ role: "menuitem",
1503
+ onClick: () => {
1504
+ setMenu(false);
1505
+ void runTest();
1506
+ },
1507
+ disabled: busy || test?.kind === "running"
1508
+ }, h(Icon, { name: "activity", size: 15 }), t("testKey")),
1316
1509
  h("button", {
1317
1510
  type: "button",
1318
1511
  className: "km-menu-item",
@@ -1331,6 +1524,7 @@ function AccountRow({ provider, keyRecord, now, onReauthenticate }) {
1331
1524
  ) : null
1332
1525
  ),
1333
1526
  error !== null ? h("div", { className: "km-key-error", role: "alert" }, error) : null,
1527
+ testLine(test, t),
1334
1528
  confirm ? h(Modal, {
1335
1529
  // SF4: account-specific copy — the grant removal consequence, not a
1336
1530
  // key-deletion/provider-release promise. Step 8: the key-pool
@@ -1346,6 +1540,114 @@ function AccountRow({ provider, keyRecord, now, onReauthenticate }) {
1346
1540
  }, h("p", { className: "km-modal-copy" }, t(provider.supported === true ? "deleteAccountBody" : "deleteAccountBodyNoKeys"))) : null
1347
1541
  );
1348
1542
  }
1543
+ function TransportAccountRow({ account }) {
1544
+ const runtime = useRuntime();
1545
+ const t = useT();
1546
+ const locale = useLocaleId();
1547
+ const [busy, setBusy] = import_react2.default.useState(false);
1548
+ const [error, setError] = import_react2.default.useState(null);
1549
+ const [confirm, setConfirm] = import_react2.default.useState(false);
1550
+ const [login, setLogin] = import_react2.default.useState(null);
1551
+ async function signIn() {
1552
+ setBusy(true);
1553
+ setError(null);
1554
+ const result = await runtime.store.beginTransportLogin(account.id);
1555
+ setBusy(false);
1556
+ if (!result.ok) {
1557
+ setError(result.error);
1558
+ return;
1559
+ }
1560
+ if (result.value !== null) setLogin({ attemptId: result.value.id });
1561
+ }
1562
+ async function remove() {
1563
+ setBusy(true);
1564
+ setError(null);
1565
+ const result = await runtime.store.removeTransportAccount(account.id);
1566
+ setBusy(false);
1567
+ if (!result.ok) setError(result.error);
1568
+ else setConfirm(false);
1569
+ }
1570
+ const identity = account.email ?? account.accountId ?? account.displayName;
1571
+ const tone = transportStatusTone(account.status);
1572
+ return h(
1573
+ "div",
1574
+ { className: "km-account-row km-transport-row" },
1575
+ h(
1576
+ "div",
1577
+ { className: "km-key-identity" },
1578
+ h(
1579
+ "div",
1580
+ { className: "km-key-alias-line" },
1581
+ h("span", { className: "km-account-glyph", "aria-hidden": true }, h(Icon, { name: "user", size: 13 })),
1582
+ h("span", { className: "km-key-alias" }, identity),
1583
+ h("span", { className: "km-tag km-tag-accent" }, t("transportTag"))
1584
+ ),
1585
+ // The credential scope, never the payload: the record is the account.
1586
+ h(
1587
+ "div",
1588
+ { className: "km-key-sub" },
1589
+ h("span", { className: "km-mono km-muted" }, account.scope)
1590
+ )
1591
+ ),
1592
+ h(
1593
+ "div",
1594
+ { className: "km-key-status" },
1595
+ h(
1596
+ "span",
1597
+ { className: cx("km-status", `km-status-${tone}`) },
1598
+ h("span", { className: cx("km-dot", `km-dot-${tone}`) }),
1599
+ h("span", null, t(transportStatusKey(account.status)))
1600
+ )
1601
+ ),
1602
+ // The expiry column stays a clock, not a countdown: the route's adapter
1603
+ // renews the grant itself, so "expired" here means "renews on next use".
1604
+ h(
1605
+ "div",
1606
+ { className: "km-key-last" },
1607
+ account.expiresAt === void 0 ? h("div", { className: "km-key-time km-muted" }, t("never")) : h("div", { className: "km-key-time" }, t("transportExpires", { time: formatClock(account.expiresAt, locale, t) }))
1608
+ ),
1609
+ h(
1610
+ "div",
1611
+ { className: "km-key-actions" },
1612
+ h(
1613
+ Button,
1614
+ {
1615
+ size: "small",
1616
+ disabled: busy,
1617
+ onClick: signIn,
1618
+ title: account.active ? t("accountReauth") : t("transportSignIn")
1619
+ },
1620
+ busy ? h(Icon, { name: "refresh", size: 14, className: "km-spin" }) : h(Icon, { name: "refresh", size: 14 }),
1621
+ t(account.active ? "accountReauth" : "transportSignIn")
1622
+ ),
1623
+ // No "Set active" entry: activation would write nothing (the stored grant
1624
+ // already IS the route's live credential), so the host endpoint stays for
1625
+ // API symmetry with the account surface and the row omits a no-effect
1626
+ // control.
1627
+ h(Button, {
1628
+ size: "small",
1629
+ variant: "danger",
1630
+ disabled: busy,
1631
+ onClick: () => setConfirm(true)
1632
+ }, h(Icon, { name: "trash", size: 14 }), t("delete"))
1633
+ ),
1634
+ error !== null ? h("div", { className: "km-key-error", role: "alert" }, error) : null,
1635
+ confirm ? h(Modal, {
1636
+ title: t("transportRemoveTitle", { name: account.displayName }),
1637
+ onClose: () => setConfirm(false),
1638
+ busy,
1639
+ footer: [
1640
+ h(Button, { key: "cancel", onClick: () => setConfirm(false), disabled: busy }, t("cancel")),
1641
+ h(Button, { key: "delete", variant: "danger", onClick: remove, disabled: busy }, busy ? t("deleting") : t("deleteConfirm"))
1642
+ ]
1643
+ }, h("p", { className: "km-modal-copy" }, t("transportRemoveBody"))) : null,
1644
+ login !== null ? h(LoginDialog, {
1645
+ provider: { displayName: account.displayName },
1646
+ attemptId: login.attemptId,
1647
+ onClose: () => setLogin(null)
1648
+ }) : null
1649
+ );
1650
+ }
1349
1651
  function ProviderCard({ provider, expanded, onToggle, now }) {
1350
1652
  const runtime = useRuntime();
1351
1653
  const t = useT();
@@ -1885,6 +2187,7 @@ function KeysManagerBody() {
1885
2187
  if (snapshot.status === "idle") void runtime.store.load();
1886
2188
  }, [runtime, snapshot.status]);
1887
2189
  const providers = state?.providers ?? [];
2190
+ const transportAccounts = state?.transportAccounts ?? [];
1888
2191
  const normalizedQuery = query.trim().toLowerCase();
1889
2192
  const matches = (provider) => {
1890
2193
  if (normalizedQuery.length === 0) return true;
@@ -2021,6 +2324,20 @@ function KeysManagerBody() {
2021
2324
  h(Button, { variant: "ghost", size: "small", onClick: () => setShowAllAvailable(false) }, t("showLess"))
2022
2325
  ) : null
2023
2326
  ) : null,
2327
+ !loading && !failed && transportAccounts.length > 0 ? h(
2328
+ "section",
2329
+ { className: "km-section" },
2330
+ h(
2331
+ "div",
2332
+ { className: "km-section-head" },
2333
+ h("h2", { className: "km-section-title" }, t("transportAccounts")),
2334
+ h("span", { className: "km-section-count" }, transportAccounts.length)
2335
+ ),
2336
+ h("div", { className: "km-provider-list" }, transportAccounts.map((account) => h(TransportAccountRow, {
2337
+ key: account.scope,
2338
+ account
2339
+ })))
2340
+ ) : null,
2024
2341
  !loading && !failed ? h("section", { className: "km-section" }, h(SettingsCard)) : null,
2025
2342
  h(
2026
2343
  "footer",
package/lib/domain.d.ts CHANGED
@@ -94,6 +94,13 @@ export interface ProviderRecord {
94
94
  brokerRef: string;
95
95
  /** The profile's reference before adoption, restored on release. */
96
96
  originalApiKeyEnv: string | null;
97
+ /**
98
+ * Adoption found a foreign projection serving the route, so it could not
99
+ * complete its one-time takeover of `originalApiKeyEnv` (PR review round 40).
100
+ * The debt survives a restart and is settled by the next projection-change
101
+ * sync; it never authorises an ordinary repair.
102
+ */
103
+ takeoverPending?: boolean;
97
104
  /** True when the profile itself was created by adding an extra provider. */
98
105
  createdProfile: boolean;
99
106
  /** True when a profile existed before adoption. */
@@ -186,6 +193,30 @@ export interface ProviderView {
186
193
  keys: KeyView[];
187
194
  warning?: string;
188
195
  }
196
+ /** Health state a transport account row renders. */
197
+ export type TransportAccountStatus = 'active' | 'expired' | 'needs-reauth' | 'signed-out';
198
+ /**
199
+ * One `llm-omp-transports/<route>` account held beside the pi-ai pool.
200
+ *
201
+ * A transport route keeps exactly one grant record, so a row is either that
202
+ * record's secret-free view or the route's signed-out placeholder. It never
203
+ * carries the grant payload itself, and it is never part of a pi-ai
204
+ * projection: the transports package's own adapter resolves the record.
205
+ */
206
+ export interface TransportAccountView {
207
+ /** Provider route the grant serves, e.g. 'cursor'. */
208
+ id: string;
209
+ /** The credential record holding the grant, e.g. 'llm-omp-transports/cursor'. */
210
+ scope: string;
211
+ displayName: string;
212
+ email?: string;
213
+ accountId?: string;
214
+ /** Epoch ms when the access token expires. */
215
+ expiresAt?: number;
216
+ status: TransportAccountStatus;
217
+ /** Derived from {@link status}: true only while it is `'active'`. */
218
+ active: boolean;
219
+ }
189
220
  /** Complete manager snapshot the UI renders. */
190
221
  export interface StateView {
191
222
  now: number;
@@ -199,6 +230,22 @@ export interface StateView {
199
230
  pausedKeyCount: number;
200
231
  bindingsCount: number;
201
232
  warnings: string[];
233
+ /**
234
+ * Transport accounts managed beside the pi-ai pool. Additive and optional,
235
+ * so a snapshot from a build without transport support still renders.
236
+ */
237
+ transportAccounts?: TransportAccountView[];
238
+ }
239
+ /**
240
+ * Outcome of one per-row connection test: one minimal request through the
241
+ * provider route, with no health or selection side effects.
242
+ */
243
+ export interface KeyTestResult {
244
+ ok: boolean;
245
+ /** Wall-clock time of the request that was actually sent. */
246
+ latencyMs: number;
247
+ /** Provider failure message, timeout, or the reason no request was sent. */
248
+ error?: string;
202
249
  }
203
250
  /** One curated extra built-in provider. */
204
251
  export interface ExtraProvider {
@@ -207,6 +254,14 @@ export interface ExtraProvider {
207
254
  api: string;
208
255
  baseURL: string;
209
256
  env: string[];
257
+ /**
258
+ * Static request headers this route needs. Maps header name → environment
259
+ * variable name; each value is resolved from the environment at add time.
260
+ * Only non-secret metadata headers are supported: values are persisted as
261
+ * plain profile settings, so a credential-bearing name (`authorization`,
262
+ * `proxy-authorization`, `cookie`, `x-api-key`, `*-api-key`) is refused.
263
+ */
264
+ headerEnv?: Record<string, string>;
210
265
  models: ModelRecord[];
211
266
  }
212
267
  /** Serializable notice captured on an interactive sign-in attempt. */
@@ -46,6 +46,18 @@ export interface LlmRuntimeLike {
46
46
  name: string;
47
47
  }>;
48
48
  discoverModels(settingsNs: string, request: DiscoveryRequest, signal: AbortSignal): Promise<DiscoveredModel[]>;
49
+ /**
50
+ * One model request through the provider route. The host runs the
51
+ * `llm/stream` waterfall, so the manager's own stream listener — and the
52
+ * broker credential wrapper — observe it exactly like a chat request.
53
+ */
54
+ stream(options: {
55
+ provider: string;
56
+ model: string;
57
+ messages: readonly unknown[];
58
+ maxTokens?: number;
59
+ signal?: AbortSignal;
60
+ }): AsyncIterable<StreamChunkLike>;
49
61
  }
50
62
  /** Credential value/source facts returned by the credential seam. */
51
63
  export interface CredentialHit {
@@ -126,6 +138,23 @@ export interface CredentialRecordsLike {
126
138
  modifyRecord(key: string, mutate: (current: CredentialRecordLike | undefined) => Promise<CredentialRecordLike | undefined>): Promise<CredentialRecordLike | undefined>;
127
139
  deleteRecord(key: string): Promise<void>;
128
140
  }
141
+ /**
142
+ * The grant payload the llm-omp transports package writes at
143
+ * `llm-omp-transports/<route>`. Keys Manager only reads it (list/status) and
144
+ * deletes the whole record; the payload stays owned by its writer, so this is
145
+ * a structural shape (the package exports no runtime type for it without
146
+ * pulling its transport code in).
147
+ */
148
+ export interface TransportGrantLike {
149
+ /** Bearer token the route's adapter sends. */
150
+ access: string;
151
+ /** Refresh token; absent on a grant that cannot be renewed. */
152
+ refresh?: string;
153
+ /** Epoch ms when the access token expires. */
154
+ expires: number;
155
+ email?: string;
156
+ accountId?: string;
157
+ }
129
158
  /** One path-addressed settings edit. */
130
159
  export type SettingsPathOp = {
131
160
  op: 'set';