@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,158 @@
|
|
|
1
|
+
import type { SystemOneRequest } from "@typesafe-ai/sdk";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { Check, Errors } from "typebox/value";
|
|
4
|
+
import { TypeSafeIntegrationError } from "./errors.js";
|
|
5
|
+
|
|
6
|
+
/** Default UTF-8 JSON byte budget for one evaluation request; the tool and the client share it. */
|
|
7
|
+
export const DEFAULT_MAX_INPUT_BYTES = 65_536;
|
|
8
|
+
|
|
9
|
+
/** Questions one request may ask. More than this needs `chunkEvaluationRequest`, which splits and fans out. */
|
|
10
|
+
export const DEFAULT_MAX_QUESTIONS = 32;
|
|
11
|
+
|
|
12
|
+
// The API accepts structured descriptions, not only strings. The schema is the only shape guidance the model gets
|
|
13
|
+
// before its first call, so every field the agent authors says what it means.
|
|
14
|
+
const entry = (description?: string) => Type.Union([
|
|
15
|
+
Type.String(),
|
|
16
|
+
Type.Null(),
|
|
17
|
+
Type.Array(Type.Unknown()),
|
|
18
|
+
Type.Record(Type.String(), Type.Unknown()),
|
|
19
|
+
], description === undefined ? {} : { description });
|
|
20
|
+
const instructions = Type.Optional(entry("One judgment about the whole state, phrased as a question or a statement."));
|
|
21
|
+
const question = Type.Union([
|
|
22
|
+
Type.Object({
|
|
23
|
+
type: Type.Literal("noul", { description: "Yes or no: the probability the instructions hold." }),
|
|
24
|
+
instructions,
|
|
25
|
+
criteria: Type.Optional(Type.Union([
|
|
26
|
+
Type.Null(),
|
|
27
|
+
Type.Object({ true: Type.Optional(entry()), false: Type.Optional(entry()) }, { additionalProperties: false }),
|
|
28
|
+
], { description: "Optional: what counts as yes and what counts as no, { true, false }." })),
|
|
29
|
+
}, { additionalProperties: false }),
|
|
30
|
+
Type.Object({
|
|
31
|
+
type: Type.Literal("choice", { description: "Pick one criteria label." }),
|
|
32
|
+
instructions,
|
|
33
|
+
criteria: Type.Record(Type.String({ minLength: 1, maxLength: 200 }), entry(), {
|
|
34
|
+
minProperties: 1,
|
|
35
|
+
maxProperties: 64,
|
|
36
|
+
description: "The options as a map from label to when it applies: { billing: \"Charges and payments\", other: null }. 1–64 entries.",
|
|
37
|
+
}),
|
|
38
|
+
}, { additionalProperties: false }),
|
|
39
|
+
Type.Object({
|
|
40
|
+
type: Type.Literal("score", { description: "Rate against the ordered criteria levels." }),
|
|
41
|
+
instructions,
|
|
42
|
+
criteria: Type.Array(entry(), { minItems: 2, maxItems: 32, description: "Ordered rubric levels, lowest first: [\"neutral\", \"angry\"]. 2–32 levels." }),
|
|
43
|
+
}, { additionalProperties: false }),
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/** The JSON schema used by both the Pi tool and the programmatic interface. */
|
|
47
|
+
export const evaluationSchema = Type.Object({
|
|
48
|
+
state: entry("What to judge: text, or an object whose fields the questions name."),
|
|
49
|
+
questions: Type.Record(Type.String({ minLength: 1, maxLength: 100 }), question, {
|
|
50
|
+
minProperties: 1,
|
|
51
|
+
maxProperties: DEFAULT_MAX_QUESTIONS,
|
|
52
|
+
description: "Questions keyed by a short id, as an object map, not an array: { \"urgent\": { type: \"noul\", instructions: ... } }.",
|
|
53
|
+
}),
|
|
54
|
+
model: Type.Optional(Type.String({ minLength: 1, maxLength: 100, description: "Jev model id, e.g. jev-latest. Omit for the default." })),
|
|
55
|
+
}, { additionalProperties: false });
|
|
56
|
+
|
|
57
|
+
const usage = `Expected { state, questions: { <id>: { type: "choice", instructions, criteria: { label: description|null } } | { type: "score", instructions, criteria: [level0, level1, ...] } | { type: "noul", instructions } } }; 1–${DEFAULT_MAX_QUESTIONS} questions, Choice 1–64 options, Score 2–32 levels.`;
|
|
58
|
+
|
|
59
|
+
/** Paths and messages only; never the submitted values. */
|
|
60
|
+
function describeSchemaErrors(value: unknown): string {
|
|
61
|
+
const details: string[] = [];
|
|
62
|
+
for (const error of Errors(evaluationSchema, value)) {
|
|
63
|
+
const path = error.instancePath.replace(/^\//, "").replace(/\//g, ".") || "request";
|
|
64
|
+
details.push(`${path.slice(0, 120)}: ${error.message}`);
|
|
65
|
+
if (details.length === 3) break;
|
|
66
|
+
}
|
|
67
|
+
return details.join("; ");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Validate without including submitted content in validation errors. Prefer prepareEvaluationRequest(), which also accepts near-misses and enforces the byte budget. */
|
|
71
|
+
export function parseEvaluationRequest(value: unknown): SystemOneRequest {
|
|
72
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value) && !isJsonSafe(value)) {
|
|
73
|
+
throw new TypeSafeIntegrationError("validation", `Invalid evaluation request: state and questions must be plain JSON. ${usage}`);
|
|
74
|
+
}
|
|
75
|
+
if (!Check(evaluationSchema, value)) {
|
|
76
|
+
throw new TypeSafeIntegrationError("validation", `Invalid evaluation request at ${describeSchemaErrors(value)}. ${usage}`);
|
|
77
|
+
}
|
|
78
|
+
return value as SystemOneRequest;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isJsonSafe(value: unknown): boolean {
|
|
82
|
+
try {
|
|
83
|
+
// Walk before schema validation to reject cycles and non-JSON values.
|
|
84
|
+
// Object descriptors avoid executing getters while checking user data.
|
|
85
|
+
const ancestors = new Set<object>();
|
|
86
|
+
const walk = (item: unknown, depth: number): void => {
|
|
87
|
+
if (depth > 64) throw new Error();
|
|
88
|
+
if (item === null || typeof item === "string" || typeof item === "boolean") return;
|
|
89
|
+
if (typeof item === "number" && Number.isFinite(item)) return;
|
|
90
|
+
if (typeof item !== "object" || ancestors.has(item)) throw new Error();
|
|
91
|
+
if (!Array.isArray(item) && Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null) throw new Error();
|
|
92
|
+
ancestors.add(item);
|
|
93
|
+
if (Object.getOwnPropertySymbols(item).length) throw new Error();
|
|
94
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(item))) {
|
|
95
|
+
if (Array.isArray(item) && key === "length") continue;
|
|
96
|
+
if (!descriptor.enumerable || descriptor.get || descriptor.set) throw new Error();
|
|
97
|
+
// SDK helpers include optional object fields whose value is undefined.
|
|
98
|
+
// JSON serialization omits those fields; array elements must remain JSON.
|
|
99
|
+
if (descriptor.value === undefined && !Array.isArray(item)) continue;
|
|
100
|
+
walk(descriptor.value, depth + 1);
|
|
101
|
+
}
|
|
102
|
+
ancestors.delete(item);
|
|
103
|
+
};
|
|
104
|
+
walk(value, 0);
|
|
105
|
+
return true;
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Accept common near-misses from language models without loosening the schema itself. Prefer prepareEvaluationRequest(), which applies this before validating. */
|
|
112
|
+
export function normalizeEvaluationRequest(value: unknown): unknown {
|
|
113
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
114
|
+
const request = value as Record<string, unknown>;
|
|
115
|
+
const questions = request.questions;
|
|
116
|
+
if (!questions || typeof questions !== "object" || Array.isArray(questions)) return value;
|
|
117
|
+
const normalized: Record<string, unknown> = {};
|
|
118
|
+
for (const [id, question] of Object.entries(questions as Record<string, unknown>)) {
|
|
119
|
+
if (!question || typeof question !== "object" || Array.isArray(question)) { normalized[id] = question; continue; }
|
|
120
|
+
const { options, levels, choices, ...rest } = question as Record<string, unknown>;
|
|
121
|
+
const item: Record<string, unknown> = { ...rest };
|
|
122
|
+
if (item.criteria === undefined) {
|
|
123
|
+
const alias = options ?? levels ?? choices;
|
|
124
|
+
if (alias !== undefined) item.criteria = alias;
|
|
125
|
+
}
|
|
126
|
+
if (item.type === "choice" && Array.isArray(item.criteria) && item.criteria.every(label => typeof label === "string" && label)) {
|
|
127
|
+
item.criteria = Object.fromEntries((item.criteria as string[]).map(label => [label, null]));
|
|
128
|
+
}
|
|
129
|
+
if (item.type === "noul" && typeof item.criteria === "string") {
|
|
130
|
+
item.criteria = { true: item.criteria };
|
|
131
|
+
}
|
|
132
|
+
normalized[id] = item;
|
|
133
|
+
}
|
|
134
|
+
return { ...request, questions: normalized };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface PrepareEvaluationOptions {
|
|
138
|
+
/** UTF-8 JSON bytes of the serialized request. Default: DEFAULT_MAX_INPUT_BYTES. */
|
|
139
|
+
maxInputBytes?: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** One byte rule for every limit check: measure the serialized request and name the configured limit. */
|
|
143
|
+
export function assertWithinByteLimit(text: string, maxInputBytes: number): void {
|
|
144
|
+
if (Buffer.byteLength(text, "utf8") > maxInputBytes) {
|
|
145
|
+
throw new TypeSafeIntegrationError("validation", `Evaluation exceeds the ${maxInputBytes}-byte input limit.`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The one admission rule: normalize known near-miss aliases, validate the schema and JSON-safety, then enforce the byte
|
|
151
|
+
* budget — always in that order. The Pi tool, the playground, and client.evaluate() all pass through here, so what one
|
|
152
|
+
* accepts the others accept.
|
|
153
|
+
*/
|
|
154
|
+
export function prepareEvaluationRequest(value: unknown, options: PrepareEvaluationOptions = {}): SystemOneRequest {
|
|
155
|
+
const validated = parseEvaluationRequest(normalizeEvaluationRequest(value));
|
|
156
|
+
assertWithinByteLimit(JSON.stringify(validated), options.maxInputBytes ?? DEFAULT_MAX_INPUT_BYTES);
|
|
157
|
+
return validated;
|
|
158
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Interactive helpers that need Pi's TUI. Kept out of the root entry so the library stays usable without Pi.
|
|
2
|
+
export { promptForApiKey } from "./key-prompt.js";
|
|
3
|
+
export { loginWithPrompt, ensureApiKey } from "./login.js";
|
|
4
|
+
export type { LoginResult, EnsureApiKeyResult } from "./login.js";
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { piTypesafeDir } from "./credentials.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* TypeSafe bills input tokens only; output is free. The default mirrors the $42-per-billion-input-token rate the README
|
|
7
|
+
* quotes, so a spend cap means something before anyone configures a price. Override it when the rate changes.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_USD_PER_MTOK = 0.042;
|
|
10
|
+
/** Ledger days kept on disk; older entries are dropped on the next write. */
|
|
11
|
+
const KEEP_DAYS = 31;
|
|
12
|
+
const USAGE_VERSION = 1;
|
|
13
|
+
|
|
14
|
+
/** Counters for one window (a session, or one local day). */
|
|
15
|
+
export interface UsageTotals {
|
|
16
|
+
readonly requestsStarted: number;
|
|
17
|
+
readonly requestsSucceeded: number;
|
|
18
|
+
readonly requestsFailed: number;
|
|
19
|
+
readonly inputTokens: number;
|
|
20
|
+
readonly outputTokens: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One day of persisted totals plus the cost estimate for that day. */
|
|
24
|
+
export interface UsageReport extends UsageTotals {
|
|
25
|
+
readonly day: string;
|
|
26
|
+
readonly estimatedUsd: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Client-side spend caps. Every field is optional; an absent field is unlimited. Session caps come from the client
|
|
31
|
+
* options only; day caps also read the `PI_TYPESAFE_MAX_*` environment variables so a headless run can bound itself
|
|
32
|
+
* without editing code. A cap that is reached raises a `budget` error before the next request leaves the process.
|
|
33
|
+
*/
|
|
34
|
+
export interface SpendCaps {
|
|
35
|
+
readonly maxRequests?: number;
|
|
36
|
+
readonly maxRequestsPerDay?: number;
|
|
37
|
+
readonly maxInputTokensPerDay?: number;
|
|
38
|
+
readonly maxUsdPerDay?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The cap that stops the next request, with what it allows and what has been used today. */
|
|
42
|
+
export interface BlockedCap {
|
|
43
|
+
readonly cap: "requestsPerDay" | "inputTokensPerDay" | "usdPerDay";
|
|
44
|
+
readonly limit: number;
|
|
45
|
+
readonly used: number;
|
|
46
|
+
readonly day: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface LedgerFile {
|
|
50
|
+
readonly version: number;
|
|
51
|
+
readonly days: Record<string, UsageTotals>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function localDay(now: Date = new Date()): string {
|
|
55
|
+
const pad = (value: number) => String(value).padStart(2, "0");
|
|
56
|
+
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The stored ledger. Alongside the key store so one directory holds every pi-typesafe file. */
|
|
60
|
+
export function usagePath(): string {
|
|
61
|
+
return join(piTypesafeDir(), "usage.json");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Input-token cost, rounded to a micro-dollar so the number stays readable. */
|
|
65
|
+
export function estimateUsd(inputTokens: number, usdPerMTok: number): number {
|
|
66
|
+
return Math.round((inputTokens * usdPerMTok) / 1e6 * 1e6) / 1e6;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function emptyTotals(): UsageTotals {
|
|
70
|
+
return { requestsStarted: 0, requestsSucceeded: 0, requestsFailed: 0, inputTokens: 0, outputTokens: 0 };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function count(value: unknown): number {
|
|
74
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function totalsOf(value: unknown): UsageTotals {
|
|
78
|
+
const raw = (value ?? {}) as Record<string, unknown>;
|
|
79
|
+
return {
|
|
80
|
+
requestsStarted: count(raw.requestsStarted),
|
|
81
|
+
requestsSucceeded: count(raw.requestsSucceeded),
|
|
82
|
+
requestsFailed: count(raw.requestsFailed),
|
|
83
|
+
inputTokens: count(raw.inputTokens),
|
|
84
|
+
outputTokens: count(raw.outputTokens),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readDays(path: string): Record<string, UsageTotals> {
|
|
89
|
+
try {
|
|
90
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
91
|
+
const days = parsed && typeof parsed === "object" ? (parsed as { days?: unknown }).days : undefined;
|
|
92
|
+
if (!days || typeof days !== "object" || Array.isArray(days)) return {};
|
|
93
|
+
const result: Record<string, UsageTotals> = {};
|
|
94
|
+
for (const [day, totals] of Object.entries(days as Record<string, unknown>)) {
|
|
95
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(day)) result[day] = totalsOf(totals);
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
} catch {
|
|
99
|
+
// A missing, unreadable, or corrupt ledger restarts today's count; it never blocks a request.
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function keepRecent(days: Record<string, UsageTotals>, today: string): Record<string, UsageTotals> {
|
|
105
|
+
const names = Object.keys(days).sort();
|
|
106
|
+
const result: Record<string, UsageTotals> = {};
|
|
107
|
+
for (const name of names.slice(-KEEP_DAYS)) result[name] = days[name] as UsageTotals;
|
|
108
|
+
result[today] = days[today] ?? emptyTotals();
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Owner-only, atomic, and best-effort: a ledger this process cannot write never fails a request. */
|
|
113
|
+
function writeDays(path: string, days: Record<string, UsageTotals>): void {
|
|
114
|
+
const file: LedgerFile = { version: USAGE_VERSION, days };
|
|
115
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
116
|
+
try {
|
|
117
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
118
|
+
writeFileSync(temporary, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600, flag: "w" });
|
|
119
|
+
chmodSync(temporary, 0o600);
|
|
120
|
+
renameSync(temporary, path);
|
|
121
|
+
} catch {
|
|
122
|
+
try { rmSync(temporary, { force: true }); } catch { /* best-effort cleanup only */ }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The caps a headless run may set without code: `PI_TYPESAFE_MAX_USD_PER_DAY` and its siblings. */
|
|
127
|
+
export function capsFromEnvironment(env: NodeJS.ProcessEnv = process.env): SpendCaps {
|
|
128
|
+
const number = (name: string): number | undefined => {
|
|
129
|
+
const raw = env[name]?.trim();
|
|
130
|
+
if (!raw) return undefined;
|
|
131
|
+
const value = Number(raw);
|
|
132
|
+
return Number.isFinite(value) && value > 0 ? value : undefined;
|
|
133
|
+
};
|
|
134
|
+
const maxRequestsPerDay = number("PI_TYPESAFE_MAX_REQUESTS_PER_DAY");
|
|
135
|
+
const maxInputTokensPerDay = number("PI_TYPESAFE_MAX_INPUT_TOKENS_PER_DAY");
|
|
136
|
+
const maxUsdPerDay = number("PI_TYPESAFE_MAX_USD_PER_DAY");
|
|
137
|
+
return {
|
|
138
|
+
...(maxRequestsPerDay === undefined ? {} : { maxRequestsPerDay: Math.floor(maxRequestsPerDay) }),
|
|
139
|
+
...(maxInputTokensPerDay === undefined ? {} : { maxInputTokensPerDay: Math.floor(maxInputTokensPerDay) }),
|
|
140
|
+
...(maxUsdPerDay === undefined ? {} : { maxUsdPerDay }),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Environment first for day caps, then the explicit option; a cap is never raised by the environment. */
|
|
145
|
+
export function mergeCaps(explicit: SpendCaps, environment: SpendCaps): SpendCaps {
|
|
146
|
+
const lowest = (a: number | undefined, b: number | undefined) =>
|
|
147
|
+
a === undefined ? b : b === undefined ? a : Math.min(a, b);
|
|
148
|
+
const maxRequestsPerDay = lowest(explicit.maxRequestsPerDay, environment.maxRequestsPerDay);
|
|
149
|
+
const maxInputTokensPerDay = lowest(explicit.maxInputTokensPerDay, environment.maxInputTokensPerDay);
|
|
150
|
+
const maxUsdPerDay = lowest(explicit.maxUsdPerDay, environment.maxUsdPerDay);
|
|
151
|
+
return {
|
|
152
|
+
...(explicit.maxRequests === undefined ? {} : { maxRequests: explicit.maxRequests }),
|
|
153
|
+
...(maxRequestsPerDay === undefined ? {} : { maxRequestsPerDay }),
|
|
154
|
+
...(maxInputTokensPerDay === undefined ? {} : { maxInputTokensPerDay }),
|
|
155
|
+
...(maxUsdPerDay === undefined ? {} : { maxUsdPerDay }),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface UsageLedgerOptions {
|
|
160
|
+
/** Defaults to usagePath(); tests point it at a temporary file. */
|
|
161
|
+
path?: string;
|
|
162
|
+
/** Clock for the local day and for rollover; injectable for tests. */
|
|
163
|
+
now?: () => Date;
|
|
164
|
+
usdPerMTok?: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* One day of persisted request, token, and cost totals, plus the caps that stop the next request. The in-memory copy is
|
|
169
|
+
* authoritative for this process; the file is the cross-process, across-restart record. Reads are defensive, writes are
|
|
170
|
+
* atomic and best-effort, and a day rolls over on the local date, so a long eval cannot accumulate forever unnoticed.
|
|
171
|
+
*/
|
|
172
|
+
export interface UsageLedger {
|
|
173
|
+
readonly path: string;
|
|
174
|
+
readonly usdPerMTok: number;
|
|
175
|
+
/** Today's totals, after any rollover. */
|
|
176
|
+
today(): UsageReport;
|
|
177
|
+
/** Count the attempt before it is submitted; a request that never returns still counts. */
|
|
178
|
+
recordStart(): void;
|
|
179
|
+
recordSuccess(inputTokens: number, outputTokens: number): void;
|
|
180
|
+
recordFailure(): void;
|
|
181
|
+
/** The reached day cap that blocks the next request, or undefined. The caller owns the caps. */
|
|
182
|
+
blocked(caps: SpendCaps): BlockedCap | undefined;
|
|
183
|
+
/** One line for status output: today's requests, tokens, and cost, with the caps that apply. */
|
|
184
|
+
describe(caps?: SpendCaps): string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function openUsageLedger(options: UsageLedgerOptions = {}): UsageLedger {
|
|
188
|
+
const path = options.path ?? usagePath();
|
|
189
|
+
const now = options.now ?? (() => new Date());
|
|
190
|
+
const usdPerMTok = options.usdPerMTok && options.usdPerMTok > 0 ? options.usdPerMTok : DEFAULT_USD_PER_MTOK;
|
|
191
|
+
let day = localDay(now());
|
|
192
|
+
let days = keepRecent(readDays(path), day);
|
|
193
|
+
let totals = days[day] as UsageTotals;
|
|
194
|
+
|
|
195
|
+
const report = (value: UsageTotals, name: string): UsageReport => ({
|
|
196
|
+
...value, day: name, estimatedUsd: estimateUsd(value.inputTokens, usdPerMTok),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const save = () => {
|
|
200
|
+
days = keepRecent({ ...days, [day]: totals }, day);
|
|
201
|
+
writeDays(path, days);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const roll = () => {
|
|
205
|
+
const current = localDay(now());
|
|
206
|
+
if (current === day) return;
|
|
207
|
+
day = current;
|
|
208
|
+
totals = days[day] ?? emptyTotals();
|
|
209
|
+
days = keepRecent(days, day);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const add = (delta: Partial<UsageTotals>) => {
|
|
213
|
+
roll();
|
|
214
|
+
totals = {
|
|
215
|
+
requestsStarted: totals.requestsStarted + (delta.requestsStarted ?? 0),
|
|
216
|
+
requestsSucceeded: totals.requestsSucceeded + (delta.requestsSucceeded ?? 0),
|
|
217
|
+
requestsFailed: totals.requestsFailed + (delta.requestsFailed ?? 0),
|
|
218
|
+
inputTokens: totals.inputTokens + (delta.inputTokens ?? 0),
|
|
219
|
+
outputTokens: totals.outputTokens + (delta.outputTokens ?? 0),
|
|
220
|
+
};
|
|
221
|
+
save();
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
path,
|
|
226
|
+
usdPerMTok,
|
|
227
|
+
today: () => { roll(); return report(totals, day); },
|
|
228
|
+
recordStart: () => add({ requestsStarted: 1 }),
|
|
229
|
+
recordSuccess: (inputTokens, outputTokens) => add({
|
|
230
|
+
requestsSucceeded: 1,
|
|
231
|
+
inputTokens: Number.isSafeInteger(inputTokens) && inputTokens > 0 ? inputTokens : 0,
|
|
232
|
+
outputTokens: Number.isSafeInteger(outputTokens) && outputTokens > 0 ? outputTokens : 0,
|
|
233
|
+
}),
|
|
234
|
+
recordFailure: () => add({ requestsFailed: 1 }),
|
|
235
|
+
blocked: (caps: SpendCaps) => {
|
|
236
|
+
roll();
|
|
237
|
+
const checks: Array<[BlockedCap["cap"], number | undefined, number]> = [
|
|
238
|
+
["requestsPerDay", caps.maxRequestsPerDay, totals.requestsStarted],
|
|
239
|
+
["inputTokensPerDay", caps.maxInputTokensPerDay, totals.inputTokens],
|
|
240
|
+
["usdPerDay", caps.maxUsdPerDay, estimateUsd(totals.inputTokens, usdPerMTok)],
|
|
241
|
+
];
|
|
242
|
+
for (const [cap, limit, used] of checks) {
|
|
243
|
+
if (limit !== undefined && used >= limit) return { cap, limit, used, day };
|
|
244
|
+
}
|
|
245
|
+
return undefined;
|
|
246
|
+
},
|
|
247
|
+
describe: (caps: SpendCaps = {}) => {
|
|
248
|
+
roll();
|
|
249
|
+
const current = report(totals, day);
|
|
250
|
+
const limits = [
|
|
251
|
+
caps.maxRequestsPerDay === undefined ? undefined : `${current.requestsStarted}/${caps.maxRequestsPerDay} requests`,
|
|
252
|
+
caps.maxInputTokensPerDay === undefined ? undefined : `${current.inputTokens}/${caps.maxInputTokensPerDay} input tokens`,
|
|
253
|
+
caps.maxUsdPerDay === undefined ? undefined : `$${current.estimatedUsd.toFixed(4)}/$${caps.maxUsdPerDay.toFixed(2)}`,
|
|
254
|
+
].filter((part): part is string => part !== undefined);
|
|
255
|
+
return `${current.requestsStarted} requests today (${current.requestsSucceeded} ok, ${current.requestsFailed} failed), ${current.inputTokens} input / ${current.outputTokens} output tokens, ~$${current.estimatedUsd.toFixed(4)}${limits.length ? `; caps ${limits.join(", ")}` : "; no daily cap"}`;
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { ask, DEFAULT_ASK_TIMEOUT_MS } from "../src/ask.js";
|
|
4
|
+
import type { Judge } from "../src/ask.js";
|
|
5
|
+
import { TypeSafeIntegrationError } from "../src/errors.js";
|
|
6
|
+
import { noul } from "../src/index.js";
|
|
7
|
+
import type { Evaluation } from "../src/client.js";
|
|
8
|
+
import type { Questions, SystemOneRequest, SystemOneResult } from "@typesafe-ai/sdk";
|
|
9
|
+
|
|
10
|
+
const request: SystemOneRequest<Questions> = { state: "synthetic", questions: { yes: noul("Is this synthetic?") } };
|
|
11
|
+
const result: SystemOneResult<Questions> = {
|
|
12
|
+
model: "jev-test",
|
|
13
|
+
answers: { yes: { type: "noul", noul: 0.9 } },
|
|
14
|
+
usage: { input_tokens: 12, output_tokens: 0 },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function judgeReturning(value: SystemOneResult<Questions>): Judge {
|
|
18
|
+
return { evaluate: async <Q extends Questions>(): Promise<Evaluation<Q>> => ({ ...value, elapsedMs: 7 } as Evaluation<Q>) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test("a successful ask returns the typed answers, model, usage, and duration", async () => {
|
|
22
|
+
const answer = await ask(judgeReturning(result), request);
|
|
23
|
+
assert.equal(answer.ok, true);
|
|
24
|
+
assert.ok(answer.ok);
|
|
25
|
+
const yes = answer.answers.yes;
|
|
26
|
+
assert.equal(yes?.type === "noul" ? yes.noul : undefined, 0.9);
|
|
27
|
+
assert.equal(answer.model, "jev-test");
|
|
28
|
+
assert.equal(answer.usage.input_tokens, 12);
|
|
29
|
+
assert.equal(answer.elapsedMs, 7);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a typed failure comes back as data, with pi-typesafe's own code and message", async () => {
|
|
33
|
+
const budget = await ask({ evaluate: async () => { throw new TypeSafeIntegrationError("budget", "TypeSafe request limit reached (1 attempts per client instance)."); } }, request);
|
|
34
|
+
assert.deepEqual(budget, { ok: false, error: "TypeSafe request limit reached (1 attempts per client instance).", errorCode: "budget" });
|
|
35
|
+
const http = await ask({ evaluate: async () => { throw new TypeSafeIntegrationError("http", "TypeSafe returned HTTP 401. Check TYPESAFE_API_KEY. No automatic retry was made.", 401); } }, request);
|
|
36
|
+
assert.equal(http.ok, false);
|
|
37
|
+
assert.equal(http.ok === false && http.errorCode, "http");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("an unknown failure is replaced by a fixed message so nothing from the transport escapes", async () => {
|
|
41
|
+
const answer = await ask({ evaluate: async () => { throw new Error("upstream body with a key: sk-secret"); } }, request);
|
|
42
|
+
assert.deepEqual(answer, { ok: false, error: "TypeSafe request failed." });
|
|
43
|
+
assert.equal(JSON.stringify(answer).includes("sk-secret"), false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("the ask deadline and the caller's signal both cancel the request", async () => {
|
|
47
|
+
const slow: Judge = {
|
|
48
|
+
evaluate: async <Q extends Questions>(_request: SystemOneRequest<Q>, options?: { signal?: AbortSignal }) => {
|
|
49
|
+
await new Promise((_resolve, reject) => {
|
|
50
|
+
options?.signal?.addEventListener("abort", () => reject(new TypeSafeIntegrationError("aborted", "TypeSafe request cancelled before submission.")), { once: true });
|
|
51
|
+
});
|
|
52
|
+
throw new Error("unreachable");
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
const timedOut = await ask(slow, request, { timeoutMs: 5 });
|
|
56
|
+
assert.deepEqual(timedOut, { ok: false, error: "TypeSafe request cancelled before submission.", errorCode: "aborted" });
|
|
57
|
+
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
const aborted = ask(slow, request, { signal: controller.signal, timeoutMs: 5_000 });
|
|
60
|
+
controller.abort();
|
|
61
|
+
assert.equal((await aborted).ok, false);
|
|
62
|
+
assert.equal(DEFAULT_ASK_TIMEOUT_MS, 15_000);
|
|
63
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { chmodSync, mkdtempSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { after, before, test } from "node:test";
|
|
6
|
+
import { authState, authStatePath, clearAuthState, describeAuth, recordAuthFailure, recordAuthVerified } from "../src/auth.js";
|
|
7
|
+
import { credentialsPath, storeApiKey } from "../src/credentials.js";
|
|
8
|
+
import { TypeSafeIntegrationError } from "../src/errors.js";
|
|
9
|
+
|
|
10
|
+
const workspace = mkdtempSync(join(tmpdir(), "pi-typesafe-auth-"));
|
|
11
|
+
const savedAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
12
|
+
const savedKey = process.env.TYPESAFE_API_KEY;
|
|
13
|
+
before(() => {
|
|
14
|
+
process.env.PI_CODING_AGENT_DIR = workspace;
|
|
15
|
+
delete process.env.TYPESAFE_API_KEY;
|
|
16
|
+
});
|
|
17
|
+
after(() => {
|
|
18
|
+
if (savedAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = savedAgentDir;
|
|
19
|
+
if (savedKey === undefined) delete process.env.TYPESAFE_API_KEY; else process.env.TYPESAFE_API_KEY = savedKey;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("no key at all is an error the consumer cannot mistake for a working setup", () => {
|
|
23
|
+
const state = authState();
|
|
24
|
+
assert.equal(state.kind, "missing");
|
|
25
|
+
assert.equal(state.keyName, "no key");
|
|
26
|
+
assert.equal(state.usable, false);
|
|
27
|
+
assert.equal(state.verified, false);
|
|
28
|
+
const report = describeAuth(state);
|
|
29
|
+
assert.equal(report.level, "error");
|
|
30
|
+
assert.ok(report.text.includes("every Jev judgment is skipped"));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("another backend reports its own key and never the TypeSafe login hint", () => {
|
|
34
|
+
const savedOpenRouter = process.env.OPENROUTER_API_KEY;
|
|
35
|
+
try {
|
|
36
|
+
delete process.env.OPENROUTER_API_KEY;
|
|
37
|
+
process.env.TYPESAFE_API_KEY = "env-key-0123456789abcdef";
|
|
38
|
+
assert.equal(authState().backend, "typesafe");
|
|
39
|
+
const missing = authState({ backend: "openrouter" });
|
|
40
|
+
assert.equal(missing.backend, "openrouter");
|
|
41
|
+
assert.equal(missing.kind, "missing");
|
|
42
|
+
assert.equal(missing.usable, false);
|
|
43
|
+
const report = describeAuth(missing);
|
|
44
|
+
assert.equal(report.level, "error");
|
|
45
|
+
assert.ok(report.text.startsWith("OpenRouter key: missing"));
|
|
46
|
+
assert.ok(report.text.includes("OPENROUTER_API_KEY is set"));
|
|
47
|
+
assert.ok(!report.text.includes("/typesafe login"));
|
|
48
|
+
|
|
49
|
+
process.env.OPENROUTER_API_KEY = "sk-or-0123456789abcdef";
|
|
50
|
+
const present = authState({ backend: "openrouter" });
|
|
51
|
+
assert.equal(present.kind, "environment");
|
|
52
|
+
assert.equal(present.keyName, "OPENROUTER_API_KEY");
|
|
53
|
+
assert.equal(present.usable, true);
|
|
54
|
+
assert.ok(describeAuth(present).text.startsWith("OpenRouter key: OPENROUTER_API_KEY"));
|
|
55
|
+
} finally {
|
|
56
|
+
delete process.env.TYPESAFE_API_KEY;
|
|
57
|
+
if (savedOpenRouter === undefined) delete process.env.OPENROUTER_API_KEY; else process.env.OPENROUTER_API_KEY = savedOpenRouter;
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("an environment key is usable but unverified until something proves it", () => {
|
|
62
|
+
process.env.TYPESAFE_API_KEY = "env-key-0123456789abcdef";
|
|
63
|
+
try {
|
|
64
|
+
const before = describeAuth(authState());
|
|
65
|
+
assert.equal(before.level, "warning");
|
|
66
|
+
assert.ok(before.text.includes("TYPESAFE_API_KEY"));
|
|
67
|
+
assert.ok(before.text.includes("not verified yet"));
|
|
68
|
+
recordAuthVerified(new Date("2026-01-01T00:00:00.000Z"));
|
|
69
|
+
const state = authState();
|
|
70
|
+
assert.equal(state.usable, true);
|
|
71
|
+
assert.equal(state.verified, true);
|
|
72
|
+
assert.equal(state.verifiedAt, "2026-01-01T00:00:00.000Z");
|
|
73
|
+
assert.equal(describeAuth(state).level, "ok");
|
|
74
|
+
assert.equal(statSync(authStatePath()).mode & 0o777, 0o600);
|
|
75
|
+
} finally {
|
|
76
|
+
delete process.env.TYPESAFE_API_KEY;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a rejected key degrades loudly and a later success clears it", () => {
|
|
81
|
+
storeApiKey("stored-key-0123456789abcdef");
|
|
82
|
+
recordAuthVerified(new Date("2026-01-02T00:00:00.000Z"));
|
|
83
|
+
recordAuthFailure(new TypeSafeIntegrationError("http", "TypeSafe returned HTTP 401. Check TYPESAFE_API_KEY. No automatic retry was made.", 401), new Date("2026-01-02T01:00:00.000Z"));
|
|
84
|
+
const degraded = authState();
|
|
85
|
+
assert.equal(degraded.kind, "stored");
|
|
86
|
+
assert.equal(degraded.usable, false);
|
|
87
|
+
assert.equal(degraded.verified, false);
|
|
88
|
+
assert.deepEqual(degraded.lastFailure, {
|
|
89
|
+
code: "http",
|
|
90
|
+
status: 401,
|
|
91
|
+
message: "TypeSafe returned HTTP 401. Check TYPESAFE_API_KEY. No automatic retry was made.",
|
|
92
|
+
at: "2026-01-02T01:00:00.000Z",
|
|
93
|
+
});
|
|
94
|
+
const report = describeAuth(degraded);
|
|
95
|
+
assert.equal(report.level, "error");
|
|
96
|
+
assert.ok(report.text.includes("was rejected"));
|
|
97
|
+
|
|
98
|
+
// A successful request proves the key again and drops the failure.
|
|
99
|
+
recordAuthVerified(new Date("2026-01-02T02:00:00.000Z"));
|
|
100
|
+
const recovered = authState();
|
|
101
|
+
assert.equal(recovered.usable, true);
|
|
102
|
+
assert.equal(recovered.verified, true);
|
|
103
|
+
assert.equal(recovered.lastFailure, undefined);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a non-authentication failure degrades the signal without pretending the key is gone", () => {
|
|
107
|
+
storeApiKey("stored-key-0123456789abcdef");
|
|
108
|
+
recordAuthVerified(new Date("2026-01-03T00:00:00.000Z"));
|
|
109
|
+
recordAuthFailure(new TypeSafeIntegrationError("timeout", "TypeSafe request timed out; it was not retried and may still be billed."), new Date("2026-01-03T00:05:00.000Z"));
|
|
110
|
+
const state = authState();
|
|
111
|
+
assert.equal(state.usable, true);
|
|
112
|
+
assert.equal(state.verified, true);
|
|
113
|
+
assert.equal(describeAuth(state).level, "ok");
|
|
114
|
+
assert.ok(describeAuth(state).text.includes("Last failure"));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a stored key readable by other users is reported as unusable, never as enabled", () => {
|
|
118
|
+
const path = credentialsPath();
|
|
119
|
+
writeFileSync(path, JSON.stringify({ apiKey: "stored-key-0123456789abcdef" }), { mode: 0o600 });
|
|
120
|
+
chmodSync(path, 0o644);
|
|
121
|
+
try {
|
|
122
|
+
const state = authState();
|
|
123
|
+
assert.equal(state.kind, "unusable");
|
|
124
|
+
assert.equal(state.usable, false);
|
|
125
|
+
assert.equal(state.reason !== undefined, true);
|
|
126
|
+
assert.equal(describeAuth(state).level, "error");
|
|
127
|
+
} finally {
|
|
128
|
+
chmodSync(path, 0o600);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("a corrupt auth record is ignored and clearing forgets both facts", () => {
|
|
133
|
+
writeFileSync(authStatePath(), "{ not json");
|
|
134
|
+
assert.equal(authState().verified, false);
|
|
135
|
+
assert.equal(authState().lastFailure, undefined);
|
|
136
|
+
recordAuthVerified(new Date("2026-01-04T00:00:00.000Z"));
|
|
137
|
+
clearAuthState();
|
|
138
|
+
const cleared = authState();
|
|
139
|
+
assert.equal(cleared.verified, false);
|
|
140
|
+
assert.equal(cleared.lastFailure, undefined);
|
|
141
|
+
});
|