pi-typesafe-router 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/src/routing.ts ADDED
@@ -0,0 +1,84 @@
1
+ import type {
2
+ CandidateCheck,
3
+ Classification,
4
+ Eligibility,
5
+ Route,
6
+ RouterConfig,
7
+ Target,
8
+ } from "./types.ts";
9
+
10
+ const virtualProviders = new Set(["auto", "smart-router", "typesafe-router"]);
11
+
12
+ const positiveFinite = (value: number): boolean => Number.isFinite(value) && value > 0;
13
+
14
+ /** Deterministic preflight only; preserve configured order, never perform runtime retries. */
15
+ export function candidateChecks(
16
+ targets: readonly Target[],
17
+ eligibility: Eligibility,
18
+ ): CandidateCheck[] {
19
+ return targets.map((target) => {
20
+ const reject = (reason: string): CandidateCheck => ({ target, eligible: false, reason });
21
+
22
+ if (virtualProviders.has(target.provider.toLowerCase())) return reject("virtual-provider");
23
+
24
+ const model = eligibility.models.find(
25
+ (item) => item.provider === target.provider && item.id === target.model,
26
+ );
27
+
28
+ if (!model) return reject("unknown-model");
29
+
30
+ if (
31
+ !eligibility.available.some(
32
+ (item) => item.provider === target.provider && item.id === target.model,
33
+ )
34
+ )
35
+ return reject("unavailable");
36
+
37
+ if (
38
+ eligibility.scope.length &&
39
+ !eligibility.scope.some(
40
+ (item) => item.provider === target.provider && item.model === target.model,
41
+ )
42
+ )
43
+ return reject("out-of-scope");
44
+
45
+ if (eligibility.hasImages && !model.input.includes("image")) return reject("image-unsupported");
46
+
47
+ if (!positiveFinite(model.contextWindow) || !positiveFinite(model.maxTokens))
48
+ return reject("invalid-model-limits");
49
+
50
+ if (
51
+ (eligibility.inputTokens !== null &&
52
+ (!Number.isFinite(eligibility.inputTokens) || eligibility.inputTokens < 0)) ||
53
+ !positiveFinite(eligibility.outputReserveTokens)
54
+ )
55
+ return reject("invalid-token-budget");
56
+ const reserve = Math.min(eligibility.outputReserveTokens, model.maxTokens);
57
+
58
+ if (eligibility.inputTokens !== null && eligibility.inputTokens + reserve > model.contextWindow)
59
+ return reject("context-overflow");
60
+
61
+ return { target, eligible: true };
62
+ });
63
+ }
64
+
65
+ export function chooseRoute(
66
+ classification: Classification | undefined,
67
+ config: RouterConfig,
68
+ ): Route {
69
+ if (!classification) return config.defaultRoute;
70
+ const { choice, confidence } = classification;
71
+
72
+ if (
73
+ choice === "uncertain" ||
74
+ confidence === undefined ||
75
+ !Number.isFinite(confidence) ||
76
+ confidence < config.minConfidence ||
77
+ confidence < 0 ||
78
+ confidence > 1 ||
79
+ !["quick", "standard", "deep"].includes(choice)
80
+ )
81
+ return config.uncertainRoute;
82
+
83
+ return choice;
84
+ }
@@ -0,0 +1,63 @@
1
+ import { mkdir, open, readFile, stat } from "node:fs/promises";
2
+ import { z } from "zod";
3
+ import { dirname } from "node:path";
4
+ import { parseConfig } from "./config.ts";
5
+ import type { RouterConfig } from "./types.ts";
6
+
7
+ const missingFile = z
8
+ .instanceof(Error)
9
+ .refine((error) => z.object({ code: z.literal("ENOENT") }).safeParse(error).success);
10
+
11
+ export async function loadConfig(path: string): Promise<RouterConfig | undefined> {
12
+ try {
13
+ if ((await stat(path)).size > 65_536) throw new Error("Router config exceeds 64 KiB");
14
+ const text = await readFile(path, "utf8");
15
+
16
+ if (Buffer.byteLength(text) > 65_536) throw new Error("Router config exceeds 64 KiB");
17
+ let value: unknown;
18
+
19
+ try {
20
+ value = JSON.parse(text.replace(/^\uFEFF/, ""));
21
+ } catch {
22
+ throw new Error("Router config is not valid JSON");
23
+ }
24
+
25
+ return parseConfig(value);
26
+ } catch (error) {
27
+ if (missingFile.safeParse(error).success) return undefined;
28
+ // Config values and filesystem error messages can contain secrets.
29
+ throw new Error(
30
+ "Invalid or unreadable router config. Check JSON, fields, bounds and model mappings.",
31
+ );
32
+ }
33
+ }
34
+
35
+ /** Never overwrite an existing configuration, including a symlink. */
36
+ export async function createConfig(path: string, config: RouterConfig): Promise<void> {
37
+ await mkdir(dirname(path), { recursive: true });
38
+ const file = await open(path, "wx", 0o600);
39
+
40
+ try {
41
+ await file.writeFile(`${JSON.stringify(config, null, 2)}\n`);
42
+ } finally {
43
+ await file.close();
44
+ }
45
+ }
46
+
47
+ /** Bound work whose underlying provider may ignore cancellation. No model setters here. */
48
+ export async function abortable<T>(work: () => Promise<T>, signal: AbortSignal): Promise<T> {
49
+ signal.throwIfAborted();
50
+ let remove = () => {};
51
+
52
+ const cancelled = new Promise<never>((_, reject) => {
53
+ const listener = () => reject(signal.reason);
54
+ signal.addEventListener("abort", listener, { once: true });
55
+ remove = () => signal.removeEventListener("abort", listener);
56
+ });
57
+
58
+ try {
59
+ return await Promise.race([work(), cancelled]);
60
+ } finally {
61
+ remove();
62
+ }
63
+ }
package/src/types.ts ADDED
@@ -0,0 +1,110 @@
1
+ export const TASK_CLASSES = ["quick", "standard", "deep", "uncertain"] as const;
2
+
3
+ export type TaskClass = (typeof TASK_CLASSES)[number];
4
+
5
+ export type Route = Exclude<TaskClass, "uncertain">;
6
+
7
+ export type Mode = "off" | "shadow" | "auto";
8
+
9
+ export type CredentialSource =
10
+ | { source: "env"; variable: string }
11
+ | { source: "pi"; provider: string };
12
+
13
+ export type Backend =
14
+ | { type: "typesafe"; model: string; auth: CredentialSource }
15
+ | { type: "cloudflare"; model: "typesafe/jev"; accountId: string; auth: CredentialSource }
16
+ | {
17
+ type: "vercel";
18
+ model: "typesafe-ai/jev";
19
+ auth: CredentialSource;
20
+ zeroDataRetention: boolean;
21
+ };
22
+
23
+ export interface Target {
24
+ provider: string;
25
+ model: string;
26
+ }
27
+
28
+ export interface RouterConfig {
29
+ version: 1;
30
+ mode: Mode;
31
+ allowHeadless: boolean;
32
+ backend: Backend;
33
+ timeoutMs: number;
34
+ generationProbeTimeoutMs: number;
35
+ minConfidence: number;
36
+ maxContextChars: number;
37
+ historyMessages: number;
38
+ outputReserveTokens: number;
39
+ routes: Record<Route, Target[]>;
40
+ defaultRoute: Route;
41
+ uncertainRoute: Route;
42
+ }
43
+
44
+ export interface ClassificationState {
45
+ current_request: string;
46
+ recent_conversation: Array<{ role: "user" | "assistant"; text: string }>;
47
+ }
48
+
49
+ export interface Classification {
50
+ choice: TaskClass;
51
+ probabilities: Record<TaskClass, number>;
52
+ confidence?: number;
53
+ requestedModel: string;
54
+ returnedModel?: string;
55
+ usage?: { inputTokens: number; outputTokens: number };
56
+ }
57
+
58
+ export type ClassifierFailureCode =
59
+ | "credentials"
60
+ | "timeout"
61
+ | "cancelled"
62
+ | "network"
63
+ | "invalid-response"
64
+ | "http";
65
+
66
+ export class ClassifierError extends Error {
67
+ constructor(
68
+ public readonly code: ClassifierFailureCode,
69
+ public readonly status?: number,
70
+ ) {
71
+ super(status === undefined ? `Classifier ${code}` : `Classifier ${code} (HTTP ${status})`);
72
+ this.name = "ClassifierError";
73
+ }
74
+ }
75
+
76
+ export interface ClassifyOptions {
77
+ signal: AbortSignal;
78
+ apiKey: string;
79
+ }
80
+
81
+ export type Classify = (
82
+ backend: Backend,
83
+ state: ClassificationState,
84
+ options: ClassifyOptions,
85
+ ) => Promise<Classification>;
86
+
87
+ export interface ModelInfo {
88
+ provider: string;
89
+ id: string;
90
+ input: readonly string[];
91
+ contextWindow: number;
92
+ maxTokens: number;
93
+ }
94
+
95
+ export interface Eligibility {
96
+ models: readonly ModelInfo[];
97
+ available: readonly ModelInfo[];
98
+ scope: readonly Target[];
99
+ hasImages: boolean;
100
+ inputTokens: number | null;
101
+ outputReserveTokens: number;
102
+ }
103
+
104
+ export interface CandidateCheck {
105
+ target: Target;
106
+ eligible: boolean;
107
+ reason?: string;
108
+ }
109
+
110
+ export const targetKey = (target: Target): string => `${target.provider}/${target.model}`;
@@ -0,0 +1,62 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { RouterContext } from "./host.ts";
3
+ import { targetKey, type RouterConfig, type Target } from "./types.ts";
4
+
5
+ const providerIdentities = new WeakMap<
6
+ NonNullable<ReturnType<RouterContext["modelRegistry"]["getProvider"]>>,
7
+ number
8
+ >();
9
+
10
+ let nextIdentity = 0;
11
+
12
+ function providerIdentity(provider: ReturnType<RouterContext["modelRegistry"]["getProvider"]>) {
13
+ if (!provider) return null;
14
+ const previous = providerIdentities.get(provider);
15
+
16
+ if (previous !== undefined) return previous;
17
+ const identity = ++nextIdentity;
18
+ providerIdentities.set(provider, identity);
19
+
20
+ return identity;
21
+ }
22
+
23
+ export function configuredTargets(config: RouterConfig) {
24
+ const targets = new Map<string, Target>();
25
+
26
+ for (const chain of Object.values(config.routes))
27
+ for (const target of chain) targets.set(targetKey(target), target);
28
+
29
+ return [...targets.values()];
30
+ }
31
+
32
+ /** Hash configuration and resolved registry references; never persist or display credential material. */
33
+ export function verificationFingerprint(
34
+ config: RouterConfig,
35
+ registry: RouterContext["modelRegistry"],
36
+ ) {
37
+ const targets = configuredTargets(config);
38
+ const providerIds = new Set(targets.map((target) => target.provider));
39
+
40
+ if (config.backend.auth.source === "pi") providerIds.add(config.backend.auth.provider);
41
+
42
+ const providers = [...providerIds].map((provider) => ({
43
+ provider,
44
+ // Pi replaces the composed provider when models.json/native registration changes.
45
+ identity: providerIdentity(registry.getProvider(provider)),
46
+ authSource: registry.getProviderAuthStatus(provider),
47
+ extensionConfig: registry.getRegisteredProviderConfig(provider),
48
+ }));
49
+
50
+ const models = targets.map((target) => ({
51
+ target,
52
+ model: registry.find(target.provider, target.model),
53
+ }));
54
+
55
+ return createHash("sha256").update(JSON.stringify({ config, providers, models })).digest("hex");
56
+ }
57
+
58
+ export interface VerifiedGeneration {
59
+ fingerprint: string;
60
+ passed: Set<string>;
61
+ checkedAt: string;
62
+ }