@herbertgao/pi-extensions 2026.9.12 → 2026.9.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +4 -4
  2. package/THIRD_PARTY_NOTICES.md +1 -1
  3. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +8 -0
  4. package/node_modules/@herbertgao/pi-subagents/package.json +1 -1
  5. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +5 -4
  6. package/node_modules/@herbertgao/pi-subagents/src/mention-clone.ts +39 -16
  7. package/node_modules/@narumitw/pi-btw/README.md +21 -4
  8. package/node_modules/@narumitw/pi-btw/dist/index.ts +1668 -958
  9. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +4 -4
  10. package/node_modules/@narumitw/pi-btw/docs/workflows.md +10 -2
  11. package/node_modules/@narumitw/pi-btw/package.json +1 -1
  12. package/node_modules/@narumitw/pi-btw/src/btw.ts +17 -79
  13. package/node_modules/@narumitw/pi-btw/src/conversation-context.ts +74 -0
  14. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +196 -7
  15. package/node_modules/@narumitw/pi-btw/src/main-thread-updates.ts +40 -0
  16. package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -2
  17. package/node_modules/@narumitw/pi-btw/src/settings.ts +49 -0
  18. package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +9 -1
  19. package/node_modules/@narumitw/pi-btw/src/workspace-layout.ts +559 -0
  20. package/node_modules/pi-multi-account/CHANGELOG.md +23 -0
  21. package/node_modules/pi-multi-account/README.md +38 -11
  22. package/node_modules/pi-multi-account/index.ts +371 -96
  23. package/node_modules/pi-multi-account/package.json +5 -4
  24. package/node_modules/pi-multi-account/provider-payload-stream.ts +36 -28
  25. package/node_modules/pi-multi-account/usage.ts +31 -2
  26. package/node_modules/pi-typesafe/README.md +3 -1
  27. package/node_modules/pi-typesafe/dist/auth.d.ts +10 -3
  28. package/node_modules/pi-typesafe/dist/auth.js +13 -7
  29. package/node_modules/pi-typesafe/dist/backends.d.ts +31 -0
  30. package/node_modules/pi-typesafe/dist/backends.js +33 -0
  31. package/node_modules/pi-typesafe/dist/client.d.ts +3 -9
  32. package/node_modules/pi-typesafe/dist/client.js +63 -39
  33. package/node_modules/pi-typesafe/dist/credentials.d.ts +11 -5
  34. package/node_modules/pi-typesafe/dist/credentials.js +15 -8
  35. package/node_modules/pi-typesafe/dist/extension.js +4 -1
  36. package/node_modules/pi-typesafe/dist/index.d.ts +1 -1
  37. package/node_modules/pi-typesafe/dist/index.js +1 -1
  38. package/node_modules/pi-typesafe/dist/login.d.ts +13 -7
  39. package/node_modules/pi-typesafe/dist/login.js +17 -8
  40. package/node_modules/pi-typesafe/dist/schema.js +19 -13
  41. package/node_modules/pi-typesafe/package.json +1 -1
  42. package/package.json +5 -5
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-multi-account",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
4
4
  "description": "Automatic multi-account failover & rotation for Pi Agent across Anthropic (Claude), OpenAI/ChatGPT Codex, Kimi For Coding, Cursor, Qwen/Alibaba, and Ollama. Auto-discovers authenticated accounts, grows the rotation on login, and drops accounts on logout, expiry, or quota/rate-limit errors",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -78,10 +78,12 @@
78
78
  "access": "public"
79
79
  },
80
80
  "peerDependencies": {
81
- "@earendil-works/pi-coding-agent": ">=0.85.1"
81
+ "@earendil-works/pi-coding-agent": ">=0.85.1 <0.88.0",
82
+ "@earendil-works/pi-ai": ">=0.85.1 <0.88.0"
82
83
  },
83
84
  "devDependencies": {
84
- "@earendil-works/pi-coding-agent": "^0.85.1",
85
+ "@earendil-works/pi-coding-agent": "0.85.1",
86
+ "@earendil-works/pi-ai": "0.85.1",
85
87
  "@types/node": "^22.20.1",
86
88
  "typescript": "^6.0.3"
87
89
  },
@@ -92,7 +94,6 @@
92
94
  },
93
95
  "dependencies": {
94
96
  "@bufbuild/protobuf": "^2.14.0",
95
- "@earendil-works/pi-ai": "^0.85.1",
96
97
  "proper-lockfile": "4.1.2"
97
98
  },
98
99
  "overrides": {
@@ -1,39 +1,47 @@
1
- import { createRequire } from "node:module";
2
- import { fileURLToPath } from "node:url";
3
-
4
1
  type Shape = (payload: any, model: any, options: any) => any;
5
2
  type Api = { streamSimple: (model: any, context: any, options: any) => any };
6
- const requireLocal = createRequire(import.meta.url);
7
- let registry: { getApiProvider: (api: string) => Api | undefined } | undefined;
3
+ let registry: Promise<typeof import("@earendil-works/pi-ai/compat")> | undefined;
8
4
 
9
- function nativeApi(api: string): Api {
10
- if (!registry) {
11
- // Pi >= 0.80 moved the API registry to /compat. Resolve lazily so merely
12
- // listing OAuth providers does not require loading a transport.
13
- for (const entry of ["@earendil-works/pi-ai/compat", "@earendil-works/pi-ai"]) {
14
- try {
15
- const candidate = requireLocal(fileURLToPath(import.meta.resolve(entry)));
16
- if (typeof candidate.getApiProvider === "function") { registry = candidate; break; }
17
- } catch { /* Try the older public entry point. */ }
18
- }
19
- }
20
- const provider = registry?.getApiProvider(api);
21
- if (!provider) throw new Error(`Pi native API is unavailable: ${api}`);
5
+ async function nativeApi(api: string): Promise<Api> {
6
+ // A bare import is essential: Pi binds it to the running host's module (including
7
+ // bundled/virtual hosts). createRequire/import.meta.resolve bypass that binding and
8
+ // can pair a new transcript with an old, extension-local transport (#65/#66).
9
+ // Lazy loading preserves account discovery when the optional transport is unavailable.
10
+ registry ??= import("@earendil-works/pi-ai/compat").catch((error) => {
11
+ registry = undefined;
12
+ throw error;
13
+ });
14
+ const native = await registry;
15
+ const provider = native.getApiProvider?.(api as any);
16
+ if (!provider) throw new Error(`Pi host API is unavailable: ${api}`);
22
17
  return provider;
23
18
  }
24
19
 
25
20
  /** Provider-level shaping applies to every public Pi client, including calls
26
21
  * outside the interactive agent's before_provider_request event lifecycle. */
27
- export function createPayloadStream(shape: Shape, resolveApi = nativeApi) {
28
- return (model: any, context: any, options: any = {}) => resolveApi(model.api).streamSimple(model, context, {
29
- ...options,
30
- onPayload: async (payload: any, actualModel: any) => {
31
- const replacement = await options.onPayload?.(payload, actualModel);
32
- const current = replacement === undefined ? payload : replacement;
33
- const shaped = await shape(current, actualModel, options);
34
- return shaped === undefined ? current : shaped;
35
- },
36
- });
22
+ export function createPayloadStream(shape: Shape, resolveApi: (api: string) => Api | Promise<Api> = nativeApi) {
23
+ return (model: any, context: any, options: any = {}) => {
24
+ const start = (api: Api) => api.streamSimple(model, context, {
25
+ ...options,
26
+ onPayload: async (payload: any, actualModel: any) => {
27
+ const replacement = await options.onPayload?.(payload, actualModel);
28
+ const current = replacement === undefined ? payload : replacement;
29
+ const shaped = await shape(current, actualModel, options);
30
+ return shaped === undefined ? current : shaped;
31
+ },
32
+ });
33
+ const api = resolveApi(model.api);
34
+ if (!(api instanceof Promise)) return start(api);
35
+ const stream = api.then(start);
36
+ // Stream functions must return synchronously. Both consumers await the SAME native
37
+ // stream; no reserialization, context conversion or second request is introduced.
38
+ // Observe setup rejection even if a caller discards the returned stream.
39
+ void stream.catch(() => {});
40
+ return {
41
+ async *[Symbol.asyncIterator]() { yield* await stream; },
42
+ async result() { return (await stream).result(); },
43
+ };
44
+ };
37
45
  }
38
46
 
39
47
  export const cursorPayloadStream = createPayloadStream((payload, _model, options) => {
@@ -192,6 +192,11 @@ function headerValue(headers: unknown, name: string): string | undefined {
192
192
  return undefined;
193
193
  }
194
194
 
195
+ function headerBoolean(headers: unknown, name: string): boolean | undefined {
196
+ const value = headerValue(headers, name)?.toLowerCase();
197
+ return value === "true" ? true : value === "false" ? false : undefined;
198
+ }
199
+
195
200
  function headerWindow(headers: unknown, prefix: "primary" | "secondary"): UsageWindow | undefined {
196
201
  const usedPercent = percent(headerValue(headers, `x-codex-${prefix}-used-percent`));
197
202
  const resetAt = epochMs(headerValue(headers, `x-codex-${prefix}-reset-at`));
@@ -222,8 +227,8 @@ export function parseCodexUsageHeaders(
222
227
  primary,
223
228
  secondary,
224
229
  credits: {
225
- hasCredits: headerValue(headers, "x-codex-credits-has-credits")?.toLowerCase() === "true",
226
- unlimited: headerValue(headers, "x-codex-credits-unlimited")?.toLowerCase() === "true",
230
+ hasCredits: headerBoolean(headers, "x-codex-credits-has-credits"),
231
+ unlimited: headerBoolean(headers, "x-codex-credits-unlimited"),
227
232
  balance: headerValue(headers, "x-codex-credits-balance"),
228
233
  },
229
234
  };
@@ -1009,6 +1014,30 @@ export function windowLabel(
1009
1014
  return `${Math.max(1, Math.round(seconds / 3_600))}h`;
1010
1015
  }
1011
1016
 
1017
+ /**
1018
+ * Preserve display identity across partial updates for the same credential only.
1019
+ * Never carry quota windows, credits or serviceability forward under next.fetchedAt:
1020
+ * those fields control routing and an old verdict must not acquire a new lease merely
1021
+ * because response headers refreshed a different field.
1022
+ */
1023
+ export function mergeUsageSnapshot(
1024
+ previous: UsageSnapshot | undefined,
1025
+ next: UsageSnapshot,
1026
+ ): UsageSnapshot {
1027
+ if (
1028
+ !previous ||
1029
+ previous.provider !== next.provider ||
1030
+ !next.credentialHash ||
1031
+ previous.credentialHash !== next.credentialHash
1032
+ )
1033
+ return next;
1034
+ return {
1035
+ ...next,
1036
+ account: next.account ?? previous.account,
1037
+ plan: next.plan ?? previous.plan,
1038
+ };
1039
+ }
1040
+
1012
1041
  export function formatUsageCompact(snapshot: UsageSnapshot, now = Date.now()): string {
1013
1042
  const who = shortAccount(snapshot.account);
1014
1043
  const parts = [
@@ -138,7 +138,9 @@ const answer = await ask(typesafe, {
138
138
  if (!answer.ok) return { skipped: answer.errorCode === "budget" }; // never throws
139
139
  ```
140
140
 
141
- Your extension owns its own user consent and budget; `/typesafe enable` applies only to this package's tool. Check `authState()` rather than your own consent flag before you report that judgments are on. Every export — the client, `ask`, batching, the usage ledger, auth state, and the `pi-typesafe/calibrate` and `pi-typesafe/ui` entry points — is in [docs/api.md](docs/api.md).
141
+ Your extension owns its own user consent and budget; `/typesafe enable` applies only to this package's tool. Check `authState()` rather than your own consent flag before you report that judgments are on.
142
+
143
+ Judgments can also go through OpenRouter: `createTypeSafe({ backend: "openrouter" })` sends them to `openrouter.ai` with the key from `OPENROUTER_API_KEY`. That backend has no login store, so `/typesafe login` does not apply to it. Pass the same `backend` to `authState`, `keySituation`, and `ensureApiKey`, or the status you report describes the TypeSafe key while the requests use another one. The `/typesafe` commands and the `typesafe_evaluate` tool always use the TypeSafe backend. Every export — the client, `ask`, batching, the usage ledger, auth state, and the `pi-typesafe/calibrate` and `pi-typesafe/ui` entry points — is in [docs/api.md](docs/api.md).
142
144
 
143
145
  ## Development
144
146
 
@@ -1,3 +1,4 @@
1
+ import type { TypeSafeBackend } from "./backends.js";
1
2
  import type { KeySource } from "./credentials.js";
2
3
  import { TypeSafeIntegrationError } from "./errors.js";
3
4
  import type { IntegrationErrorCode } from "./errors.js";
@@ -14,6 +15,8 @@ export interface AuthFailure {
14
15
  * that judgments will happen — an enabled extension with no key used to look identical to a working one.
15
16
  */
16
17
  export interface AuthState {
18
+ /** The judgment backend this state describes; each backend has its own key. */
19
+ readonly backend: TypeSafeBackend;
17
20
  /** Same kinds as KeySituation: where the key in effect comes from. */
18
21
  readonly kind: "environment" | "stored" | "missing" | "unusable";
19
22
  readonly source?: KeySource;
@@ -21,9 +24,12 @@ export interface AuthState {
21
24
  readonly path: string;
22
25
  /** Why a stored key cannot be used, when that is the case. */
23
26
  readonly reason?: string;
24
- /** Short human label for the key source: `TYPESAFE_API_KEY`, `/typesafe login`, `no key`, `unusable key`. */
27
+ /** Short human label for the key source: `TYPESAFE_API_KEY`, `OPENROUTER_API_KEY`, `/typesafe login`, `no key`, `unusable key`. */
25
28
  readonly keyName: string;
26
- /** True when the key in effect was accepted by api.typesafe.ai (login verifies it; a successful request proves it). */
29
+ /**
30
+ * True when the key in effect was accepted by the backend (login verifies a TypeSafe key; a successful request proves
31
+ * any key). The record is shared across backends: switching backends keeps the last outcome until the next request.
32
+ */
27
33
  readonly verified: boolean;
28
34
  readonly verifiedAt?: string;
29
35
  /** The last failure, cleared by the next successful request. */
@@ -33,9 +39,10 @@ export interface AuthState {
33
39
  }
34
40
  /** The auth-state file: one small, owner-only record that outlives the process that wrote it. */
35
41
  export declare function authStatePath(): string;
36
- /** What the key situation, the last outcome, and the clock add up to. Never throws. */
42
+ /** What the key situation, the last outcome, and the clock add up to for one backend. Never throws. */
37
43
  export declare function authState(options?: {
38
44
  path?: string;
45
+ backend?: TypeSafeBackend;
39
46
  }): AuthState;
40
47
  /** Record that the key was accepted: login verification, or any successful request. Clears the last failure. */
41
48
  export declare function recordAuthVerified(at?: Date): void;
@@ -1,5 +1,6 @@
1
1
  import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { DEFAULT_BACKEND, TYPESAFE_KEY_ENV, backendConfig, usesTypesafeKey } from "./backends.js";
3
4
  import { credentialsPath, keySituation, keySourceLabel, piTypesafeDir } from "./credentials.js";
4
5
  import { TypeSafeIntegrationError } from "./errors.js";
5
6
  const AUTH_VERSION = 1;
@@ -49,15 +50,17 @@ function writeState(path, state) {
49
50
  catch { /* best-effort cleanup only */ }
50
51
  }
51
52
  }
52
- /** What the key situation, the last outcome, and the clock add up to. Never throws. */
53
+ /** What the key situation, the last outcome, and the clock add up to for one backend. Never throws. */
53
54
  export function authState(options = {}) {
54
55
  const path = options.path ?? authStatePath();
55
- const situation = keySituation();
56
+ const backend = options.backend ?? DEFAULT_BACKEND;
57
+ const situation = keySituation(backend);
56
58
  const stored = readState(path);
57
59
  const source = situation.kind === "environment" ? "environment" : situation.kind === "stored" ? "stored" : undefined;
58
60
  const rejected = stored.lastFailure?.code === "http" && stored.lastFailure.status !== undefined && REJECTED_STATUSES.has(stored.lastFailure.status);
59
61
  const usable = source !== undefined && !rejected;
60
62
  return {
63
+ backend,
61
64
  kind: situation.kind,
62
65
  ...(source === undefined ? {} : { source }),
63
66
  path: situation.kind === "unusable" ? situation.path : credentialsPath(),
@@ -96,19 +99,22 @@ export function clearAuthState() {
96
99
  * state instead of reporting "enabled".
97
100
  */
98
101
  export function describeAuth(state = authState()) {
102
+ const config = backendConfig(state.backend ?? DEFAULT_BACKEND);
103
+ const label = `${config.label} key`;
99
104
  const since = state.lastFailure ? ` Last failure: ${state.lastFailure.message}${state.lastFailure.at ? ` (${state.lastFailure.at})` : ""}` : "";
100
105
  if (state.kind === "missing") {
101
- return { level: "error", text: `TypeSafe key: missing — every Jev judgment is skipped until a key is configured (/typesafe login or TYPESAFE_API_KEY).${since}` };
106
+ const how = usesTypesafeKey(config) ? `a key is configured (/typesafe login or ${TYPESAFE_KEY_ENV})` : `${config.keyEnv} is set in the environment`;
107
+ return { level: "error", text: `${label}: missing — every Jev judgment is skipped until ${how}.${since}` };
102
108
  }
103
109
  if (state.kind === "unusable") {
104
- return { level: "error", text: `TypeSafe key: unusable (${state.reason ?? "unknown reason"}) — judgments are skipped until the key is fixed.${since}` };
110
+ return { level: "error", text: `${label}: unusable (${state.reason ?? "unknown reason"}) — judgments are skipped until the key is fixed.${since}` };
105
111
  }
106
112
  const rejected = state.lastFailure?.code === "http" && state.lastFailure.status !== undefined && REJECTED_STATUSES.has(state.lastFailure.status);
107
113
  if (rejected) {
108
- return { level: "error", text: `TypeSafe key: ${state.keyName} was rejected.${since}` };
114
+ return { level: "error", text: `${label}: ${state.keyName} was rejected.${since}` };
109
115
  }
110
116
  if (!state.verified) {
111
- return { level: "warning", text: `TypeSafe key: ${state.keyName} (not verified yet — the first request proves it).${since}` };
117
+ return { level: "warning", text: `${label}: ${state.keyName} (not verified yet — the first request proves it).${since}` };
112
118
  }
113
- return { level: "ok", text: `TypeSafe key: ${state.keyName} (verified${state.verifiedAt ? ` ${state.verifiedAt}` : ""}).${since}` };
119
+ return { level: "ok", text: `${label}: ${state.keyName} (verified${state.verifiedAt ? ` ${state.verifiedAt}` : ""}).${since}` };
114
120
  }
@@ -0,0 +1,31 @@
1
+ export type TypeSafeBackend = "typesafe" | "openrouter";
2
+ export interface BackendConfig {
3
+ /** Human name for status lines: "TypeSafe", "OpenRouter". */
4
+ label: string;
5
+ host: string;
6
+ /** The environment variable that carries this backend's key. Absent means the TypeSafe key resolution applies. */
7
+ keyEnv?: string;
8
+ /** Request path, when the backend does not serve the SDK's own `/v1/systemone`. */
9
+ path?: string;
10
+ /** Request path for the model list, when the backend does not serve the SDK's own `/v1/models`. */
11
+ modelsPath?: string;
12
+ /** Field the model list arrives in, when the backend does not use the SDK's own `models`. */
13
+ modelsField?: string;
14
+ /** Entry field carrying the id callers pass as `model:`, when the SDK's own `name` is only a label. */
15
+ modelsIdField?: string;
16
+ /** Whether the model list checks the key. A public list accepts any key, so it proves nothing. Absent means it does. */
17
+ modelsVerifyKey?: boolean;
18
+ }
19
+ /** The backend every key and auth function assumes when none is named. */
20
+ export declare const DEFAULT_BACKEND: TypeSafeBackend;
21
+ /** The environment variable and login store that the default backend reads. */
22
+ export declare const TYPESAFE_KEY_ENV = "TYPESAFE_API_KEY";
23
+ /** Registry of known judgment backends. Extendable by callers. */
24
+ export declare const DECISIONS_BACKENDS: Record<TypeSafeBackend, BackendConfig>;
25
+ /** The registry entry for a backend name; a `configuration` error for a name the registry does not know. */
26
+ export declare function backendConfig(name: TypeSafeBackend): BackendConfig;
27
+ /**
28
+ * Whether a backend's key comes from the TypeSafe resolution (`TYPESAFE_API_KEY`, then the login store) or only from
29
+ * its own environment variable. Only the TypeSafe backend has a login store; every other backend is environment-only.
30
+ */
31
+ export declare function usesTypesafeKey(backend: BackendConfig): boolean;
@@ -0,0 +1,33 @@
1
+ import { TypeSafeIntegrationError } from "./errors.js";
2
+ /** The backend every key and auth function assumes when none is named. */
3
+ export const DEFAULT_BACKEND = "typesafe";
4
+ /** The environment variable and login store that the default backend reads. */
5
+ export const TYPESAFE_KEY_ENV = "TYPESAFE_API_KEY";
6
+ /** Registry of known judgment backends. Extendable by callers. */
7
+ export const DECISIONS_BACKENDS = {
8
+ typesafe: { label: "TypeSafe", host: "https://api.typesafe.ai", keyEnv: TYPESAFE_KEY_ENV },
9
+ openrouter: {
10
+ label: "OpenRouter",
11
+ host: "https://openrouter.ai",
12
+ keyEnv: "OPENROUTER_API_KEY",
13
+ path: "/api/alpha/decisions",
14
+ modelsPath: "/api/v1/models",
15
+ modelsField: "data",
16
+ modelsIdField: "id",
17
+ modelsVerifyKey: false,
18
+ },
19
+ };
20
+ /** The registry entry for a backend name; a `configuration` error for a name the registry does not know. */
21
+ export function backendConfig(name) {
22
+ const backend = DECISIONS_BACKENDS[name];
23
+ if (!backend)
24
+ throw new TypeSafeIntegrationError("configuration", `Unknown judgment backend "${name}". Valid backends: ${Object.keys(DECISIONS_BACKENDS).join(", ")}.`);
25
+ return backend;
26
+ }
27
+ /**
28
+ * Whether a backend's key comes from the TypeSafe resolution (`TYPESAFE_API_KEY`, then the login store) or only from
29
+ * its own environment variable. Only the TypeSafe backend has a login store; every other backend is environment-only.
30
+ */
31
+ export function usesTypesafeKey(backend) {
32
+ return (backend.keyEnv ?? TYPESAFE_KEY_ENV) === TYPESAFE_KEY_ENV;
33
+ }
@@ -1,15 +1,9 @@
1
1
  import type { Fetch, Questions, SystemOneRequest, SystemOneResult } from "@typesafe-ai/sdk";
2
+ import type { TypeSafeBackend } from "./backends.js";
2
3
  import type { BatchEvaluation, BatchOptions } from "./batch.js";
3
4
  import type { BlockedCap, SpendCaps, UsageLedger, UsageReport } from "./usage.js";
4
- export type TypeSafeBackend = "typesafe" | "openrouter";
5
- export interface BackendConfig {
6
- host: string;
7
- keyEnv?: string;
8
- /** Request path, when the backend does not serve the SDK's own `/v1/systemone`. */
9
- path?: string;
10
- }
11
- /** Registry of known judgment backends. Extendable by callers. */
12
- export declare const DECISIONS_BACKENDS: Record<TypeSafeBackend, BackendConfig>;
5
+ export { DECISIONS_BACKENDS, DEFAULT_BACKEND } from "./backends.js";
6
+ export type { BackendConfig, TypeSafeBackend } from "./backends.js";
13
7
  export interface TypeSafeOptions {
14
8
  /** Defaults to TYPESAFE_API_KEY, then the key saved by `/typesafe login`; never returned. */
15
9
  apiKey?: string;
@@ -1,20 +1,59 @@
1
1
  import { TypeSafeClient } from "@typesafe-ai/sdk";
2
2
  import { recordAuthFailure, recordAuthVerified } from "./auth.js";
3
+ import { DEFAULT_BACKEND, TYPESAFE_KEY_ENV, backendConfig, usesTypesafeKey } from "./backends.js";
3
4
  import { evaluateAll, evaluateMany } from "./batch.js";
4
5
  import { keySituation } from "./credentials.js";
5
6
  import { TypeSafeIntegrationError, safeError } from "./errors.js";
6
7
  import { DEFAULT_MAX_INPUT_BYTES, assertWithinByteLimit, prepareEvaluationRequest } from "./schema.js";
7
8
  import { DEFAULT_USD_PER_MTOK, capsFromEnvironment, estimateUsd, mergeCaps, openUsageLedger } from "./usage.js";
8
- /** The path the SDK appends to whatever base URL it is given. */
9
+ export { DECISIONS_BACKENDS, DEFAULT_BACKEND } from "./backends.js";
10
+ /** The paths the SDK appends to whatever base URL it is given. */
9
11
  const SDK_PATH = "/v1/systemone";
10
- /** Registry of known judgment backends. Extendable by callers. */
11
- export const DECISIONS_BACKENDS = {
12
- typesafe: { host: "https://api.typesafe.ai", keyEnv: "TYPESAFE_API_KEY" },
13
- openrouter: { host: "https://openrouter.ai", keyEnv: "OPENROUTER_API_KEY", path: "/api/alpha/decisions" },
14
- };
15
- /** Send the SDK's fixed path to the backend's own, preserving any caller-supplied transport. */
16
- function backendFetch(path, inner = fetch) {
17
- return (input, init) => inner(String(input).replace(SDK_PATH, path), init);
12
+ const SDK_MODELS_PATH = "/v1/models";
13
+ /**
14
+ * Send the SDK's fixed paths to the backend's own, preserving any caller-supplied transport. A backend that serves
15
+ * its model list under another path also gets that list renamed to the field the SDK reads.
16
+ */
17
+ function backendFetch(backend, inner = fetch) {
18
+ const { path, modelsPath, modelsField } = backend;
19
+ return async (input, init) => {
20
+ const url = String(input);
21
+ const models = modelsPath !== undefined && url.includes(SDK_MODELS_PATH);
22
+ const rewrite = models ? modelsPath : path;
23
+ const response = await inner(rewrite === undefined ? url : url.replace(models ? SDK_MODELS_PATH : SDK_PATH, rewrite), init);
24
+ return models && modelsField !== undefined ? translateModels(response, backend) : response;
25
+ };
26
+ }
27
+ /**
28
+ * Hand the SDK the list it expects: the field it reads, and the entry value callers pass as `model:` when the backend
29
+ * labels models differently. Status and headers survive; a body without the declared field is passed through
30
+ * unchanged, so the SDK still reports its own shape error.
31
+ */
32
+ async function translateModels(response, backend) {
33
+ const { modelsField, modelsIdField } = backend;
34
+ const text = await response.text();
35
+ let wire;
36
+ try {
37
+ wire = JSON.parse(text);
38
+ }
39
+ catch {
40
+ wire = undefined;
41
+ }
42
+ const list = modelsField !== undefined && wire !== null && typeof wire === "object" ? wire[modelsField] : undefined;
43
+ const headers = new Headers(response.headers);
44
+ // The body is replaced, so a copied length would describe the old one.
45
+ headers.delete("content-length");
46
+ headers.delete("content-encoding");
47
+ const send = (body) => new Response(body, { status: response.status, statusText: response.statusText, headers });
48
+ if (!Array.isArray(list))
49
+ return send(text);
50
+ const models = list.map(entry => {
51
+ if (modelsIdField === undefined || entry === null || typeof entry !== "object")
52
+ return entry;
53
+ const id = entry[modelsIdField];
54
+ return typeof id === "string" && id.length > 0 ? { ...entry, name: id } : entry;
55
+ });
56
+ return send(JSON.stringify({ models }));
18
57
  }
19
58
  /** Default attempts per client instance; the extension quotes the same number in its consent copy. */
20
59
  export const DEFAULT_MAX_REQUESTS = 20;
@@ -80,37 +119,20 @@ function capsDescription(caps) {
80
119
  /** A bounded, server-side TypeSafe client independent of Pi's runtime. */
81
120
  export function createTypeSafe(options = {}) {
82
121
  let apiKey = options.apiKey?.trim();
83
- // Resolve backend and host.
84
- const backendName = options.backend;
85
- let baseURL = "https://api.typesafe.ai";
86
- let keyEnv = "TYPESAFE_API_KEY";
87
- let backendPath;
88
- if (backendName !== undefined) {
89
- const backend = DECISIONS_BACKENDS[backendName];
90
- if (!backend)
91
- throw new TypeSafeIntegrationError("configuration", `Unknown judgment backend "${backendName}". Valid backends: ${Object.keys(DECISIONS_BACKENDS).join(", ")}.`);
92
- baseURL = backend.host;
93
- keyEnv = backend.keyEnv ?? "TYPESAFE_API_KEY";
94
- backendPath = backend.path;
95
- }
122
+ const backendName = options.backend ?? DEFAULT_BACKEND;
123
+ const backend = backendConfig(backendName);
124
+ const baseURL = backend.host;
96
125
  if (!apiKey) {
97
- // Backend-specific env var first (e.g. OPENROUTER_API_KEY), then fall back to
98
- // the standard TYPESAFE_API_KEY / stored-key resolution.
99
- if (backendName !== undefined && keyEnv !== "TYPESAFE_API_KEY") {
100
- const fromEnv = process.env[keyEnv]?.trim();
101
- if (fromEnv)
102
- apiKey = fromEnv;
103
- }
104
- if (!apiKey) {
105
- const situation = keySituation();
106
- if (situation.kind === "unusable")
107
- throw new TypeSafeIntegrationError("configuration", situation.reason);
108
- if (situation.kind === "environment" || situation.kind === "stored")
109
- apiKey = situation.key;
110
- }
126
+ // The same resolution that authState() and ensureApiKey() report, so the status line and the request agree.
127
+ const situation = keySituation(backendName);
128
+ if (situation.kind === "unusable")
129
+ throw new TypeSafeIntegrationError("configuration", situation.reason);
130
+ if (situation.kind === "environment" || situation.kind === "stored")
131
+ apiKey = situation.key;
111
132
  }
112
133
  if (!apiKey) {
113
- throw new TypeSafeIntegrationError("configuration", `No API key. Run /typesafe login in Pi, or set ${keyEnv} in the environment.`);
134
+ const how = usesTypesafeKey(backend) ? `Run /typesafe login in Pi, or set ${TYPESAFE_KEY_ENV}` : `Set ${backend.keyEnv}`;
135
+ throw new TypeSafeIntegrationError("configuration", `No API key. ${how} in the environment.`);
114
136
  }
115
137
  const timeout = positiveInteger(options.timeoutMs ?? 15_000, "timeoutMs");
116
138
  const maxInputBytes = positiveInteger(options.maxInputBytes ?? DEFAULT_MAX_INPUT_BYTES, "maxInputBytes");
@@ -122,7 +144,8 @@ export function createTypeSafe(options = {}) {
122
144
  ...(options.maxInputTokensPerDay === undefined ? {} : { maxInputTokensPerDay: positiveInteger(options.maxInputTokensPerDay, "maxInputTokensPerDay") }),
123
145
  ...(options.maxUsdPerDay === undefined ? {} : { maxUsdPerDay: positiveNumber(options.maxUsdPerDay, "maxUsdPerDay") }),
124
146
  }, capsFromEnvironment());
125
- const transport = backendPath ? backendFetch(backendPath, options.fetch) : options.fetch;
147
+ // A backend that serves its own paths gets a transport that rewrites them; the default backend keeps the caller's.
148
+ const transport = backend.path !== undefined || backend.modelsPath !== undefined ? backendFetch(backend, options.fetch) : options.fetch;
126
149
  const model = options.model ?? (backendName === "openrouter" ? "typesafe/jev-1.13" : "jev-latest");
127
150
  if (typeof model !== "string" || !model.trim() || model.length > 100)
128
151
  throw new TypeSafeIntegrationError("configuration", "model must be a nonempty string of at most 100 characters.");
@@ -160,7 +183,8 @@ export function createTypeSafe(options = {}) {
160
183
  const models = await client.models.list(callOptions);
161
184
  if (!Array.isArray(models))
162
185
  throw new TypeSafeIntegrationError("response", "TypeSafe returned an unexpected model list.");
163
- if (!verificationRecorded) {
186
+ // A backend that serves its list publicly accepts any key, so a success there proves nothing about one.
187
+ if (backend.modelsVerifyKey !== false && !verificationRecorded) {
164
188
  verificationRecorded = true;
165
189
  recordAuthVerified();
166
190
  }
@@ -1,8 +1,12 @@
1
+ import type { TypeSafeBackend } from "./backends.js";
1
2
  export type KeySource = "environment" | "stored";
2
3
  /** The complete, never-throwing answer to "which key is in effect". */
3
- export type KeySituation = {
4
+ export type KeySituation =
5
+ /** `keyEnv` names the variable that was read; absent means `TYPESAFE_API_KEY`. */
6
+ {
4
7
  readonly kind: "environment";
5
8
  readonly key: string;
9
+ readonly keyEnv?: string;
6
10
  } | {
7
11
  readonly kind: "stored";
8
12
  readonly key: string;
@@ -26,10 +30,12 @@ export declare function normalizeApiKey(value: unknown): string;
26
30
  */
27
31
  export declare function readStoredApiKey(): string | undefined;
28
32
  /**
29
- * What the environment, the login store, and file permissions add up to right now. Never throws; the "unusable" kind
30
- * carries the user-facing reason (a stored key that other local users can read).
33
+ * What the environment, the login store, and file permissions add up to right now for one judgment backend. Never
34
+ * throws; the "unusable" kind carries the user-facing reason (a stored key that other local users can read).
35
+ * The TypeSafe backend reads `TYPESAFE_API_KEY`, then the login store. Every other backend reads only its own
36
+ * environment variable, because the store holds a TypeSafe key and a login verifies against api.typesafe.ai.
31
37
  */
32
- export declare function keySituation(): KeySituation;
38
+ export declare function keySituation(backend?: TypeSafeBackend): KeySituation;
33
39
  /** Short phrase naming the source; full sentences stay with the caller. */
34
40
  export declare function keySourceLabel(situation: KeySituation): string;
35
41
  /**
@@ -38,7 +44,7 @@ export declare function keySourceLabel(situation: KeySituation): string;
38
44
  * store must not be read. New code should call keySituation() instead: same precedence, never throws, and the
39
45
  * "must not be read" case arrives as `unusable` with the reason.
40
46
  */
41
- export declare function resolveApiKey(): {
47
+ export declare function resolveApiKey(backend?: TypeSafeBackend): {
42
48
  key: string;
43
49
  source: KeySource;
44
50
  } | undefined;
@@ -1,6 +1,7 @@
1
1
  import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
+ import { DEFAULT_BACKEND, TYPESAFE_KEY_ENV, backendConfig, usesTypesafeKey } from "./backends.js";
4
5
  import { TypeSafeIntegrationError } from "./errors.js";
5
6
  /** Mirrors Pi's agent directory rule; every pi-typesafe file lives in this one directory. */
6
7
  export function piTypesafeDir() {
@@ -44,13 +45,19 @@ export function readStoredApiKey() {
44
45
  }
45
46
  }
46
47
  /**
47
- * What the environment, the login store, and file permissions add up to right now. Never throws; the "unusable" kind
48
- * carries the user-facing reason (a stored key that other local users can read).
48
+ * What the environment, the login store, and file permissions add up to right now for one judgment backend. Never
49
+ * throws; the "unusable" kind carries the user-facing reason (a stored key that other local users can read).
50
+ * The TypeSafe backend reads `TYPESAFE_API_KEY`, then the login store. Every other backend reads only its own
51
+ * environment variable, because the store holds a TypeSafe key and a login verifies against api.typesafe.ai.
49
52
  */
50
- export function keySituation() {
51
- const fromEnvironment = process.env.TYPESAFE_API_KEY?.trim();
53
+ export function keySituation(backend = DEFAULT_BACKEND) {
54
+ const config = backendConfig(backend);
55
+ const keyEnv = config.keyEnv ?? TYPESAFE_KEY_ENV;
56
+ const fromEnvironment = process.env[keyEnv]?.trim();
52
57
  if (fromEnvironment)
53
- return { kind: "environment", key: fromEnvironment };
58
+ return { kind: "environment", key: fromEnvironment, keyEnv };
59
+ if (!usesTypesafeKey(config))
60
+ return { kind: "missing" };
54
61
  const path = credentialsPath();
55
62
  try {
56
63
  const key = readStoredApiKey();
@@ -66,7 +73,7 @@ export function keySituation() {
66
73
  /** Short phrase naming the source; full sentences stay with the caller. */
67
74
  export function keySourceLabel(situation) {
68
75
  switch (situation.kind) {
69
- case "environment": return "TYPESAFE_API_KEY";
76
+ case "environment": return situation.keyEnv ?? TYPESAFE_KEY_ENV;
70
77
  case "stored": return "/typesafe login";
71
78
  case "missing": return "no key";
72
79
  case "unusable": return "unusable key";
@@ -78,8 +85,8 @@ export function keySourceLabel(situation) {
78
85
  * store must not be read. New code should call keySituation() instead: same precedence, never throws, and the
79
86
  * "must not be read" case arrives as `unusable` with the reason.
80
87
  */
81
- export function resolveApiKey() {
82
- const situation = keySituation();
88
+ export function resolveApiKey(backend = DEFAULT_BACKEND) {
89
+ const situation = keySituation(backend);
83
90
  if (situation.kind === "unusable")
84
91
  throw new TypeSafeIntegrationError("configuration", situation.reason);
85
92
  return situation.kind === "environment" || situation.kind === "stored" ? { key: situation.key, source: situation.kind } : undefined;
@@ -11,7 +11,7 @@ const sample = {
11
11
  questions: {
12
12
  category: { type: "choice", instructions: "Which team should handle this message?", criteria: { billing: "Charges and payments", technical: "Software failures", other: "None of these" } },
13
13
  urgent: { type: "noul", instructions: "Does the sender request help today?" },
14
- frustration: { type: "score", instructions: "How frustrated does the sender sound?", criteria: ["A neutral request without expressed frustration", "Expressed frustration while remaining civil", "Explicit anger or threats"] },
14
+ frustration: { type: "score", instructions: "How frustrated does the sender sound?", criteria: ["Neutral request", "Frustrated but civil", "Angry or threatening"] },
15
15
  },
16
16
  };
17
17
  function format(result, expanded = false) {
@@ -69,6 +69,9 @@ export default function typesafeExtension(pi) {
69
69
  description: `Evaluate supplied state with independent Choice, Score, and Noul questions in one TypeSafe request. Each question judges the whole state, so when several items are involved, put each item in a named state field (e.g. \`reports.r1\`) and ask one question per item per dimension (e.g. \`r1_owner\`, \`r2_owner\`), naming the field in the instructions; never aggregate several items into one question. ${disclosure} Requires operator opt-in via /typesafe enable or PI_TYPESAFE_ENABLED=1. Limit: 32 questions, ${DEFAULT_MAX_INPUT_BYTES / 1024} KiB JSON, ${DEFAULT_MAX_REQUESTS} attempts per session; no retries.`,
70
70
  promptSnippet: "Ask batched structured questions with TypeSafe (external service; operator opt-in required)",
71
71
  promptGuidelines: [
72
+ // The payload shape is what models get wrong on the first call; the same sample the playground edits is the cheapest way to show it.
73
+ // Every session pays for this line on every tool listing, so the sample stays short.
74
+ `Request shape, all three question kinds in one call: ${JSON.stringify(sample)}`,
72
75
  "Use typesafe_evaluate only for requested semantic judgments, not calculations or exact lookups; send only the relevant permitted data.",
73
76
  "Batch independent typesafe_evaluate questions over the same state; use code or explicit permission rules for actions, never confidence as authorization.",
74
77
  "When typesafe_evaluate judges several items, give each item a named state field and ask one question per item per dimension, naming the field in the instructions; one question over many items returns an unusable blend.",
@@ -1,4 +1,4 @@
1
- export { createTypeSafe, DEFAULT_MAX_REQUESTS, DECISIONS_BACKENDS } from "./client.js";
1
+ export { createTypeSafe, DEFAULT_MAX_REQUESTS, DECISIONS_BACKENDS, DEFAULT_BACKEND } from "./client.js";
2
2
  export type { TypeSafe, TypeSafeOptions, EvaluationOptions, Evaluation, UsageSnapshot, SpendReport, TypeSafeBackend, BackendConfig, } from "./client.js";
3
3
  export { ask, DEFAULT_ASK_TIMEOUT_MS } from "./ask.js";
4
4
  export type { AskAnswer, AskOptions, Judge } from "./ask.js";
@@ -1,4 +1,4 @@
1
- export { createTypeSafe, DEFAULT_MAX_REQUESTS, DECISIONS_BACKENDS } from "./client.js";
1
+ export { createTypeSafe, DEFAULT_MAX_REQUESTS, DECISIONS_BACKENDS, DEFAULT_BACKEND } from "./client.js";
2
2
  export { ask, DEFAULT_ASK_TIMEOUT_MS } from "./ask.js";
3
3
  export { fanOut, DEFAULT_CONCURRENCY, evaluateMany, evaluateAll, chunkEvaluationRequest } from "./batch.js";
4
4
  export { authState, authStatePath, clearAuthState, describeAuth, recordAuthFailure, recordAuthVerified } from "./auth.js";