Skip to content

Commit 22fd667

Browse files
authored
[codex] Add Lambda Labs cloud adapter (#726)
* Add Lambda Labs cloud adapter * Tighten Lambda Labs instance mapping * Require Lambda launch instance ID
1 parent 1be9efe commit 22fd667

9 files changed

Lines changed: 723 additions & 3 deletions

File tree

packages/cli/src/adapter-registry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export const CATEGORIES: readonly AdapterCategory[] = [
7878
id: 'cloud',
7979
pkgPrefix: '@profullstack/sh1pt-cloud',
8080
description: 'Raw-compute cloud providers — VPS, GPU, rollouts',
81-
adapters: ['atlantic', 'cloudflare', 'digitalocean', 'exe-dev', 'firebase', 'fly', 'hetzner', 'nvidia', 'railway', 'runpod', 'supabase', 'vultr'],
81+
adapters: ['atlantic', 'cloudflare', 'digitalocean', 'exe-dev', 'firebase', 'fly', 'hetzner', 'lambda-labs', 'nvidia', 'railway', 'runpod', 'supabase', 'vultr'],
8282
},
8383
{
8484
id: 'observability',

packages/cli/src/commands/scale.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ const PROVIDER_PRICING: Record<string, { hourly: number; spot: number }> = {
105105
'vultr': { hourly: 0.035, spot: 0.035 },
106106
'hetzner': { hourly: 0.028, spot: 0.028 },
107107
'runpod': { hourly: 0.34, spot: 0.17 },
108+
'lambda-labs': { hourly: 0.75, spot: 0.75 },
108109
'vast': { hourly: 0.25, spot: 0.12 },
109110
'latitude': { hourly: 0.60, spot: 0.30 },
110111
'crusoe': { hourly: 0.14, spot: 0.07 },
@@ -138,6 +139,7 @@ const DEFAULT_PRICING: Record<string, { label: string; hourly: number }> = {
138139
'cloud-digitalocean': { label: 'DigitalOcean (VPS)', hourly: 0.007 },
139140
'cloud-vultr': { label: 'Vultr (VPS)', hourly: 0.007 },
140141
'cloud-hetzner': { label: 'Hetzner Cloud (VPS)', hourly: 0.005 },
142+
'cloud-lambda-labs': { label: 'Lambda Labs (GPU)', hourly: 0.75 },
141143
'cloud-atlantic': { label: 'Atlantic.Net (VPS)', hourly: 0.008 },
142144
'cloud-railway': { label: 'Railway (hosting)', hourly: 0.017 },
143145
'cloud-cloudflare': { label: 'Cloudflare (Workers)', hourly: 0.0 },
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Lambda Labs (GPU Cloud)
2+
3+
Provides the Lambda Labs (GPU Cloud) cloud provider adapter for sh1pt scale and deploy workflows.
4+
5+
## What it does
6+
7+
- Connects cloud provider credentials and project settings.
8+
- Supports infrastructure planning, deployment, or status workflows where implemented.
9+
- Includes a connection flow for account or credential setup.
10+
- Includes setup guidance for required credentials or provider configuration.
11+
12+
## Package
13+
14+
- Name: `@profullstack/sh1pt-cloud-lambda-labs`
15+
- Path: `packages/cloud/lambda-labs`
16+
- Adapter ID: `cloud-lambda-labs`
17+
- Homepage: https://sh1pt.com
18+
19+
## Scripts
20+
21+
- `build`: `tsc -p tsconfig.json`
22+
- `prepublishOnly`: `pnpm build`
23+
- `typecheck`: `tsc -p tsconfig.json --noEmit`
24+
25+
## Usage
26+
27+
```bash
28+
pnpm add @profullstack/sh1pt-cloud-lambda-labs
29+
```
30+
31+
## Development
32+
33+
```bash
34+
pnpm --filter @profullstack/sh1pt-cloud-lambda-labs typecheck
35+
```
36+
37+
Run tests from the repository root when this module includes a test file:
38+
39+
```bash
40+
pnpm vitest run packages/cloud/lambda-labs/src/index.test.ts
41+
```
42+
43+
<!-- Generated by scripts/gen-module-readmes.mjs -->
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"name": "@profullstack/sh1pt-cloud-lambda-labs",
3+
"version": "0.1.15",
4+
"type": "module",
5+
"main": "./src/index.ts",
6+
"scripts": {
7+
"build": "tsc -p tsconfig.json",
8+
"typecheck": "tsc -p tsconfig.json --noEmit",
9+
"prepublishOnly": "pnpm build"
10+
},
11+
"dependencies": {
12+
"@profullstack/sh1pt-core": "workspace:*"
13+
},
14+
"license": "MIT",
15+
"repository": {
16+
"type": "git",
17+
"url": "git+https://github.com/profullstack/sh1pt.git",
18+
"directory": "packages/cloud/lambda-labs"
19+
},
20+
"homepage": "https://sh1pt.com",
21+
"bugs": "https://github.com/profullstack/sh1pt/issues",
22+
"files": [
23+
"dist"
24+
],
25+
"publishConfig": {
26+
"access": "public",
27+
"main": "./dist/index.js",
28+
"types": "./dist/index.d.ts",
29+
"exports": {
30+
".": {
31+
"types": "./dist/index.d.ts",
32+
"import": "./dist/index.js",
33+
"default": "./dist/index.js"
34+
}
35+
}
36+
}
37+
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { contractTestCloud } from '@profullstack/sh1pt-core/testing';
3+
import cloud from './index.js';
4+
5+
const sampleTypes = {
6+
data: {
7+
gpu_1x_a10: {
8+
instance_type: {
9+
name: 'gpu_1x_a10',
10+
description: '1x A10 (24 GB PCIe)',
11+
gpu_description: 'A10 (24 GB PCIe)',
12+
price_cents_per_hour: 75,
13+
specs: { vcpus: 30, memory_gib: 200, storage_gib: 1400, gpus: 1 },
14+
},
15+
regions_with_capacity_available: [{ name: 'us-west-1', description: 'US West' }],
16+
},
17+
gpu_1x_a100: {
18+
instance_type: {
19+
name: 'gpu_1x_a100',
20+
description: '1x A100 (40 GB PCIe)',
21+
gpu_description: 'A100 (40 GB PCIe)',
22+
price_cents_per_hour: 129,
23+
specs: { vcpus: 30, memory_gib: 200, storage_gib: 1400, gpus: 1 },
24+
},
25+
regions_with_capacity_available: [{ name: 'us-west-1', description: 'US West' }],
26+
},
27+
},
28+
};
29+
30+
const ctx = {
31+
secret: (key: string) => key === 'LAMBDA_CLOUD_API_KEY' ? 'test' : undefined,
32+
log: vi.fn(),
33+
dryRun: false,
34+
};
35+
36+
beforeEach(() => {
37+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(sampleTypes), { status: 200 })));
38+
});
39+
40+
afterEach(() => {
41+
vi.restoreAllMocks();
42+
});
43+
44+
contractTestCloud(cloud, {
45+
sampleConfig: { sshKeyNames: ['test-key'] },
46+
sampleSpec: { kind: 'gpu', gpu: { model: 'A10', count: 1 }, region: 'us-west-1' },
47+
requiredSecrets: ['LAMBDA_CLOUD_API_KEY'],
48+
});
49+
50+
describe('lambda-labs cloud adapter', () => {
51+
it('reports API errors with status and message', async () => {
52+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ error: { code: 'auth_failed', message: 'invalid key' } }), { status: 401 })));
53+
54+
await expect(cloud.connect(ctx, {})).rejects.toThrow('401 auth_failed');
55+
});
56+
57+
it('quotes the cheapest matching GPU type', async () => {
58+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(sampleTypes), { status: 200 })));
59+
60+
const quote = await cloud.quote(ctx, { kind: 'gpu', gpu: { model: 'A10', count: 1 }, region: 'us-west-1' }, {});
61+
62+
expect(quote).toMatchObject({
63+
hourly: 0.75,
64+
monthly: 547.5,
65+
currency: 'USD',
66+
provider: 'lambda-labs',
67+
sku: 'gpu_1x_a10',
68+
});
69+
});
70+
71+
it('does not match short GPU names inside larger model names', async () => {
72+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
73+
data: { gpu_1x_a100: sampleTypes.data.gpu_1x_a100 },
74+
}), { status: 200 })));
75+
76+
const quote = await cloud.quote(ctx, { kind: 'gpu', gpu: { model: 'A10', count: 1 }, region: 'us-west-1' }, {});
77+
78+
expect(quote.sku).toBe('none');
79+
expect(quote.hourly).toBe(0);
80+
});
81+
82+
it('dry-run provision never calls fetch', async () => {
83+
const fetchMock = vi.fn();
84+
vi.stubGlobal('fetch', fetchMock);
85+
86+
const instance = await cloud.provision({ ...ctx, dryRun: true }, { kind: 'gpu', gpu: { model: 'A10', count: 1 } }, {});
87+
88+
expect(fetchMock).not.toHaveBeenCalled();
89+
expect(instance).toMatchObject({ id: 'dry-run', kind: 'gpu', status: 'provisioning' });
90+
});
91+
92+
it('requires an SSH key name before billable launch', async () => {
93+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(sampleTypes), { status: 200 })));
94+
95+
await expect(cloud.provision(ctx, { kind: 'gpu', gpu: { model: 'A10', count: 1 } }, {})).rejects.toThrow('SSH key name');
96+
});
97+
98+
it('rejects multiple SSH key names instead of silently truncating them', async () => {
99+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(sampleTypes), { status: 200 })));
100+
101+
await expect(cloud.provision(
102+
ctx,
103+
{ kind: 'gpu', gpu: { model: 'A10', count: 1 } },
104+
{ sshKeyNames: ['key-one', 'key-two'] },
105+
)).rejects.toThrow('exactly one SSH key name');
106+
});
107+
108+
it('launches with the wrapped Lambda response shape', async () => {
109+
const fetchMock = vi.fn(async (url: string, _init?: RequestInit) => {
110+
if (url.endsWith('/instance-types')) {
111+
return new Response(JSON.stringify(sampleTypes), { status: 200 });
112+
}
113+
return new Response(JSON.stringify({ data: { instance_ids: ['0920582c'] } }), { status: 200 });
114+
});
115+
vi.stubGlobal('fetch', fetchMock);
116+
117+
const instance = await cloud.provision(
118+
ctx,
119+
{ kind: 'gpu', gpu: { model: 'A10', count: 1 }, region: 'us-west-1', maxHourlyPrice: 1, tags: ['team:infra', 'sh1pt'] },
120+
{ sshKeyNames: ['default-key'], tags: { app: 'sh1pt' } },
121+
);
122+
123+
expect(instance).toMatchObject({
124+
id: '0920582c',
125+
kind: 'gpu',
126+
status: 'provisioning',
127+
hourlyRate: 0.75,
128+
sku: 'gpu_1x_a10',
129+
});
130+
expect(fetchMock).toHaveBeenLastCalledWith(
131+
'https://cloud.lambda.ai/api/v1/instance-operations/launch',
132+
expect.objectContaining({
133+
method: 'POST',
134+
body: expect.stringContaining('"ssh_key_names":["default-key"]'),
135+
}),
136+
);
137+
const request = fetchMock.mock.calls.at(-1)?.[1] as RequestInit;
138+
expect(JSON.parse(String(request.body))).toMatchObject({
139+
tags: [
140+
{ key: 'app', value: 'sh1pt' },
141+
{ key: 'team', value: 'infra' },
142+
{ key: 'tag-2', value: 'sh1pt' },
143+
],
144+
});
145+
});
146+
147+
it('throws when launch succeeds without an instance id', async () => {
148+
const fetchMock = vi.fn(async (url: string) => {
149+
if (url.endsWith('/instance-types')) {
150+
return new Response(JSON.stringify(sampleTypes), { status: 200 });
151+
}
152+
return new Response(JSON.stringify({ data: { instance_ids: [] } }), { status: 200 });
153+
});
154+
vi.stubGlobal('fetch', fetchMock);
155+
156+
await expect(cloud.provision(
157+
ctx,
158+
{ kind: 'gpu', gpu: { model: 'A10', count: 1 }, region: 'us-west-1', maxHourlyPrice: 1 },
159+
{ sshKeyNames: ['default-key'] },
160+
)).rejects.toThrow('returned no instance ID');
161+
});
162+
163+
it('preserves Lambda instance timestamp fields in status responses', async () => {
164+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
165+
data: {
166+
id: '0920582c',
167+
status: 'active',
168+
ip: '198.51.100.2',
169+
private_ip: '10.0.2.100',
170+
created_at: '2026-06-13T19:30:00Z',
171+
ssh_key_names: ['default-key'],
172+
file_system_names: [],
173+
region: { name: 'us-west-1', description: 'US West' },
174+
instance_type: sampleTypes.data.gpu_1x_a10.instance_type,
175+
actions: {},
176+
tags: [{ key: 'team', value: 'infra' }],
177+
},
178+
}), { status: 200 })));
179+
180+
const instance = await cloud.status(ctx, '0920582c', {});
181+
182+
expect(instance).toMatchObject({
183+
id: '0920582c',
184+
status: 'running',
185+
createdAt: '2026-06-13T19:30:00Z',
186+
publicIp: '198.51.100.2',
187+
privateIp: '10.0.2.100',
188+
tags: ['team:infra'],
189+
});
190+
});
191+
192+
it('uses a stable unknown timestamp when Lambda omits creation time', async () => {
193+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
194+
data: {
195+
id: 'terminating-id',
196+
status: 'terminating',
197+
ssh_key_names: ['default-key'],
198+
file_system_names: [],
199+
region: { name: 'us-west-1', description: 'US West' },
200+
instance_type: sampleTypes.data.gpu_1x_a10.instance_type,
201+
actions: {},
202+
},
203+
}), { status: 200 })));
204+
205+
const instance = await cloud.status(ctx, 'terminating-id', {});
206+
207+
expect(instance).toMatchObject({
208+
id: 'terminating-id',
209+
status: 'stopped',
210+
createdAt: '1970-01-01T00:00:00.000Z',
211+
});
212+
});
213+
});

0 commit comments

Comments
 (0)