@pi-in-go/pigpen-pi-typesafe 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CREDITS.md +14 -0
- package/LICENSE +22 -0
- package/README.md +45 -0
- package/extensions/pi-typesafe/branches_test.go +185 -0
- package/extensions/pi-typesafe/command.go +319 -0
- package/extensions/pi-typesafe/export_test.go +9 -0
- package/extensions/pi-typesafe/extension.go +188 -0
- package/extensions/pi-typesafe/extension_test.go +321 -0
- package/extensions/pi-typesafe/fakehost_test.go +548 -0
- package/extensions/pi-typesafe/format.go +191 -0
- package/extensions/pi-typesafe/format_test.go +75 -0
- package/extensions/pi-typesafe/go.mod +10 -0
- package/extensions/pi-typesafe/go.sum +2 -0
- package/extensions/pi-typesafe/go.work +11 -0
- package/extensions/pi-typesafe/harness_test.go +200 -0
- package/extensions/pi-typesafe/ownmodel_test.go +100 -0
- package/extensions/pi-typesafe/review_test.go +134 -0
- package/extensions/pi-typesafe/tool.go +193 -0
- package/extensions/pi-typesafe/twin_test.go +28 -0
- package/libs/pi-typesafe-api/CREDITS.md +14 -0
- package/libs/pi-typesafe-api/LICENSE +22 -0
- package/libs/pi-typesafe-api/README.md +30 -0
- package/libs/pi-typesafe-api/ask.go +62 -0
- package/libs/pi-typesafe-api/ask_test.go +76 -0
- package/libs/pi-typesafe-api/auth.go +249 -0
- package/libs/pi-typesafe-api/auth_test.go +131 -0
- package/libs/pi-typesafe-api/backends.go +336 -0
- package/libs/pi-typesafe-api/backends_test.go +404 -0
- package/libs/pi-typesafe-api/batch.go +202 -0
- package/libs/pi-typesafe-api/batch_test.go +202 -0
- package/libs/pi-typesafe-api/battery_test.go +41 -0
- package/libs/pi-typesafe-api/calibrate.go +354 -0
- package/libs/pi-typesafe-api/calibrate_test.go +186 -0
- package/libs/pi-typesafe-api/client.go +615 -0
- package/libs/pi-typesafe-api/client_test.go +490 -0
- package/libs/pi-typesafe-api/credentials.go +252 -0
- package/libs/pi-typesafe-api/credentials_test.go +216 -0
- package/libs/pi-typesafe-api/doc.go +14 -0
- package/libs/pi-typesafe-api/errors.go +143 -0
- package/libs/pi-typesafe-api/evaluation.go +86 -0
- package/libs/pi-typesafe-api/evaluation_schema.json +264 -0
- package/libs/pi-typesafe-api/gaps_test.go +77 -0
- package/libs/pi-typesafe-api/go.mod +9 -0
- package/libs/pi-typesafe-api/go.sum +2 -0
- package/libs/pi-typesafe-api/helpers_test.go +169 -0
- package/libs/pi-typesafe-api/hostmodel/hostmodel.go +87 -0
- package/libs/pi-typesafe-api/json.go +299 -0
- package/libs/pi-typesafe-api/json_test.go +92 -0
- package/libs/pi-typesafe-api/ownmodel_test.go +79 -0
- package/libs/pi-typesafe-api/package.json +40 -0
- package/libs/pi-typesafe-api/provenance.json +18 -0
- package/libs/pi-typesafe-api/review_test.go +23 -0
- package/libs/pi-typesafe-api/schema.go +473 -0
- package/libs/pi-typesafe-api/schema_test.go +262 -0
- package/libs/pi-typesafe-api/testdata/tools/typebox-messages.mts +5 -0
- package/libs/pi-typesafe-api/testdata/typebox-messages.json +285 -0
- package/libs/pi-typesafe-api/twin_test.go +28 -0
- package/libs/pi-typesafe-api/ui/fakehost_test.go +548 -0
- package/libs/pi-typesafe-api/ui/keyprompt.go +115 -0
- package/libs/pi-typesafe-api/ui/login.go +106 -0
- package/libs/pi-typesafe-api/ui/twin_test.go +28 -0
- package/libs/pi-typesafe-api/ui/ui_test.go +285 -0
- package/libs/pi-typesafe-api/usage.go +366 -0
- package/libs/pi-typesafe-api/usage_test.go +139 -0
- package/libs/typesafe/CONTRACT.md +125 -0
- package/libs/typesafe/CREDITS.md +37 -0
- package/libs/typesafe/LICENSE +23 -0
- package/libs/typesafe/README.md +19 -0
- package/libs/typesafe/go.mod +3 -0
- package/libs/typesafe/libraries/ownmodel/backend_test.go +496 -0
- package/libs/typesafe/libraries/ownmodel/canon.go +190 -0
- package/libs/typesafe/libraries/ownmodel/convert.go +199 -0
- package/libs/typesafe/libraries/ownmodel/doc.go +15 -0
- package/libs/typesafe/libraries/ownmodel/equivalence_test.go +199 -0
- package/libs/typesafe/libraries/ownmodel/helpers_test.go +155 -0
- package/libs/typesafe/libraries/ownmodel/mutation_test.go +31 -0
- package/libs/typesafe/libraries/ownmodel/ownmodel.go +225 -0
- package/libs/typesafe/libraries/ownmodel/plan.go +442 -0
- package/libs/typesafe/libraries/ownmodel/run.go +288 -0
- package/libs/typesafe/libraries/ownmodel/schema_test.go +254 -0
- package/libs/typesafe/libraries/ownmodel/twins_test.go +169 -0
- package/libs/typesafe/libraries/ownmodel/utils_test.go +125 -0
- package/libs/typesafe/libraries/pigmodel/pigmodel.go +264 -0
- package/libs/typesafe/libraries/pigmodel/pigmodel_test.go +410 -0
- package/libs/typesafe/libraries/typesafe/answers.go +268 -0
- package/libs/typesafe/libraries/typesafe/api_response_test.go +113 -0
- package/libs/typesafe/libraries/typesafe/batch.go +80 -0
- package/libs/typesafe/libraries/typesafe/batch_test.go +133 -0
- package/libs/typesafe/libraries/typesafe/bench_test.go +71 -0
- package/libs/typesafe/libraries/typesafe/client.go +561 -0
- package/libs/typesafe/libraries/typesafe/client_test.go +495 -0
- package/libs/typesafe/libraries/typesafe/crosscheck_test.go +464 -0
- package/libs/typesafe/libraries/typesafe/crosscheck_workflowevals_test.go +219 -0
- package/libs/typesafe/libraries/typesafe/doc.go +27 -0
- package/libs/typesafe/libraries/typesafe/entry.go +142 -0
- package/libs/typesafe/libraries/typesafe/env.go +11 -0
- package/libs/typesafe/libraries/typesafe/errors.go +310 -0
- package/libs/typesafe/libraries/typesafe/errors_test.go +175 -0
- package/libs/typesafe/libraries/typesafe/helpers_test.go +294 -0
- package/libs/typesafe/libraries/typesafe/live_test.go +96 -0
- package/libs/typesafe/libraries/typesafe/logging.go +160 -0
- package/libs/typesafe/libraries/typesafe/logging_test.go +259 -0
- package/libs/typesafe/libraries/typesafe/marshal_test.go +112 -0
- package/libs/typesafe/libraries/typesafe/mutation_test.go +39 -0
- package/libs/typesafe/libraries/typesafe/questions.go +490 -0
- package/libs/typesafe/libraries/typesafe/questions_test.go +166 -0
- package/libs/typesafe/libraries/typesafe/regressions_test.go +159 -0
- package/libs/typesafe/libraries/typesafe/reliability_test.go +649 -0
- package/libs/typesafe/libraries/typesafe/retry.go +350 -0
- package/libs/typesafe/libraries/typesafe/retry_test.go +297 -0
- package/libs/typesafe/libraries/typesafe/runtime_test.go +26 -0
- package/libs/typesafe/libraries/typesafe/transport_test.go +163 -0
- package/libs/typesafe/libraries/typesafe/twins_test.go +127 -0
- package/libs/typesafe/libraries/typesafe/types_test.go +165 -0
- package/libs/typesafe/libraries/typesafe/version.go +10 -0
- package/libs/typesafe/package.json +37 -0
- package/libs/typesafe/provenance.json +49 -0
- package/package.json +42 -0
- package/port/PORT.md +98 -0
- package/port/accepted-gaps.json +3 -0
- package/port/golden/enable-confirm.jsonl +11 -0
- package/port/golden/enable-decline.jsonl +20 -0
- package/port/golden/enable-missing-key.jsonl +4 -0
- package/port/golden/login-shadow.jsonl +4 -0
- package/port/golden/logout-env-key.jsonl +6 -0
- package/port/golden/playground-cancel.jsonl +4 -0
- package/port/golden/playground-invalid-json.jsonl +5 -0
- package/port/golden/playground-invalid-questions.jsonl +5 -0
- package/port/golden/status-env-key.jsonl +6 -0
- package/port/golden/status-no-key.jsonl +6 -0
- package/port/golden/tool-disabled.jsonl +18 -0
- package/port/golden/trailing-words.jsonl +10 -0
- package/port/library-mutations.py +44 -0
- package/port/mutations.json +302 -0
- package/port/oracle/.env.example +4 -0
- package/port/oracle/CHANGELOG.md +91 -0
- package/port/oracle/CONTRIBUTING.md +35 -0
- package/port/oracle/LICENSE +21 -0
- package/port/oracle/README.md +159 -0
- package/port/oracle/docs/api.md +143 -0
- package/port/oracle/docs/ci-cd.md +97 -0
- package/port/oracle/examples/decision-extension.ts +41 -0
- package/port/oracle/extensions/index.js +2 -0
- package/port/oracle/package.json +89 -0
- package/port/oracle/scripts/dev-pi.mjs +23 -0
- package/port/oracle/scripts/live-smoke.mjs +35 -0
- package/port/oracle/src/ask.ts +42 -0
- package/port/oracle/src/auth.ts +171 -0
- package/port/oracle/src/backends.ts +196 -0
- package/port/oracle/src/batch.ts +170 -0
- package/port/oracle/src/calibrate.ts +237 -0
- package/port/oracle/src/client.ts +310 -0
- package/port/oracle/src/credentials.ts +136 -0
- package/port/oracle/src/errors.ts +53 -0
- package/port/oracle/src/extension.ts +204 -0
- package/port/oracle/src/index.ts +31 -0
- package/port/oracle/src/key-prompt.ts +51 -0
- package/port/oracle/src/login.ts +60 -0
- package/port/oracle/src/schema.ts +158 -0
- package/port/oracle/src/ui.ts +4 -0
- package/port/oracle/src/usage.ts +258 -0
- package/port/oracle/tests/ask.test.ts +63 -0
- package/port/oracle/tests/auth.test.ts +141 -0
- package/port/oracle/tests/backends.test.ts +380 -0
- package/port/oracle/tests/batch.test.ts +156 -0
- package/port/oracle/tests/calibrate.test.ts +144 -0
- package/port/oracle/tests/client.test.ts +499 -0
- package/port/oracle/tests/credentials.test.ts +144 -0
- package/port/oracle/tests/extension.test.ts +276 -0
- package/port/oracle/tests/key-prompt.test.ts +47 -0
- package/port/oracle/tests/login.test.ts +101 -0
- package/port/oracle/tests/schema.test.ts +85 -0
- package/port/oracle/tests/usage.test.ts +106 -0
- package/port/oracle/tsconfig.build.json +10 -0
- package/port/oracle/tsconfig.json +14 -0
- package/port/scenarios/enable-confirm.json +5 -0
- package/port/scenarios/enable-decline.json +3 -0
- package/port/scenarios/enable-missing-key.json +2 -0
- package/port/scenarios/login-shadow.json +2 -0
- package/port/scenarios/logout-env-key.json +3 -0
- package/port/scenarios/playground-cancel.json +2 -0
- package/port/scenarios/playground-invalid-json.json +2 -0
- package/port/scenarios/playground-invalid-questions.json +2 -0
- package/port/scenarios/status-env-key.json +3 -0
- package/port/scenarios/status-no-key.json +3 -0
- package/port/scenarios/tool-disabled.json +2 -0
- package/port/scenarios/trailing-words.json +5 -0
- package/port/upstream-tests.json +160 -0
- package/provenance.json +18 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { DEFAULT_BACKEND, TYPESAFE_KEY_ENV, resolveBackend, usesTypesafeKey } from "./backends.js";
|
|
5
|
+
import type { BackendSpec } from "./backends.js";
|
|
6
|
+
import { TypeSafeIntegrationError } from "./errors.js";
|
|
7
|
+
|
|
8
|
+
export type KeySource = "environment" | "stored";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The complete answer to "which key is in effect" for a valid backend: it never throws. An invalid backend — an
|
|
12
|
+
* unknown name or an endpoint object that fails validation — throws the same `configuration` error as resolveBackend.
|
|
13
|
+
*/
|
|
14
|
+
export type KeySituation =
|
|
15
|
+
/** `keyEnv` names the variable that was read; absent means `TYPESAFE_API_KEY`. */
|
|
16
|
+
| { readonly kind: "environment"; readonly key: string; readonly keyEnv?: string }
|
|
17
|
+
| { readonly kind: "stored"; readonly key: string; readonly path: string }
|
|
18
|
+
| { readonly kind: "missing" }
|
|
19
|
+
| { readonly kind: "unusable"; readonly path: string; readonly reason: string };
|
|
20
|
+
|
|
21
|
+
/** Mirrors Pi's agent directory rule; every pi-typesafe file lives in this one directory. */
|
|
22
|
+
export function piTypesafeDir(): string {
|
|
23
|
+
const configured = process.env.PI_CODING_AGENT_DIR?.trim();
|
|
24
|
+
const agentDir = configured
|
|
25
|
+
? (configured === "~" || configured.startsWith("~/") ? join(homedir(), configured.slice(1)) : configured)
|
|
26
|
+
: join(homedir(), ".pi", "agent");
|
|
27
|
+
return join(agentDir, "pi-typesafe");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Where the key lives, next to Pi's own auth.json. */
|
|
31
|
+
export function credentialsPath(): string {
|
|
32
|
+
return join(piTypesafeDir(), "auth.json");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Accepts the key only when it is a plausible token; never logs or echoes the value. */
|
|
36
|
+
export function normalizeApiKey(value: unknown): string {
|
|
37
|
+
const key = typeof value === "string" ? value.trim() : "";
|
|
38
|
+
if (key.length < 16 || key.length > 512 || /\s/.test(key) || /[^\x21-\x7e]/.test(key)) {
|
|
39
|
+
throw new TypeSafeIntegrationError("validation", "That does not look like a TypeSafe API key. Copy the complete key from console.typesafe.ai and try again; nothing was saved.");
|
|
40
|
+
}
|
|
41
|
+
return key;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The stored key, or `undefined` when the file is missing, unreadable, or holds no usable value. Throws
|
|
46
|
+
* `configuration` when the file is readable by other users; keySituation() reports that case as `unusable` instead.
|
|
47
|
+
*/
|
|
48
|
+
export function readStoredApiKey(): string | undefined {
|
|
49
|
+
const path = credentialsPath();
|
|
50
|
+
try {
|
|
51
|
+
if (process.platform !== "win32" && (statSync(path).mode & 0o077) !== 0) {
|
|
52
|
+
// Refuse to use a key other local users can read; the user must fix permissions or log in again.
|
|
53
|
+
throw new TypeSafeIntegrationError("configuration", `Refusing to read ${path}: it is readable by other users. Run chmod 600 on it, or run /typesafe logout and /typesafe login.`);
|
|
54
|
+
}
|
|
55
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
56
|
+
const key = parsed && typeof parsed === "object" ? (parsed as { apiKey?: unknown }).apiKey : undefined;
|
|
57
|
+
return typeof key === "string" && key.trim() ? key.trim() : undefined;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error instanceof TypeSafeIntegrationError) throw error;
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* What the environment, the login store, and file permissions add up to right now for one judgment backend. A valid
|
|
66
|
+
* backend never throws: the "unusable" kind carries the user-facing reason (a stored key that other local users can
|
|
67
|
+
* read). An invalid backend — an unknown name or an endpoint object that fails validation — throws the same
|
|
68
|
+
* `configuration` error as resolveBackend(), so validate a user-supplied endpoint with resolveBackend() first.
|
|
69
|
+
* The TypeSafe backend reads `TYPESAFE_API_KEY`, then the login store. Every other backend reads only its own
|
|
70
|
+
* environment variable, because the store holds a TypeSafe key and a login verifies against api.typesafe.ai.
|
|
71
|
+
*/
|
|
72
|
+
export function keySituation(backend: BackendSpec = DEFAULT_BACKEND): KeySituation {
|
|
73
|
+
const config = resolveBackend(backend);
|
|
74
|
+
const keyEnv = config.keyEnv;
|
|
75
|
+
const fromEnvironment = process.env[keyEnv]?.trim();
|
|
76
|
+
if (fromEnvironment) return { kind: "environment", key: fromEnvironment, keyEnv };
|
|
77
|
+
if (!usesTypesafeKey(config)) return { kind: "missing" };
|
|
78
|
+
const path = credentialsPath();
|
|
79
|
+
try {
|
|
80
|
+
const key = readStoredApiKey();
|
|
81
|
+
return key ? { kind: "stored", key, path } : { kind: "missing" };
|
|
82
|
+
} catch (error) {
|
|
83
|
+
// readStoredApiKey only throws for a store this process must not use; report it instead of failing the caller.
|
|
84
|
+
if (error instanceof TypeSafeIntegrationError && error.code === "configuration") return { kind: "unusable", path, reason: error.message };
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Short phrase naming the source; full sentences stay with the caller. */
|
|
90
|
+
export function keySourceLabel(situation: KeySituation): string {
|
|
91
|
+
switch (situation.kind) {
|
|
92
|
+
case "environment": return situation.keyEnv ?? TYPESAFE_KEY_ENV;
|
|
93
|
+
case "stored": return "/typesafe login";
|
|
94
|
+
case "missing": return "no key";
|
|
95
|
+
case "unusable": return "unusable key";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The pre-0.4.0 key interface, frozen for existing callers: environment first so CI and scripts stay explicit, the
|
|
101
|
+
* stored key as the interactive default, `undefined` when no key is configured, and a `configuration` error when a
|
|
102
|
+
* store must not be read. New code should call keySituation() instead: same precedence, never throws for a valid
|
|
103
|
+
* backend (an invalid one throws resolveBackend's `configuration` error), and the "must not be read" case arrives as
|
|
104
|
+
* `unusable` with the reason.
|
|
105
|
+
*/
|
|
106
|
+
export function resolveApiKey(backend: BackendSpec = DEFAULT_BACKEND): { key: string; source: KeySource } | undefined {
|
|
107
|
+
const situation = keySituation(backend);
|
|
108
|
+
if (situation.kind === "unusable") throw new TypeSafeIntegrationError("configuration", situation.reason);
|
|
109
|
+
return situation.kind === "environment" || situation.kind === "stored" ? { key: situation.key, source: situation.kind } : undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function storeApiKey(value: unknown): string {
|
|
113
|
+
const key = normalizeApiKey(value);
|
|
114
|
+
const path = credentialsPath();
|
|
115
|
+
try {
|
|
116
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
117
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
118
|
+
writeFileSync(temporary, `${JSON.stringify({ apiKey: key }, null, 2)}\n`, { mode: 0o600, flag: "w" });
|
|
119
|
+
chmodSync(temporary, 0o600);
|
|
120
|
+
renameSync(temporary, path);
|
|
121
|
+
} catch {
|
|
122
|
+
throw new TypeSafeIntegrationError("configuration", `Could not write ${path}. Check directory permissions, or set TYPESAFE_API_KEY in the environment instead.`);
|
|
123
|
+
}
|
|
124
|
+
return path;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function clearStoredApiKey(): boolean {
|
|
128
|
+
const path = credentialsPath();
|
|
129
|
+
try {
|
|
130
|
+
statSync(path);
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
rmSync(path, { force: true });
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { APIError, APIConnectionError, APITimeoutError, APIUserAbortError } from "@typesafe-ai/sdk";
|
|
2
|
+
import { DECISIONS_BACKENDS, TYPESAFE_KEY_ENV, backendConfig } from "./backends.js";
|
|
3
|
+
import type { BackendConfig, BackendSpec } from "./backends.js";
|
|
4
|
+
|
|
5
|
+
export type IntegrationErrorCode = "configuration" | "validation" | "budget" | "aborted" | "timeout" | "http" | "connection" | "response";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Safe to display: never contains upstream bodies, keys, or submitted state. The only header value a message can
|
|
9
|
+
* quote is a numeric Retry-After count in seconds, which cannot carry a secret.
|
|
10
|
+
*/
|
|
11
|
+
export class TypeSafeIntegrationError extends Error {
|
|
12
|
+
override readonly name = "TypeSafeIntegrationError";
|
|
13
|
+
constructor(
|
|
14
|
+
readonly code: IntegrationErrorCode,
|
|
15
|
+
message: string,
|
|
16
|
+
readonly status?: number,
|
|
17
|
+
) {
|
|
18
|
+
super(message);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A numeric Retry-After delay in seconds; dates, blanks, and anything else stay out of the message. */
|
|
23
|
+
function retryAfterSeconds(error: APIError): number | undefined {
|
|
24
|
+
const raw = error.headers?.get("retry-after")?.trim();
|
|
25
|
+
if (!raw) return undefined;
|
|
26
|
+
const seconds = Number(raw);
|
|
27
|
+
return Number.isSafeInteger(seconds) && seconds >= 0 ? seconds : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Classify an error into a message safe to display. `backend` names the key variable the 401 advice tells the user
|
|
32
|
+
* to check and selects the 402 wording; omitting it keeps the one-argument call and assumes the default TypeSafe key.
|
|
33
|
+
*/
|
|
34
|
+
export function safeError(error: unknown, backend?: BackendSpec | BackendConfig): TypeSafeIntegrationError {
|
|
35
|
+
if (error instanceof TypeSafeIntegrationError) return error;
|
|
36
|
+
if (error instanceof APIUserAbortError) return new TypeSafeIntegrationError("aborted", "TypeSafe request cancelled; an already submitted request may still be billed.");
|
|
37
|
+
if (error instanceof APITimeoutError) return new TypeSafeIntegrationError("timeout", "TypeSafe request timed out; it was not retried and may still be billed.");
|
|
38
|
+
if (error instanceof APIError) {
|
|
39
|
+
const config = backend === undefined ? undefined : typeof backend === "string" ? backendConfig(backend) : backend;
|
|
40
|
+
const keyEnv = config?.keyEnv ?? TYPESAFE_KEY_ENV;
|
|
41
|
+
const openrouter = config !== undefined && config.host === DECISIONS_BACKENDS.openrouter.host;
|
|
42
|
+
const retry = error.status === 429 ? retryAfterSeconds(error) : undefined;
|
|
43
|
+
const advice = error.status === 401 ? `Check ${keyEnv}.`
|
|
44
|
+
: error.status === 402 ? (openrouter ? "Insufficient credits. Add credits at https://openrouter.ai/credits." : "Check your account balance.")
|
|
45
|
+
: error.status === 403 ? "Check your account access and model permissions."
|
|
46
|
+
: error.status === 429 ? `Check your account quota and try again later.${retry === undefined ? "" : ` Retry after ${retry} seconds.`}`
|
|
47
|
+
: error.status === 400 || error.status === 422 ? "Check the question format and model limits."
|
|
48
|
+
: "Try again later or check the service status.";
|
|
49
|
+
return new TypeSafeIntegrationError("http", `TypeSafe returned HTTP ${error.status}. ${advice} No automatic retry was made.`, error.status);
|
|
50
|
+
}
|
|
51
|
+
if (error instanceof APIConnectionError) return new TypeSafeIntegrationError("connection", "Could not complete the TypeSafe connection. No automatic retry was made.");
|
|
52
|
+
return new TypeSafeIntegrationError("response", "TypeSafe returned an unreadable or unexpected response.");
|
|
53
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Static } from "typebox";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import type { Questions } from "@typesafe-ai/sdk";
|
|
5
|
+
import { DEFAULT_BACKEND, defaultModelId } from "./backends.js";
|
|
6
|
+
import { createTypeSafe, DEFAULT_MAX_REQUESTS } from "./client.js";
|
|
7
|
+
import type { Evaluation, TypeSafe } from "./client.js";
|
|
8
|
+
import { authState, clearAuthState, describeAuth } from "./auth.js";
|
|
9
|
+
import { clearStoredApiKey, credentialsPath, keySituation, keySourceLabel } from "./credentials.js";
|
|
10
|
+
import { TypeSafeIntegrationError, safeError } from "./errors.js";
|
|
11
|
+
import { loginWithPrompt } from "./login.js";
|
|
12
|
+
import { DEFAULT_MAX_INPUT_BYTES, evaluationSchema, normalizeEvaluationRequest, prepareEvaluationRequest } from "./schema.js";
|
|
13
|
+
|
|
14
|
+
const disclosure = "Submitted state and questions will be sent to api.typesafe.ai and may incur charges. Do not include secrets. The extension does not collect files or conversation history. Results are model judgments, not proof or authorization.";
|
|
15
|
+
const sample = {
|
|
16
|
+
state: { message: "I was charged twice for my subscription. Please help today." },
|
|
17
|
+
questions: {
|
|
18
|
+
category: { type: "choice", instructions: "Which team should handle this message?", criteria: { billing: "Charges and payments", technical: "Software failures", other: "None of these" } },
|
|
19
|
+
urgent: { type: "noul", instructions: "Does the sender request help today?" },
|
|
20
|
+
frustration: { type: "score", instructions: "How frustrated does the sender sound?", criteria: ["Neutral request", "Frustrated but civil", "Angry or threatening"] },
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function format(result: Evaluation<Questions>, expanded = false): string {
|
|
25
|
+
const lines = [`TypeSafe · ${JSON.stringify(result.model)} · ${result.elapsedMs} ms`];
|
|
26
|
+
for (const [id, answer] of Object.entries(result.answers)) {
|
|
27
|
+
const label = JSON.stringify(id);
|
|
28
|
+
if (answer.type === "noul") lines.push(`${label}: P(yes) = ${answer.noul.toFixed(3)}`);
|
|
29
|
+
else if (answer.type === "choice") lines.push(`${label}: ${JSON.stringify(answer.choice)} · confidence ${answer.confidence.toFixed(3)}`);
|
|
30
|
+
else lines.push(`${label}: ${answer.score.toFixed(3)} · confidence ${answer.confidence.toFixed(3)}`);
|
|
31
|
+
if (expanded && answer.type !== "noul") lines.push(` ${JSON.stringify(answer.probabilities)}`);
|
|
32
|
+
}
|
|
33
|
+
lines.push(`${result.usage.input_tokens} input / ${result.usage.output_tokens} output tokens`);
|
|
34
|
+
lines.push("Confidence is distribution concentration, not proof of correctness.");
|
|
35
|
+
return lines.join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Native Pi registration; importing the root library does not load this module. */
|
|
39
|
+
export default function typesafeExtension(pi: ExtensionAPI): void {
|
|
40
|
+
let enabled = process.env.PI_TYPESAFE_ENABLED === "1";
|
|
41
|
+
let client: TypeSafe | undefined;
|
|
42
|
+
// One callout per distinct degradation per session: a long run must not bury the reason in repeated notices.
|
|
43
|
+
let calledOut: string | undefined;
|
|
44
|
+
const getClient = () => client ??= createTypeSafe();
|
|
45
|
+
const callOut = (ctx: ExtensionContext | undefined, key: string, text: string) => {
|
|
46
|
+
if (calledOut === key) return;
|
|
47
|
+
calledOut = key;
|
|
48
|
+
try {
|
|
49
|
+
if (ctx?.hasUI) ctx.ui.notify(text, "warning");
|
|
50
|
+
else pi.sendMessage({ customType: "typesafe-status", content: text, display: true });
|
|
51
|
+
} catch {
|
|
52
|
+
// Reporting must never replace the failure it describes, and a headless run may have no message channel.
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
57
|
+
enabled = process.env.PI_TYPESAFE_ENABLED === "1";
|
|
58
|
+
client = undefined;
|
|
59
|
+
calledOut = undefined;
|
|
60
|
+
// An enabled extension with no usable key used to look exactly like a working one. Say it at startup; an
|
|
61
|
+
// unverified-but-present key stays quiet, because the first request is what proves it.
|
|
62
|
+
const auth = describeAuth(authState());
|
|
63
|
+
if (enabled && auth.level === "error") callOut(ctx, `start:${auth.level}`, `TypeSafe is enabled but judgments are skipped. ${auth.text}`);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
pi.registerEntryRenderer<Evaluation<Questions>>("typesafe-result", (entry, { expanded }) => new Text(entry.data ? format(entry.data, expanded) : "TypeSafe · no result", 0, 0));
|
|
67
|
+
|
|
68
|
+
pi.registerTool({
|
|
69
|
+
name: "typesafe_evaluate",
|
|
70
|
+
label: "TypeSafe",
|
|
71
|
+
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.`,
|
|
72
|
+
promptSnippet: "Ask batched structured questions with TypeSafe (external service; operator opt-in required)",
|
|
73
|
+
promptGuidelines: [
|
|
74
|
+
// 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.
|
|
75
|
+
// Every session pays for this line on every tool listing, so the sample stays short.
|
|
76
|
+
`Request shape, all three question kinds in one call: ${JSON.stringify(sample)}`,
|
|
77
|
+
"Use typesafe_evaluate only for requested semantic judgments, not calculations or exact lookups; send only the relevant permitted data.",
|
|
78
|
+
"Batch independent typesafe_evaluate questions over the same state; use code or explicit permission rules for actions, never confidence as authorization.",
|
|
79
|
+
"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.",
|
|
80
|
+
"Report typesafe_evaluate answers as the model's judgments with their probabilities; do not replace them with your own guesses, and say when an answer is uncertain.",
|
|
81
|
+
],
|
|
82
|
+
parameters: evaluationSchema,
|
|
83
|
+
// Pi validates against `parameters` after this hook; the cast only names the schema's type.
|
|
84
|
+
prepareArguments: args => normalizeEvaluationRequest(args) as Static<typeof evaluationSchema>,
|
|
85
|
+
async execute(_id, params, signal, _onUpdate, ctx): Promise<AgentToolResult<Evaluation<Questions>>> {
|
|
86
|
+
if (!enabled) throw new TypeSafeIntegrationError("configuration", "TypeSafe is disabled. Ask the operator to run /typesafe enable; do not enable it by editing configuration or environment files.");
|
|
87
|
+
// The tool admits through the same rule as the library; evaluate() re-runs it idempotently.
|
|
88
|
+
const request = prepareEvaluationRequest(params);
|
|
89
|
+
try {
|
|
90
|
+
const result = await getClient().evaluate(request, signal ? { signal } : {});
|
|
91
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
|
|
92
|
+
} catch (error) {
|
|
93
|
+
const safe = safeError(error);
|
|
94
|
+
// Authentication degradation is louder than a single failed call: it means every later judgment is skipped.
|
|
95
|
+
const rejected = safe.code === "http" && (safe.status === 401 || safe.status === 403);
|
|
96
|
+
if (rejected || safe.code === "configuration") {
|
|
97
|
+
callOut(ctx, `run:${safe.code}:${safe.status ?? ""}`, `TypeSafe is not authenticated (${safe.message}) Judgments will fail until the key is fixed.`);
|
|
98
|
+
}
|
|
99
|
+
throw safe;
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
renderCall(args) {
|
|
103
|
+
return new Text(`TypeSafe · ${Object.keys(args.questions ?? {}).length} questions · external request`, 0, 0);
|
|
104
|
+
},
|
|
105
|
+
renderResult(result, { expanded, isPartial }) {
|
|
106
|
+
if (isPartial) return new Text("TypeSafe · waiting for response", 0, 0);
|
|
107
|
+
if (!result.details?.answers) return new Text(result.content.filter(part => part.type === "text").map(part => part.text).join("\n"), 0, 0);
|
|
108
|
+
return new Text(format(result.details, expanded), 0, 0);
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const actions = ["login", "logout", "setup", "status", "enable", "disable", "test", "playground"];
|
|
113
|
+
pi.registerCommand("typesafe", {
|
|
114
|
+
description: "TypeSafe login, consent, usage, sample test, and JSON playground",
|
|
115
|
+
getArgumentCompletions(prefix) {
|
|
116
|
+
const matches = actions.filter(action => action.startsWith(prefix)).map(action => ({ value: action, label: action }));
|
|
117
|
+
return matches.length ? matches : null;
|
|
118
|
+
},
|
|
119
|
+
async handler(args, ctx) {
|
|
120
|
+
const action = args.trim() || "status";
|
|
121
|
+
const report = (text: string, level: "info" | "warning" | "error" = "info") => {
|
|
122
|
+
if (ctx.hasUI) ctx.ui.notify(text, level);
|
|
123
|
+
else pi.sendMessage({ customType: "typesafe-status", content: text, display: true });
|
|
124
|
+
};
|
|
125
|
+
try {
|
|
126
|
+
if (action === "status") {
|
|
127
|
+
const spend = client?.getSpend();
|
|
128
|
+
const auth = describeAuth(authState());
|
|
129
|
+
const session = spend
|
|
130
|
+
? `Session ${spend.session.requestsStarted}/${DEFAULT_MAX_REQUESTS} attempts, ${spend.session.requestsSucceeded} successful, ${spend.session.requestsFailed} failed, ${spend.session.inputTokens} input tokens (~$${spend.session.estimatedUsd.toFixed(4)}).`
|
|
131
|
+
: `Session 0/${DEFAULT_MAX_REQUESTS} attempts; no client yet in this session.`;
|
|
132
|
+
const today = spend
|
|
133
|
+
? `Today ${spend.today.requestsStarted} requests (${spend.today.requestsSucceeded} ok, ${spend.today.requestsFailed} failed), ${spend.today.inputTokens} input tokens, ~$${spend.today.estimatedUsd.toFixed(4)}.`
|
|
134
|
+
: "";
|
|
135
|
+
const blocked = spend?.blocked ? ` Cap reached: ${spend.blocked.cap} ${spend.blocked.used}/${spend.blocked.limit} on ${spend.blocked.day}; no request will be submitted until the local day rolls over.` : "";
|
|
136
|
+
report(`TypeSafe: ${enabled ? "enabled" : "disabled"}. ${auth.text} ${session} ${today}${blocked} Model: ${defaultModelId(DEFAULT_BACKEND)}. Session limits reset on session start/reload; daily counters persist and caps come from client options or PI_TYPESAFE_MAX_* environment variables. ${disclosure}`, auth.level === "error" && enabled ? "warning" : "info");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (action === "logout") {
|
|
140
|
+
const removed = clearStoredApiKey();
|
|
141
|
+
clearAuthState();
|
|
142
|
+
client = undefined;
|
|
143
|
+
enabled = false;
|
|
144
|
+
report(removed ? `Removed the stored key at ${credentialsPath()}. TypeSafe is disabled.` : "No stored key to remove." + (process.env.TYPESAFE_API_KEY?.trim() ? " TYPESAFE_API_KEY is still set in the environment." : ""));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (action === "disable") {
|
|
148
|
+
enabled = false;
|
|
149
|
+
report("TypeSafe disabled for future agent calls. In-flight requests are not cancelled.");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (!actions.includes(action)) {
|
|
153
|
+
report(`Usage: /typesafe ${actions.join(" | ")}`, "warning");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!ctx.hasUI) {
|
|
157
|
+
report("This command needs interactive Pi. For headless tool use, explicitly set PI_TYPESAFE_ENABLED=1 and TYPESAFE_API_KEY before launching Pi.", "warning");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const situation = keySituation();
|
|
161
|
+
if (action === "login" || (action === "setup" && situation.kind === "missing")) {
|
|
162
|
+
if (process.env.TYPESAFE_API_KEY?.trim()) {
|
|
163
|
+
report("TYPESAFE_API_KEY is set in the environment and takes precedence over a stored key. Unset it before using /typesafe login.", "warning");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const login = await loginWithPrompt(ctx);
|
|
167
|
+
if (login === undefined) { report("Login cancelled; nothing was saved."); return; }
|
|
168
|
+
client = undefined;
|
|
169
|
+
report(`Key verified (${login.models} model${login.models === 1 ? "" : "s"} available) and saved to ${login.path} with owner-only permissions. Run /typesafe enable to allow agent tool calls.`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (action === "setup") {
|
|
173
|
+
const current = situation.kind === "environment" || situation.kind === "stored" ? `configured via ${keySourceLabel(situation)}`
|
|
174
|
+
: situation.kind === "unusable" ? `unusable — ${situation.reason}`
|
|
175
|
+
: "missing";
|
|
176
|
+
report(`Key ${current}. Run /typesafe test for one sample request or /typesafe enable to allow agent tool calls.`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (action === "enable") {
|
|
180
|
+
if (situation.kind === "missing") { report("Run /typesafe login first: no API key is configured.", "warning"); return; }
|
|
181
|
+
if (situation.kind === "unusable") { report(`The stored key cannot be used. ${situation.reason}`, "warning"); return; }
|
|
182
|
+
if (await ctx.ui.confirm("Enable TypeSafe for this session?", disclosure)) {
|
|
183
|
+
enabled = true;
|
|
184
|
+
report(`TypeSafe enabled. Up to ${DEFAULT_MAX_REQUESTS} attempts in this session; /typesafe disable stops future agent calls.`);
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
let request = sample;
|
|
189
|
+
if (action === "playground") {
|
|
190
|
+
const text = await ctx.ui.editor("TypeSafe request JSON · edit state and questions", JSON.stringify(sample, null, 2));
|
|
191
|
+
if (text === undefined) return;
|
|
192
|
+
try { request = JSON.parse(text); } catch { report("Invalid JSON. Keep quoted strings on one line; nothing was sent.", "error"); return; }
|
|
193
|
+
}
|
|
194
|
+
const validated = prepareEvaluationRequest(request);
|
|
195
|
+
if (!await ctx.ui.confirm("Send this TypeSafe request?", disclosure)) return;
|
|
196
|
+
const result = await getClient().evaluate(validated);
|
|
197
|
+
// Playground results stay out of LLM context; the agent tool returns its own results normally.
|
|
198
|
+
pi.appendEntry("typesafe-result", result);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
report(safeError(error).message, "error");
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export { createTypeSafe, backendHost, resolveBackend, DEFAULT_MAX_REQUESTS, DECISIONS_BACKENDS, DEFAULT_BACKEND } from "./client.js";
|
|
2
|
+
export type {
|
|
3
|
+
TypeSafe, TypeSafeOptions, EvaluationOptions, Evaluation, UsageSnapshot, SpendReport,
|
|
4
|
+
TypeSafeBackend, BackendConfig, BackendEndpoint, BackendSpec, ResolvedBackend,
|
|
5
|
+
} from "./client.js";
|
|
6
|
+
export { ask, DEFAULT_ASK_TIMEOUT_MS } from "./ask.js";
|
|
7
|
+
export type { AskAnswer, AskOptions, Judge } from "./ask.js";
|
|
8
|
+
export { fanOut, DEFAULT_CONCURRENCY, evaluateMany, evaluateAll, chunkEvaluationRequest } from "./batch.js";
|
|
9
|
+
export type { BatchEvaluation, BatchOptions, FanOutOptions, Settled } from "./batch.js";
|
|
10
|
+
export { authState, authStatePath, clearAuthState, describeAuth, recordAuthFailure, recordAuthVerified } from "./auth.js";
|
|
11
|
+
export type { AuthFailure, AuthReport, AuthState } from "./auth.js";
|
|
12
|
+
export {
|
|
13
|
+
clearStoredApiKey, credentialsPath, keySituation, keySourceLabel, normalizeApiKey, piTypesafeDir, resolveApiKey, storeApiKey,
|
|
14
|
+
} from "./credentials.js";
|
|
15
|
+
export type { KeySituation, KeySource } from "./credentials.js";
|
|
16
|
+
export {
|
|
17
|
+
capsFromEnvironment, DEFAULT_USD_PER_MTOK, emptyTotals, estimateUsd, localDay, mergeCaps, openUsageLedger, usagePath,
|
|
18
|
+
} from "./usage.js";
|
|
19
|
+
export type { BlockedCap, SpendCaps, UsageLedger, UsageLedgerOptions, UsageReport, UsageTotals } from "./usage.js";
|
|
20
|
+
export { TypeSafeIntegrationError } from "./errors.js";
|
|
21
|
+
export type { IntegrationErrorCode } from "./errors.js";
|
|
22
|
+
export {
|
|
23
|
+
DEFAULT_MAX_INPUT_BYTES, DEFAULT_MAX_QUESTIONS, evaluationSchema, normalizeEvaluationRequest, parseEvaluationRequest, prepareEvaluationRequest,
|
|
24
|
+
} from "./schema.js";
|
|
25
|
+
export type { PrepareEvaluationOptions } from "./schema.js";
|
|
26
|
+
export { choice, noul, score } from "@typesafe-ai/sdk";
|
|
27
|
+
export type {
|
|
28
|
+
Questions, Question, SystemOneRequest, SystemOneResult, EntryType, JsonValue,
|
|
29
|
+
ChoiceQuestion, ChoiceResponse, NoulQuestion, NoulResponse,
|
|
30
|
+
ScoreQuestion, ScoreResponse, Usage,
|
|
31
|
+
} from "@typesafe-ai/sdk";
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Container, CURSOR_MARKER, type Focusable, Input, Key, matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
/** Single-line input that renders bullets instead of the typed value. */
|
|
5
|
+
class SecretInput extends Input {
|
|
6
|
+
override render(width: number): string[] {
|
|
7
|
+
const length = [...this.getValue()].length;
|
|
8
|
+
const bullets = "•".repeat(Math.min(length, Math.max(0, width - 2)));
|
|
9
|
+
const cursor = this.focused ? `${CURSOR_MARKER}\x1b[7m \x1b[27m` : "";
|
|
10
|
+
return [truncateToWidth(bullets + cursor, width, "")];
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class KeyPrompt extends Container implements Focusable {
|
|
15
|
+
private readonly input = new SecretInput();
|
|
16
|
+
private isFocused = false;
|
|
17
|
+
|
|
18
|
+
constructor(theme: { fg(color: string, text: string): string; bold(text: string): string }, done: (value: string | undefined) => void) {
|
|
19
|
+
super();
|
|
20
|
+
this.addChild(new Text(theme.fg("accent", theme.bold("TypeSafe API key")), 1, 0));
|
|
21
|
+
this.addChild(new Text(theme.fg("muted", "Paste the key from console.typesafe.ai › API Keys. Input is hidden. Enter saves, Esc cancels."), 1, 0));
|
|
22
|
+
this.addChild(this.input);
|
|
23
|
+
this.input.onSubmit = value => done(value);
|
|
24
|
+
this.input.onEscape = () => done(undefined);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get focused(): boolean {
|
|
28
|
+
return this.isFocused;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
set focused(value: boolean) {
|
|
32
|
+
this.isFocused = value;
|
|
33
|
+
this.input.focused = value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
handleInput(data: string): void {
|
|
37
|
+
if (matchesKey(data, Key.escape)) {
|
|
38
|
+
this.input.onEscape?.();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
this.input.handleInput(data);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Hidden input in the TUI; falls back to Pi's plain input dialog where custom components are unavailable. */
|
|
46
|
+
export async function promptForApiKey(ctx: ExtensionCommandContext): Promise<string | undefined> {
|
|
47
|
+
if (typeof ctx.ui.custom === "function") {
|
|
48
|
+
return ctx.ui.custom<string | undefined>((_tui, theme, _keybindings, done) => new KeyPrompt(theme, done));
|
|
49
|
+
}
|
|
50
|
+
return ctx.ui.input("TypeSafe API key (visible while typing)", "Paste the key, then press Enter");
|
|
51
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { DEFAULT_BACKEND, resolveBackend, usesTypesafeKey } from "./backends.js";
|
|
3
|
+
import type { BackendSpec } from "./backends.js";
|
|
4
|
+
import { createTypeSafe } from "./client.js";
|
|
5
|
+
import { keySituation, normalizeApiKey, storeApiKey } from "./credentials.js";
|
|
6
|
+
import type { KeySource } from "./credentials.js";
|
|
7
|
+
import { TypeSafeIntegrationError } from "./errors.js";
|
|
8
|
+
import { promptForApiKey } from "./key-prompt.js";
|
|
9
|
+
|
|
10
|
+
export interface LoginResult {
|
|
11
|
+
/** Where the verified key was saved. */
|
|
12
|
+
path: string;
|
|
13
|
+
/** Number of models the key can access; proves the key was accepted by the API. */
|
|
14
|
+
models: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Prompt for a TypeSafe key (hidden input), verify it against api.typesafe.ai with a model listing, and store it for
|
|
19
|
+
* every pi-typesafe consumer. Resolves to undefined when the user cancels. Rejects with TypeSafeIntegrationError for an
|
|
20
|
+
* invalid or unverifiable key. Refuses when TYPESAFE_API_KEY is set, because the environment would shadow the stored
|
|
21
|
+
* key. Other backends have no login: their key is an environment variable only.
|
|
22
|
+
*/
|
|
23
|
+
export async function loginWithPrompt(ctx: ExtensionCommandContext): Promise<LoginResult | undefined> {
|
|
24
|
+
if (process.env.TYPESAFE_API_KEY?.trim()) {
|
|
25
|
+
throw new TypeSafeIntegrationError("configuration", "TYPESAFE_API_KEY is set in the environment and takes precedence over a stored key. Unset it before logging in interactively.");
|
|
26
|
+
}
|
|
27
|
+
if (!ctx.hasUI) {
|
|
28
|
+
throw new TypeSafeIntegrationError("configuration", "Logging in needs an interactive session. Set TYPESAFE_API_KEY in the environment instead.");
|
|
29
|
+
}
|
|
30
|
+
const entered = await promptForApiKey(ctx);
|
|
31
|
+
if (entered === undefined) return undefined;
|
|
32
|
+
const key = normalizeApiKey(entered);
|
|
33
|
+
// Verify before saving so a bad paste fails here, not on first use.
|
|
34
|
+
const models = await createTypeSafe({ apiKey: key }).listModels();
|
|
35
|
+
return { path: storeApiKey(key), models: models.length };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type EnsureApiKeyResult =
|
|
39
|
+
| { source: KeySource; login?: undefined }
|
|
40
|
+
| { source: "stored"; login: LoginResult };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Use the configured key for the backend if there is one; otherwise run the login prompt. `undefined` means the user
|
|
44
|
+
* cancelled. A store that must not be read throws `configuration` with the reason instead of prompting, so a
|
|
45
|
+
* permissions problem stays visible; the result shape is frozen for existing callers. A backend without a login store
|
|
46
|
+
* (every backend except TypeSafe) throws `configuration` naming its environment variable instead of prompting, because
|
|
47
|
+
* the prompt would verify the key against the wrong service and store it where the TypeSafe key lives.
|
|
48
|
+
*/
|
|
49
|
+
export async function ensureApiKey(ctx: ExtensionCommandContext, options: { backend?: BackendSpec } = {}): Promise<EnsureApiKeyResult | undefined> {
|
|
50
|
+
const backend = options.backend ?? DEFAULT_BACKEND;
|
|
51
|
+
const situation = keySituation(backend);
|
|
52
|
+
if (situation.kind === "environment" || situation.kind === "stored") return { source: situation.kind };
|
|
53
|
+
if (situation.kind === "unusable") throw new TypeSafeIntegrationError("configuration", situation.reason);
|
|
54
|
+
const config = resolveBackend(backend);
|
|
55
|
+
if (!usesTypesafeKey(config)) {
|
|
56
|
+
throw new TypeSafeIntegrationError("configuration", `No ${config.label} key. Set ${config.keyEnv} in the environment; /typesafe login stores a TypeSafe key only.`);
|
|
57
|
+
}
|
|
58
|
+
const login = await loginWithPrompt(ctx);
|
|
59
|
+
return login ? { source: "stored", login } : undefined;
|
|
60
|
+
}
|