// Pure, side-effect-free core of the extension: endpoint resolution, provider config assembly, // and balance-response interpretation. Kept separate from index.ts (the wiring) so every branch // is unit-testable without a live pi runtime or a network call. import type { ProviderConfig, ProviderModelConfig } from "@earendil-works/pi-coding-agent"; // The env var holding the raw nullsink bearer key ("0sink_..."). Referenced by the provider configs // as "$NULLSINK_API_KEY" (pi interpolates it at request time) and read directly by the /nullsink // balance command (which needs the raw value to call GET /balance). export const API_KEY_ENV = "NULLSINK_API_KEY"; // Optional override for self-hosted / forked deployments. Unset => the public instance. export const BASE_URL_ENV = "NULLSINK_BASE_URL"; // The public instance. A self-host sets NULLSINK_BASE_URL to its own origin. export const NULLSINK_DEFAULT_BASE_URL = "https://nullsink.is"; // The provider registration keys. Three providers because nullsink speaks two wire formats and // groups models into trust tiers; splitting them mirrors the /models page and keeps the /model // picker legible. export const PROVIDER_IDS = { anthropic: "nullsink", openai: "nullsink-openai", tinfoil: "nullsink-tinfoil", } as const; export interface Endpoints { /** Site root, e.g. "https://nullsink.is". The Anthropic SDK appends "/v1/messages". */ site: string; /** OpenAI base, e.g. "https://nullsink.is/v1". The OpenAI SDK appends "/chat/completions". */ openai: string; /** Balance endpoint, e.g. "https://nullsink.is/balance". */ balance: string; } // Normalize an override to the site root: trim, drop trailing slashes, and tolerate a caller who // passes the OpenAI base ("…/v1") by stripping a single trailing "/v1". Empty/absent => default. export function resolveEndpoints(baseUrlOverride?: string | null): Endpoints { const raw = (baseUrlOverride ?? "").trim(); let site = raw.length > 0 ? raw : NULLSINK_DEFAULT_BASE_URL; site = site.replace(/\/+$/, "").replace(/\/v1$/, ""); return { site, openai: `${site}/v1`, balance: `${site}/balance` }; } // The shape of src/models.json (generated by scripts/sync-models.ts). `input` is a plain string[] // in JSON; toModelConfig narrows it to the ("text"|"image")[] pi wants. export interface RawModel { id: string; name: string; reasoning: boolean; input: string[]; cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; contextWindow: number; maxTokens: number; } export interface ModelsFile { providers: { anthropic: RawModel[]; openai: RawModel[]; tinfoil: RawModel[] }; } function toModelConfig(m: RawModel): ProviderModelConfig { const input = m.input.filter((x): x is "text" | "image" => x === "text" || x === "image"); return { id: m.id, name: m.name, reasoning: m.reasoning, input: input.length > 0 ? input : ["text"], cost: m.cost, contextWindow: m.contextWindow, maxTokens: m.maxTokens, }; } export interface NamedProvider { name: string; config: ProviderConfig; } // Build the three provider registrations. The apiKey is an env reference ("$NULLSINK_API_KEY") so // pi resolves it per request and the raw key never lands in config/state. Anthropic and GPT-family // models use their native APIs; Tinfoil stays on the OpenAI-compatible chat-completions surface. export function buildProviders(models: ModelsFile, endpoints: Endpoints): NamedProvider[] { const apiKey = `$${API_KEY_ENV}`; return [ { name: PROVIDER_IDS.anthropic, config: { name: "nullsink · Anthropic", baseUrl: endpoints.site, apiKey, api: "anthropic-messages", models: models.providers.anthropic.map(toModelConfig), }, }, { name: PROVIDER_IDS.openai, config: { name: "nullsink · OpenAI", baseUrl: endpoints.openai, apiKey, api: "openai-responses", models: models.providers.openai.map(toModelConfig), }, }, { name: PROVIDER_IDS.tinfoil, config: { name: "nullsink · Tinfoil", baseUrl: endpoints.openai, apiKey, api: "openai-completions", models: models.providers.tinfoil.map(toModelConfig), }, }, ]; } // Fixed en-US currency formatting so output is stable regardless of the runtime locale. const USD_FMT = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }); export type BalanceKind = "ok" | "unknown" | "error"; export interface BalanceResult { kind: BalanceKind; balanceUsd?: number; message: string; } // Interpret a GET /balance response. 401 is deliberately ambiguous on nullsink's side (wrong key, // or a right key whose deposit hasn't confirmed) — surface that rather than asserting "invalid". export function interpretBalance(status: number, body: unknown): BalanceResult { if (status === 401) { return { kind: "unknown", message: "Key unknown or unfunded — wrong key, or a deposit that hasn't confirmed yet.", }; } if (status < 200 || status >= 300) { return { kind: "error", message: `Balance check failed (HTTP ${status}).` }; } const usd = body !== null && typeof body === "object" && "balance_usd" in body ? body.balance_usd : undefined; if (typeof usd !== "number" || !Number.isFinite(usd)) { return { kind: "error", message: "Malformed balance response from server." }; } return { kind: "ok", balanceUsd: usd, message: `Balance: ${USD_FMT.format(usd)}` }; } // --- persistent config + status rendering (pure) ---------------------------- // The store.ts I/O layer parses raw JSON through parseConfigV2 and renders through the helpers // below; index.ts wires them to pi. Everything here is pure so the precedence + render matrix is // unit-testable without a live runtime. // How the balance readout is surfaced. `both` shows the footer line AND the widget; `off` hides both. export const DISPLAY_MODES = ["statusline", "widget", "both", "off"] as const; export type DisplayMode = (typeof DISPLAY_MODES)[number]; export function isDisplayMode(x: unknown): x is DisplayMode { return typeof x === "string" && (DISPLAY_MODES as readonly string[]).includes(x); } // --- persistent config, schema v2 (profiles) -------------------------------- export interface PendingOrder { hash: string; // sha256 of the profile's token baseUrl: string; // instance the quote came from — mismatch drops the order creditUsd: number; rail: string; // server rail id, e.g. "monero" unit: string; // display ticker, e.g. "XMR" payTo: string; amount: string; // VERBATIM coin string — display as-is payUri: string; expiresAt: number; // epoch ms (pay-by deadline) createdAt: number; // epoch ms (drives the 24h backstop) } export interface Profile { apiKey?: string; pendingOrder?: PendingOrder } export interface ProviderToggles { anthropic: boolean; openai: boolean; tinfoil: boolean } export interface StoredConfigV2 { version: 2; activeProfile: string; profiles: Record; baseUrl?: string; display?: DisplayMode; defaultModel?: string; thinkingLevel?: string; providers?: ProviderToggles; lowBalanceUsd?: number; spendWarnUsd?: number; showSpend?: boolean; refreshSeconds?: number; setupDone?: boolean; /** Unknown top-level fields, preserved across load→save (forward compatibility). */ extra?: Record; } export const DEFAULTS = { lowBalanceUsd: 1, refreshSeconds: 60, display: "statusline", providers: { anthropic: true, openai: true, tinfoil: true }, } as const; const KNOWN_KEYS = new Set([ "version", "activeProfile", "profiles", "baseUrl", "display", "defaultModel", "thinkingLevel", "providers", "lowBalanceUsd", "spendWarnUsd", "showSpend", "refreshSeconds", "setupDone", // v1 keys, consumed by migration: "apiKey", // retired keys — recognized so they are dropped on save, never preserved as unknowns: "incognito", ]); const str = (x: unknown): string | undefined => (typeof x === "string" && x.trim() ? x : undefined); // v1 parity: apiKey/baseUrl load trimmed (v1's parser did — a pasted key with stray whitespace // must still authenticate). Other string fields keep str()'s verbatim value. const trimmedStr = (x: unknown): string | undefined => str(x)?.trim(); const num = (x: unknown): number | undefined => (typeof x === "number" && Number.isFinite(x) ? x : undefined); const bool = (x: unknown): boolean | undefined => (typeof x === "boolean" ? x : undefined); function parsePendingOrder(x: unknown): PendingOrder | undefined { if (typeof x !== "object" || x === null) return undefined; const o = x as Record; const hash = str(o.hash), baseUrl = str(o.baseUrl), rail = str(o.rail), unit = str(o.unit); const payTo = str(o.payTo), amount = str(o.amount), payUri = str(o.payUri); const creditUsd = num(o.creditUsd), expiresAt = num(o.expiresAt), createdAt = num(o.createdAt); if (!hash || !baseUrl || !rail || !unit || !payTo || !amount || !payUri) return undefined; if (creditUsd === undefined || expiresAt === undefined || createdAt === undefined) return undefined; return { hash, baseUrl, creditUsd, rail, unit, payTo, amount, payUri, expiresAt, createdAt }; } function parseProfile(x: unknown): Profile { if (typeof x !== "object" || x === null) return {}; const o = x as Record; const p: Profile = {}; const apiKey = trimmedStr(o.apiKey); if (apiKey) p.apiKey = apiKey; const order = parsePendingOrder(o.pendingOrder); if (order) p.pendingOrder = order; return p; } function parseProviders(x: unknown): ProviderToggles | undefined { if (typeof x !== "object" || x === null) return undefined; const o = x as Record; return { anthropic: bool(o.anthropic) ?? true, openai: bool(o.openai) ?? true, tinfoil: bool(o.tinfoil) ?? true, }; } export function emptyConfigV2(): StoredConfigV2 { return { version: 2, activeProfile: "default", profiles: {} }; } // Parse any historical shape. v1 ({ apiKey, baseUrl, display, setupDone }) migrates into // profiles.default. Wrong-typed fields degrade to absent — a hand-edited file never bricks load. export function parseConfigV2(raw: unknown): StoredConfigV2 | null { if (typeof raw !== "object" || raw === null) return null; const o = raw as Record; const cfg = emptyConfigV2(); if (typeof o.profiles === "object" && o.profiles !== null) { for (const [name, p] of Object.entries(o.profiles as Record)) { const clean = str(name); if (clean) cfg.profiles[clean] = parseProfile(p); } } // v1 migration: a top-level apiKey becomes profiles.default (v2 files never carry one). const v1Key = trimmedStr(o.apiKey); if (v1Key && !cfg.profiles.default?.apiKey) { cfg.profiles.default = { ...cfg.profiles.default, apiKey: v1Key }; } const active = str(o.activeProfile); cfg.activeProfile = active && Object.hasOwn(cfg.profiles, active) ? active : "default"; cfg.baseUrl = trimmedStr(o.baseUrl); cfg.display = isDisplayMode(o.display) ? o.display : undefined; cfg.defaultModel = str(o.defaultModel); cfg.thinkingLevel = str(o.thinkingLevel); cfg.providers = parseProviders(o.providers); cfg.lowBalanceUsd = num(o.lowBalanceUsd); cfg.spendWarnUsd = num(o.spendWarnUsd); cfg.showSpend = bool(o.showSpend); cfg.refreshSeconds = num(o.refreshSeconds); cfg.setupDone = bool(o.setupDone); const extra: Record = {}; for (const [k, v] of Object.entries(o)) if (!KNOWN_KEYS.has(k)) extra[k] = v; if (Object.keys(extra).length > 0) cfg.extra = extra; return cfg; } // Disk shape: defined fields + preserved unknowns at top level; `extra` itself never serialized. export function serializeConfigV2(cfg: StoredConfigV2): Record { const { extra, ...rest } = cfg; const out: Record = { ...extra, ...rest }; for (const [k, v] of Object.entries(out)) if (v === undefined) delete out[k]; return out; } export function activeProfile(cfg: StoredConfigV2): Profile { return Object.hasOwn(cfg.profiles, cfg.activeProfile) ? cfg.profiles[cfg.activeProfile]! : {}; } export function clampRefreshSeconds(n: number): number { if (!Number.isFinite(n)) return DEFAULTS.refreshSeconds; return Math.max(15, Math.round(n)); } // Mask a key for display: keep the public "0sink_" prefix + last 4 chars, hide the 43-char secret // middle. Short/odd values collapse to just the tail (or "…") so nothing sensitive leaks. export function maskKey(key: string): string { const k = key.trim(); if (k.length <= 4) return "…"; if (k.length <= 10) return `…${k.slice(-4)}`; return `${k.slice(0, 6)}…${k.slice(-4)}`; } // Effective base URL string to feed resolveEndpoints: env override wins, then the saved file value, // then undefined (resolveEndpoints applies the public default). Shared by startup, the config menu // display, and the base-URL editor so all three agree on precedence. export function resolveBaseUrlValue(envUrl?: string | null, fileUrl?: string | null): string | undefined { return envUrl?.trim() || fileUrl?.trim() || undefined; } export interface OrderReadout { phase: "waiting" | "confirming" | "finalizing"; confirmations?: number; required?: number; } export const formatUsd = (n: number): string => USD_FMT.format(n); export function renderOrderSegment(o: OrderReadout): string { if (o.phase === "confirming" && o.confirmations !== undefined && o.required !== undefined) { return `⧗ confirming ${o.confirmations}/${o.required}`; } return `⧗ ${o.phase}`; } export interface StatusState { configured: boolean; balance?: BalanceResult; loading?: boolean; lowBalanceUsd: number; order?: OrderReadout; spendUsd?: number; } // Core readout: no key → balance (low vs ok) → 401 unfunded → error → mid-fetch → not-yet-fetched. // BalanceResult's real shape is { kind, balanceUsd?, message } (src/config.ts) — amounts are // formatted HERE via USD_FMT, never read from a display field (none exists). function renderCore(s: StatusState): string { if (!s.configured) return "○ no key · /nullsink setup"; const b = s.balance; if (b?.kind === "ok" && b.balanceUsd !== undefined) { const usd = USD_FMT.format(b.balanceUsd); return b.balanceUsd < s.lowBalanceUsd ? `⚠ ${usd} · top up` : `● ${usd}`; } if (b?.kind === "unknown") return "⚠ unfunded · /nullsink topup"; if (b?.kind === "error") return "⚠ balance unavailable"; return s.loading ? "… checking balance" : "● balance not checked"; } export function renderStatusLine(s: StatusState): string { const parts = [`nullsink ${renderCore(s)}`]; if (s.spendUsd !== undefined) parts.push(`spent ${USD_FMT.format(s.spendUsd)}`); if (s.order) parts.push(renderOrderSegment(s.order)); return parts.join(" · "); } export function renderWidget(s: StatusState): string[] { return [renderStatusLine(s), " /nullsink — settings · wallet · models"]; }