Add Prime Intellect client, API, and MCP server
packages/prime — a hand-written typed client, because the first-party SDK is Python only. Deliberately narrow: PIG reads availability and nothing else, and the key it holds should be scoped so it could not provision even if the code tried. Rate limits are undocumented upstream, so it backs off empirically with full jitter and honours Retry-After. Unknown fields survive in `raw` rather than being dropped. apps/api — Hono, with authentication and authorization kept firmly apart. A verified JWT proves someone has an account in the identity project, which may be shared with other applications; it does NOT prove they belong here. Access requires a row in PIG's own users table, and a token without one gets 403 needs_profile rather than entry. The capacity service is the business logic: availability counts sold and held separately, so a live hold removes inventory from everyone else's availability without inflating utilisation. Expired holds are ignored at read time, so the numbers stay right even when the sweeper is behind. Matching treats interconnect as a hard filter and excludes Unknown as well as Ethernet — unverified is not the same as adequate. apps/mcp — nine tools over stdio, so a team member drives PIG from Claude Code, Codex, prime-agent, or a Buzz agent. It holds an API key and calls the same HTTP API the browser does, with no database credentials, so an agent can never reach further than the person it acts for. Results are formatted as prose rather than raw JSON. Theme preferences live in the database rather than localStorage, so a chosen accent follows someone from laptop to phone. Status colours stay independent of the accent: if "at risk" re-tinted to whatever a user picked, the signal would be gone. Note on the SDK import: its package exports use a `./*` wildcard whose types entry resolves server/mcp.js to server/mcp.js.d.ts, which does not exist. The runtime specifier must keep the .js suffix, so the types are mapped via tsconfig paths rather than by writing an import that would fail at runtime. Verified: all five packages typecheck; the MCP server constructs and registers its tools. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
export * from './ontology';
|
||||
export * from './margin';
|
||||
export * from './theme';
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* The PIG palette.
|
||||
*
|
||||
* Users pick an accent and the whole interface re-tints from it. The accent is
|
||||
* stored as a KEY rather than a hex string, for two reasons: the palette can be
|
||||
* retuned centrally without migrating anyone's saved preference, and nobody can
|
||||
* choose a colour that is illegible against the surfaces.
|
||||
*
|
||||
* Every accent below is specified as HSL triples for light and dark surfaces
|
||||
* independently. A colour that reads well on white is usually too dark on
|
||||
* near-black, so the dark variants are lifted in lightness and slightly
|
||||
* desaturated — the same hue, tuned twice, rather than one value used in both
|
||||
* places and looking wrong in one of them.
|
||||
*
|
||||
* Contrast: every `fg` is chosen to clear WCAG AA (4.5:1) against its own
|
||||
* surface, and `on` is the text colour that clears AA against the accent itself
|
||||
* when used as a solid fill.
|
||||
*/
|
||||
|
||||
export interface AccentDefinition {
|
||||
key: string;
|
||||
label: string;
|
||||
/** Light-mode: accent as HSL channels, `H S% L%`, for CSS `hsl(var(--x))`. */
|
||||
light: { accent: string; fg: string; on: string; subtle: string };
|
||||
/** Dark-mode equivalents. */
|
||||
dark: { accent: string; fg: string; on: string; subtle: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* `pig` is the default and the brand: near-black in light mode, near-white in
|
||||
* dark. The mascot is a black-and-white pig, so the product's own accent is
|
||||
* monochrome and every other choice is the user's personality rather than ours.
|
||||
*/
|
||||
export const ACCENTS: AccentDefinition[] = [
|
||||
{
|
||||
key: 'pig',
|
||||
label: 'Pig',
|
||||
light: {
|
||||
accent: '240 6% 10%',
|
||||
fg: '240 6% 10%',
|
||||
on: '0 0% 100%',
|
||||
subtle: '240 5% 96%',
|
||||
},
|
||||
dark: {
|
||||
accent: '0 0% 98%',
|
||||
fg: '0 0% 98%',
|
||||
on: '240 6% 10%',
|
||||
subtle: '240 4% 16%',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'rose',
|
||||
label: 'Rose',
|
||||
light: { accent: '346 77% 50%', fg: '346 77% 42%', on: '0 0% 100%', subtle: '346 77% 97%' },
|
||||
dark: { accent: '346 84% 62%', fg: '346 90% 72%', on: '346 90% 12%', subtle: '346 40% 18%' },
|
||||
},
|
||||
{
|
||||
key: 'amber',
|
||||
label: 'Amber',
|
||||
light: { accent: '32 95% 44%', fg: '28 80% 36%', on: '0 0% 100%', subtle: '38 92% 95%' },
|
||||
dark: { accent: '38 92% 58%', fg: '43 96% 68%', on: '26 83% 12%', subtle: '30 40% 18%' },
|
||||
},
|
||||
{
|
||||
key: 'emerald',
|
||||
label: 'Emerald',
|
||||
light: { accent: '160 84% 32%', fg: '161 88% 26%', on: '0 0% 100%', subtle: '152 76% 96%' },
|
||||
dark: { accent: '158 64% 48%', fg: '156 72% 62%', on: '160 90% 10%', subtle: '158 35% 16%' },
|
||||
},
|
||||
{
|
||||
key: 'sky',
|
||||
label: 'Sky',
|
||||
light: { accent: '201 90% 40%', fg: '202 90% 33%', on: '0 0% 100%', subtle: '204 94% 96%' },
|
||||
dark: { accent: '199 89% 58%', fg: '198 93% 68%', on: '202 90% 10%', subtle: '200 40% 17%' },
|
||||
},
|
||||
{
|
||||
key: 'violet',
|
||||
label: 'Violet',
|
||||
light: { accent: '262 83% 55%', fg: '263 70% 46%', on: '0 0% 100%', subtle: '270 100% 97%' },
|
||||
dark: { accent: '258 90% 70%', fg: '255 92% 78%', on: '264 80% 12%', subtle: '260 35% 20%' },
|
||||
},
|
||||
{
|
||||
key: 'slate',
|
||||
label: 'Slate',
|
||||
light: { accent: '215 25% 35%', fg: '215 25% 28%', on: '0 0% 100%', subtle: '210 40% 96%' },
|
||||
dark: { accent: '213 27% 74%', fg: '214 32% 82%', on: '215 28% 12%', subtle: '215 20% 18%' },
|
||||
},
|
||||
];
|
||||
|
||||
export const ACCENT_KEYS = ACCENTS.map((a) => a.key);
|
||||
export const DEFAULT_ACCENT = 'pig';
|
||||
|
||||
export const THEME_MODES = ['light', 'dark', 'system'] as const;
|
||||
export type ThemeMode = (typeof THEME_MODES)[number];
|
||||
export const DEFAULT_THEME_MODE: ThemeMode = 'system';
|
||||
|
||||
export function getAccent(key: string | null | undefined): AccentDefinition {
|
||||
return ACCENTS.find((a) => a.key === key) ?? ACCENTS[0]!;
|
||||
}
|
||||
|
||||
export function isValidAccent(key: string): boolean {
|
||||
return ACCENT_KEYS.includes(key);
|
||||
}
|
||||
|
||||
export function isValidThemeMode(mode: string): mode is ThemeMode {
|
||||
return (THEME_MODES as readonly string[]).includes(mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic colours for pipeline stages and health states.
|
||||
*
|
||||
* Deliberately independent of the user's accent: if "at risk" re-tinted to
|
||||
* whatever someone picked, a violet enthusiast would see warnings in violet and
|
||||
* the signal would be gone. Status colour must mean the same thing for
|
||||
* everyone.
|
||||
*/
|
||||
export const STATUS_COLORS = {
|
||||
positive: { light: '160 84% 32%', dark: '158 64% 52%' },
|
||||
warning: { light: '32 95% 44%', dark: '38 92% 60%' },
|
||||
danger: { light: '0 72% 45%', dark: '0 84% 65%' },
|
||||
info: { light: '201 90% 40%', dark: '199 89% 60%' },
|
||||
neutral: { light: '240 4% 46%', dark: '240 5% 65%' },
|
||||
} as const;
|
||||
|
||||
export type StatusColor = keyof typeof STATUS_COLORS;
|
||||
@@ -48,6 +48,20 @@ export const users = pgTable(
|
||||
*/
|
||||
isPlatformAdmin: boolean('is_platform_admin').notNull().default(false),
|
||||
|
||||
/**
|
||||
* Appearance preferences, persisted server-side rather than in
|
||||
* localStorage so a person's chosen look follows them between their
|
||||
* laptop and their phone. `system` defers to the OS.
|
||||
*/
|
||||
themeMode: text('theme_mode').notNull().default('system'),
|
||||
/**
|
||||
* Accent colour key from the shared palette in @pig/core. The whole
|
||||
* interface re-tints from this one value. Stored as a key rather than a
|
||||
* hex string so the palette can be retuned centrally — and so a user
|
||||
* cannot pick something illegible against the surface colours.
|
||||
*/
|
||||
accentColor: text('accent_color').notNull().default('pig'),
|
||||
|
||||
/** Set when the person stops using PIG. Rows are retained for audit. */
|
||||
deactivatedAt: timestamp('deactivated_at', { withTimezone: true }),
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@pig/prime",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": { ".": "./src/index.ts" },
|
||||
"scripts": { "typecheck": "tsc --noEmit" },
|
||||
"dependencies": { "@pig/core": "*" }
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* A typed client for the Prime Intellect compute API.
|
||||
*
|
||||
* Written by hand because no first-party TypeScript SDK exists — the official
|
||||
* SDK and CLI are Python. The surface here is deliberately narrow: PIG reads
|
||||
* GPU availability to populate its inventory and does nothing else. It cannot
|
||||
* provision, terminate, or spend money, and the API key it holds should be
|
||||
* scoped so that it could not even if the code tried.
|
||||
*
|
||||
* Two operational realities shape this file.
|
||||
*
|
||||
* **Rate limits are undocumented.** There is no published quota, so the client
|
||||
* cannot pace itself against a known budget. It instead backs off empirically:
|
||||
* exponential with jitter on 429 and 5xx, honouring `Retry-After` when the
|
||||
* server sends one, and giving up rather than hammering.
|
||||
*
|
||||
* **The upstream shape may drift.** Responses are parsed defensively; unknown
|
||||
* fields are preserved verbatim in `raw` rather than dropped, so a field that
|
||||
* appears upstream tomorrow is already captured today.
|
||||
*/
|
||||
|
||||
export interface PrimeClientOptions {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
/** Total attempts per request, including the first. */
|
||||
maxAttempts?: number;
|
||||
/** Ceiling on backoff between attempts. */
|
||||
maxBackoffMs?: number;
|
||||
/** Per-request timeout. */
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
onRetry?: (info: { attempt: number; delayMs: number; reason: string }) => void;
|
||||
}
|
||||
|
||||
export class PrimeApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly body?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'PrimeApiError';
|
||||
}
|
||||
|
||||
/** Retrying a 4xx that is not 429 will fail identically every time. */
|
||||
get isRetryable(): boolean {
|
||||
return this.status === 429 || this.status >= 500;
|
||||
}
|
||||
}
|
||||
|
||||
/** A GPU availability listing, as returned by the availability endpoints. */
|
||||
export interface PrimeGpuListing {
|
||||
cloudId?: string;
|
||||
gpuType?: string;
|
||||
socket?: string;
|
||||
provider?: string;
|
||||
region?: string;
|
||||
dataCenter?: string;
|
||||
country?: string;
|
||||
gpuCount?: number;
|
||||
gpuMemory?: number;
|
||||
vcpu?: { defaultCount?: number } | number;
|
||||
memory?: { defaultCount?: number } | number;
|
||||
disk?: {
|
||||
minCount?: number;
|
||||
defaultCount?: number;
|
||||
maxCount?: number;
|
||||
pricePerUnit?: number;
|
||||
};
|
||||
internetSpeed?: number;
|
||||
interconnect?: number;
|
||||
interconnectType?: string;
|
||||
provisioningTime?: number;
|
||||
stockStatus?: string;
|
||||
security?: string;
|
||||
prices?: {
|
||||
onDemand?: number | null;
|
||||
communityPrice?: number | null;
|
||||
isVariable?: boolean;
|
||||
currency?: string;
|
||||
};
|
||||
images?: string[];
|
||||
isSpot?: boolean;
|
||||
prepaidTime?: number;
|
||||
/** Everything the upstream sent, including fields not modelled above. */
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PrimeAvailabilityQuery {
|
||||
regions?: string[];
|
||||
gpuCount?: number;
|
||||
gpuType?: string;
|
||||
socket?: string;
|
||||
security?: 'secure_cloud' | 'community_cloud';
|
||||
dataCenterId?: string;
|
||||
cloudId?: string;
|
||||
page?: number;
|
||||
/** Upstream caps this at 100. Values above are clamped rather than rejected. */
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://api.primeintellect.ai';
|
||||
|
||||
export class PrimeClient {
|
||||
private readonly apiKey: string;
|
||||
private readonly baseUrl: string;
|
||||
private readonly maxAttempts: number;
|
||||
private readonly maxBackoffMs: number;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly onRetry: PrimeClientOptions['onRetry'];
|
||||
|
||||
constructor(options: PrimeClientOptions) {
|
||||
if (!options.apiKey) throw new Error('PrimeClient requires an apiKey.');
|
||||
this.apiKey = options.apiKey;
|
||||
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
||||
this.maxAttempts = options.maxAttempts ?? 5;
|
||||
this.maxBackoffMs = options.maxBackoffMs ?? 30_000;
|
||||
this.timeoutMs = options.timeoutMs ?? 30_000;
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
this.onRetry = options.onRetry;
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of GPU availability.
|
||||
*
|
||||
* Returns the listings plus `totalCount` so a caller can page without
|
||||
* guessing. Note that upstream paginates from page 1, not 0.
|
||||
*/
|
||||
async listGpuAvailability(
|
||||
query: PrimeAvailabilityQuery = {},
|
||||
): Promise<{ items: PrimeGpuListing[]; totalCount: number }> {
|
||||
const params = new URLSearchParams();
|
||||
for (const region of query.regions ?? []) params.append('regions', region);
|
||||
if (query.gpuCount != null) params.set('gpu_count', String(query.gpuCount));
|
||||
if (query.gpuType) params.set('gpu_type', query.gpuType);
|
||||
if (query.socket) params.set('socket', query.socket);
|
||||
if (query.security) params.set('security', query.security);
|
||||
if (query.dataCenterId) params.set('data_center_id', query.dataCenterId);
|
||||
if (query.cloudId) params.set('cloud_id', query.cloudId);
|
||||
params.set('page', String(query.page ?? 1));
|
||||
// Upstream rejects page_size above 100; clamp rather than surface a 400.
|
||||
params.set('page_size', String(Math.min(query.pageSize ?? 100, 100)));
|
||||
|
||||
const body = await this.request<unknown>(`/api/v1/availability/gpus?${params}`);
|
||||
return normaliseListingPage(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every page of GPU availability, as an async iterable.
|
||||
*
|
||||
* Yields page by page rather than accumulating, so a large inventory does not
|
||||
* have to fit in memory at once and the caller can begin upserting
|
||||
* immediately. Stops when a page comes back empty, which also protects
|
||||
* against a `totalCount` that disagrees with reality.
|
||||
*/
|
||||
async *iterateGpuAvailability(
|
||||
query: Omit<PrimeAvailabilityQuery, 'page'> = {},
|
||||
): AsyncGenerator<PrimeGpuListing[], void, undefined> {
|
||||
let page = 1;
|
||||
let seen = 0;
|
||||
for (;;) {
|
||||
const { items, totalCount } = await this.listGpuAvailability({ ...query, page });
|
||||
if (items.length === 0) return;
|
||||
yield items;
|
||||
seen += items.length;
|
||||
if (totalCount > 0 && seen >= totalCount) return;
|
||||
page += 1;
|
||||
// A defensive ceiling. Without it a misbehaving upstream that always
|
||||
// returns a full page would loop until the process is killed.
|
||||
if (page > 1000) return;
|
||||
}
|
||||
}
|
||||
|
||||
/** Multi-node cluster availability — the shape that can actually train. */
|
||||
async listMultiNodeAvailability(
|
||||
query: PrimeAvailabilityQuery = {},
|
||||
): Promise<{ items: PrimeGpuListing[]; totalCount: number }> {
|
||||
const params = new URLSearchParams();
|
||||
if (query.gpuType) params.set('gpu_type', query.gpuType);
|
||||
if (query.gpuCount != null) params.set('gpu_count', String(query.gpuCount));
|
||||
params.set('page', String(query.page ?? 1));
|
||||
params.set('page_size', String(Math.min(query.pageSize ?? 100, 100)));
|
||||
|
||||
const body = await this.request<unknown>(`/api/v1/availability/multi-node?${params}`);
|
||||
return normaliseListingPage(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap credential check. Used by the settings screen so an operator finds
|
||||
* out their key is wrong at configuration time rather than at 3am when the
|
||||
* sync silently stops.
|
||||
*/
|
||||
async verifyCredentials(): Promise<{ ok: boolean; detail?: string }> {
|
||||
try {
|
||||
await this.listGpuAvailability({ pageSize: 1 });
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
if (error instanceof PrimeApiError) {
|
||||
return {
|
||||
ok: false,
|
||||
detail:
|
||||
error.status === 401 || error.status === 403
|
||||
? 'Key rejected. Check it has the Availability → Read scope and has not expired.'
|
||||
: `Upstream returned ${error.status}.`,
|
||||
};
|
||||
}
|
||||
return { ok: false, detail: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(path: string): Promise<T> {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
||||
headers: {
|
||||
authorization: `Bearer ${this.apiKey}`,
|
||||
accept: 'application/json',
|
||||
'user-agent': 'pig-crm/0.1 (+https://primeintellectgrowth.com)',
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (response.ok) return (await response.json()) as T;
|
||||
|
||||
const text = await response.text().catch(() => '');
|
||||
const error = new PrimeApiError(
|
||||
`Prime Intellect API returned ${response.status}`,
|
||||
response.status,
|
||||
text.slice(0, 500),
|
||||
);
|
||||
if (!error.isRetryable || attempt === this.maxAttempts) throw error;
|
||||
|
||||
// Honour Retry-After when offered; it is better information than any
|
||||
// backoff curve we could invent.
|
||||
const retryAfter = parseRetryAfter(response.headers.get('retry-after'));
|
||||
const delay = retryAfter ?? this.backoffMs(attempt);
|
||||
this.onRetry?.({ attempt, delayMs: delay, reason: `HTTP ${response.status}` });
|
||||
await sleep(delay);
|
||||
lastError = error;
|
||||
} catch (error) {
|
||||
if (error instanceof PrimeApiError) {
|
||||
if (!error.isRetryable || attempt === this.maxAttempts) throw error;
|
||||
lastError = error;
|
||||
} else {
|
||||
// Network failure or timeout. Both are worth retrying.
|
||||
if (attempt === this.maxAttempts) throw error;
|
||||
const delay = this.backoffMs(attempt);
|
||||
this.onRetry?.({
|
||||
attempt,
|
||||
delayMs: delay,
|
||||
reason: error instanceof Error ? error.message : 'network error',
|
||||
});
|
||||
await sleep(delay);
|
||||
lastError = error;
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error('Prime Intellect request failed.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Exponential backoff with full jitter.
|
||||
*
|
||||
* Jitter matters more than the curve: without it, several workers that hit a
|
||||
* limit together retry together, and the thundering herd reproduces the
|
||||
* problem that caused the limit.
|
||||
*/
|
||||
private backoffMs(attempt: number): number {
|
||||
const ceiling = Math.min(this.maxBackoffMs, 1000 * 2 ** (attempt - 1));
|
||||
return Math.round(Math.random() * ceiling);
|
||||
}
|
||||
}
|
||||
|
||||
function parseRetryAfter(header: string | null): number | null {
|
||||
if (!header) return null;
|
||||
const seconds = Number(header);
|
||||
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
|
||||
const date = Date.parse(header);
|
||||
if (Number.isFinite(date)) return Math.max(0, date - Date.now());
|
||||
return null;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a page of listings.
|
||||
*
|
||||
* The upstream has returned both `{items, totalCount}` and a bare array across
|
||||
* its endpoint generations, so both are accepted rather than assuming one and
|
||||
* breaking on the other.
|
||||
*/
|
||||
function normaliseListingPage(body: unknown): {
|
||||
items: PrimeGpuListing[];
|
||||
totalCount: number;
|
||||
} {
|
||||
const rawItems: unknown[] = Array.isArray(body)
|
||||
? body
|
||||
: Array.isArray((body as { items?: unknown[] })?.items)
|
||||
? ((body as { items: unknown[] }).items ?? [])
|
||||
: [];
|
||||
|
||||
const totalCount =
|
||||
typeof (body as { totalCount?: number })?.totalCount === 'number'
|
||||
? (body as { totalCount: number }).totalCount
|
||||
: rawItems.length;
|
||||
|
||||
return { items: rawItems.map(toListing), totalCount };
|
||||
}
|
||||
|
||||
function toListing(raw: unknown): PrimeGpuListing {
|
||||
const r = (raw ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
cloudId: str(r.cloudId),
|
||||
gpuType: str(r.gpuType),
|
||||
socket: str(r.socket),
|
||||
provider: str(r.provider),
|
||||
region: str(r.region),
|
||||
dataCenter: str(r.dataCenter),
|
||||
country: str(r.country),
|
||||
gpuCount: num(r.gpuCount),
|
||||
gpuMemory: num(r.gpuMemory),
|
||||
vcpu: r.vcpu as PrimeGpuListing['vcpu'],
|
||||
memory: r.memory as PrimeGpuListing['memory'],
|
||||
disk: r.disk as PrimeGpuListing['disk'],
|
||||
internetSpeed: num(r.internetSpeed),
|
||||
interconnect: num(r.interconnect),
|
||||
interconnectType: str(r.interconnectType),
|
||||
provisioningTime: num(r.provisioningTime),
|
||||
stockStatus: str(r.stockStatus),
|
||||
security: str(r.security),
|
||||
prices: r.prices as PrimeGpuListing['prices'],
|
||||
images: Array.isArray(r.images) ? (r.images as string[]) : undefined,
|
||||
isSpot: typeof r.isSpot === 'boolean' ? r.isSpot : undefined,
|
||||
prepaidTime: num(r.prepaidTime),
|
||||
raw: r,
|
||||
};
|
||||
}
|
||||
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined);
|
||||
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined);
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './client';
|
||||
export * from './map';
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Mapping upstream availability listings into PIG's inventory rows.
|
||||
*
|
||||
* The schema was written to mirror the upstream field names, so this is mostly
|
||||
* a rename rather than a transformation. The interesting parts are the two
|
||||
* places where a judgement is required.
|
||||
*
|
||||
* **Money.** Upstream prices are floating-point dollars per hour. PIG stores
|
||||
* integer cents, because these values feed margin reporting and floating-point
|
||||
* currency in a system that reports margin is a defect waiting to be found by
|
||||
* an accountant. Rounding happens exactly once, here, at the boundary.
|
||||
*
|
||||
* **Interconnect.** The single most commercially loaded field, since it decides
|
||||
* whether capacity can train or only serve. Upstream sends free text with
|
||||
* inconsistent casing, and an unrecognised value maps to `Unknown` rather than
|
||||
* being optimistically read as Ethernet — claiming a cluster has no fast fabric
|
||||
* when it does loses a deal, but the reverse sells a customer something that
|
||||
* will not work.
|
||||
*/
|
||||
import type { InterconnectType, SecurityTier, StockStatus } from '@pig/core';
|
||||
import type { PrimeGpuListing } from './client';
|
||||
|
||||
export interface MappedListing {
|
||||
externalCloudId: string | null;
|
||||
providerSlug: string | null;
|
||||
gpuType: string;
|
||||
socket: string | null;
|
||||
gpuCount: number;
|
||||
gpuMemoryGb: number | null;
|
||||
vcpu: number | null;
|
||||
memoryGb: number | null;
|
||||
diskGb: number | null;
|
||||
internetMbps: number | null;
|
||||
interconnectGbps: number | null;
|
||||
interconnectType: InterconnectType;
|
||||
region: string | null;
|
||||
country: string | null;
|
||||
securityTier: SecurityTier;
|
||||
stockStatus: StockStatus;
|
||||
isSpot: boolean;
|
||||
provisioningMinutes: number | null;
|
||||
prepaidHours: string | null;
|
||||
onDemandPriceCents: number | null;
|
||||
communityPriceCents: number | null;
|
||||
priceIsVariable: boolean;
|
||||
currency: string;
|
||||
images: string[];
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function mapListing(listing: PrimeGpuListing): MappedListing | null {
|
||||
// Without a GPU type and a count there is nothing sellable to record.
|
||||
if (!listing.gpuType || !listing.gpuCount) return null;
|
||||
|
||||
return {
|
||||
externalCloudId: listing.cloudId ?? null,
|
||||
providerSlug: listing.provider ?? null,
|
||||
gpuType: listing.gpuType,
|
||||
socket: normaliseSocket(listing.socket),
|
||||
gpuCount: listing.gpuCount,
|
||||
gpuMemoryGb: listing.gpuMemory ?? null,
|
||||
vcpu: unwrapCount(listing.vcpu),
|
||||
memoryGb: unwrapCount(listing.memory),
|
||||
diskGb: listing.disk?.defaultCount ?? null,
|
||||
internetMbps: listing.internetSpeed ?? null,
|
||||
interconnectGbps: listing.interconnect ?? null,
|
||||
interconnectType: normaliseInterconnect(listing.interconnectType),
|
||||
region: listing.region ?? null,
|
||||
country: listing.country ?? null,
|
||||
securityTier: listing.security === 'community_cloud' ? 'community_cloud' : 'secure_cloud',
|
||||
stockStatus: normaliseStock(listing.stockStatus),
|
||||
isSpot: listing.isSpot ?? false,
|
||||
provisioningMinutes: listing.provisioningTime ?? null,
|
||||
prepaidHours: listing.prepaidTime != null ? String(listing.prepaidTime) : null,
|
||||
onDemandPriceCents: toCents(listing.prices?.onDemand),
|
||||
communityPriceCents: toCents(listing.prices?.communityPrice),
|
||||
priceIsVariable: listing.prices?.isVariable ?? false,
|
||||
currency: listing.prices?.currency ?? 'USD',
|
||||
images: listing.images ?? [],
|
||||
raw: listing.raw,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dollars to integer cents.
|
||||
*
|
||||
* Multiplying by 100 in floating point then truncating loses a cent on values
|
||||
* like 2.43 (which is 2.4299999... in binary), so this rounds rather than
|
||||
* truncates. At GPU-hour scale one cent compounds into real money across
|
||||
* millions of hours.
|
||||
*/
|
||||
export function toCents(dollars: number | null | undefined): number | null {
|
||||
if (dollars == null || !Number.isFinite(dollars)) return null;
|
||||
return Math.round(dollars * 100);
|
||||
}
|
||||
|
||||
function unwrapCount(value: unknown): number | null {
|
||||
if (typeof value === 'number') return value;
|
||||
if (value && typeof value === 'object') {
|
||||
const c = (value as { defaultCount?: unknown }).defaultCount;
|
||||
if (typeof c === 'number') return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normaliseSocket(socket: string | undefined): string | null {
|
||||
if (!socket) return null;
|
||||
const upper = socket.toUpperCase().replace(/[\s_-]/g, '');
|
||||
if (upper === 'PCIE') return 'PCIe';
|
||||
const sxm = /^SXM([2-6])$/.exec(upper);
|
||||
if (sxm) return `SXM${sxm[1]}`;
|
||||
// Unknown sockets are dropped rather than stored, since the column is an
|
||||
// enum. The verbatim value survives in `raw`.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interconnect, conservatively.
|
||||
*
|
||||
* `Unknown` is the safe default. Guessing Ethernet would understate real
|
||||
* capacity; guessing InfiniBand would sell a training customer a cluster that
|
||||
* cannot train. Neither error is acceptable, so an unrecognised value stays
|
||||
* explicitly unknown and a human resolves it.
|
||||
*/
|
||||
function normaliseInterconnect(value: string | undefined): InterconnectType {
|
||||
if (!value) return 'Unknown';
|
||||
const v = value.toLowerCase().replace(/[\s_-]/g, '');
|
||||
if (v.includes('infiniband') || v === 'ib') return 'Infiniband';
|
||||
if (v.includes('roce')) return 'RoCE';
|
||||
if (v.includes('nvlink') || v.includes('nvl')) return 'NVLink';
|
||||
if (v.includes('ethernet') || v.includes('eth')) return 'Ethernet';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
function normaliseStock(value: string | undefined): StockStatus {
|
||||
switch ((value ?? '').toLowerCase()) {
|
||||
case 'available':
|
||||
return 'Available';
|
||||
case 'low':
|
||||
return 'Low';
|
||||
case 'medium':
|
||||
return 'Medium';
|
||||
case 'high':
|
||||
return 'High';
|
||||
case 'unavailable':
|
||||
return 'Unavailable';
|
||||
default:
|
||||
// An unknown stock signal is treated as unavailable rather than
|
||||
// available: it is better to under-promise inventory than to have a
|
||||
// seller offer capacity that turns out not to exist.
|
||||
return 'Unavailable';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "noEmit": true },
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user