Skip to content

Commit 67660dc

Browse files
author
Wundercorp
committed
.
1 parent dea0d8f commit 67660dc

24 files changed

Lines changed: 4378 additions & 510 deletions

LOCAL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ curl http://127.0.0.1:8787/health
231231
curl http://127.0.0.1:8787/v1/gateways
232232
```
233233

234-
The protected endpoints continue to validate real RS256 access tokens from the configured issuer. For Cognito, set `AUTH_AUDIENCE` to the generated app client ID and obtain an access token whose `client_id` matches that value:
234+
The protected endpoints continue to validate real RS256 access tokens from the configured issuer. For Cognito, set `AUTH_AUDIENCE` to the same generated app client ID used by the web application and obtain an access token whose `client_id` matches that value. After changing the app client ID, sign out and sign in again so the browser does not reuse a token from the previous client:
235235

236236
```bash
237237
curl http://127.0.0.1:8787/v1/me \

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,21 +52,26 @@ om pull ollama://qwen2.5:3b
5252
om run qwen2.5:3b "Explain gateway interoperability."
5353
```
5454

55-
Start a local API:
55+
Start the local API. A default model is optional, so the dashboard can connect before anything is installed:
5656

5757
```bash
58-
om serve tinyllama --port 11435
58+
om serve --port 11435
5959
curl http://127.0.0.1:11435/v1/models
6060
```
6161

6262
The local server supports:
6363

6464
- `GET /health`
6565
- `GET /v1/models`
66+
- `GET /v1/model-catalog`
67+
- `POST /v1/models/install`
68+
- `GET /v1/model-installs/:jobId`
6669
- `POST /v1/chat/completions`
6770
- `GET /api/tags`
6871
- `POST /api/generate`
6972

73+
The authenticated web dashboard uses the catalog and install-job endpoints for a one-click starter-model download with local progress reporting. Installation requests are restricted to configured browser origins.
74+
7075
## Gateway interoperability
7176

7277
Gateways normalize external model catalogs and artifact sources into a stable descriptor. Runtimes are separate from gateways, so one gateway can feed multiple local runtimes and one runtime can execute models from multiple gateways.

apps/aws-api/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
"private": true,
55
"type": "module",
66
"scripts": {
7-
"build": "esbuild src/index.mjs --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.mjs",
7+
"build": "esbuild src/index.mjs --bundle --platform=node --target=node22 --format=esm --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\" --outfile=dist/index.mjs",
88
"check": "node --check src/index.mjs",
9-
"test": "node --test"
9+
"test": "node --test",
10+
"test:bundle": "node scripts/bundle-smoke.mjs"
1011
},
1112
"dependencies": {
1213
"@aws-sdk/client-dynamodb": "3.1084.0",
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import assert from 'node:assert/strict';
2+
3+
process.env.ALLOWED_ORIGINS = 'https://openmodel.sh';
4+
delete process.env.GATEWAY_REGISTRY_TABLE;
5+
6+
const bundleUrl = new URL(`../dist/index.mjs?smoke=${Date.now()}`, import.meta.url);
7+
const { handler } = await import(bundleUrl.href);
8+
9+
const healthResponse = await handler({
10+
rawPath: '/health',
11+
headers: { origin: 'https://openmodel.sh' },
12+
requestContext: { http: { method: 'GET' } }
13+
});
14+
15+
assert.equal(healthResponse.statusCode, 200);
16+
assert.deepEqual(JSON.parse(healthResponse.body), {
17+
status: 'ok',
18+
service: 'openmodel-aws-api'
19+
});
20+
21+
const gatewayResponse = await handler({
22+
rawPath: '/v1/gateways',
23+
headers: { origin: 'https://openmodel.sh' },
24+
requestContext: { http: { method: 'GET' } }
25+
});
26+
27+
assert.equal(gatewayResponse.statusCode, 200);
28+
assert.deepEqual(
29+
JSON.parse(gatewayResponse.body).data.map((gateway) => gateway.id),
30+
['huggingface', 'direct', 'ollama']
31+
);
32+
33+
console.log('Bundled Lambda smoke test passed.');

apps/aws-api/src/index.mjs

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -84,20 +84,21 @@ async function authenticate(authorizationHeader) {
8484
throw new HttpError(401, 'Token issuer did not match.');
8585
}
8686

87-
const expectedAudiences = requireEnvironmentVariable('AUTH_AUDIENCE')
87+
if (payload.token_use && payload.token_use !== 'access') {
88+
throw new HttpError(401, 'An access token is required.');
89+
}
90+
91+
const expectedClientIds = requireEnvironmentVariable('AUTH_AUDIENCE')
8892
.split(',')
8993
.map((value) => value.trim())
9094
.filter(Boolean);
91-
const presentedAudiences = [
92-
...(Array.isArray(payload.aud) ? payload.aud : [payload.aud]),
93-
payload.client_id,
94-
payload.azp
95-
].filter((value) => typeof value === 'string');
96-
if (!expectedAudiences.some((expectedAudience) => presentedAudiences.includes(expectedAudience))) {
97-
throw new HttpError(401, 'Token client or audience did not match.');
98-
}
99-
if (payload.token_use && payload.token_use !== 'access') {
100-
throw new HttpError(401, 'An access token is required.');
95+
const presentedClientId = typeof payload.client_id === 'string'
96+
? payload.client_id
97+
: typeof payload.azp === 'string'
98+
? payload.azp
99+
: undefined;
100+
if (!presentedClientId || !expectedClientIds.includes(presentedClientId)) {
101+
throw new HttpError(401, 'Token was issued for a different Cognito app client. Sign out and sign in again.');
101102
}
102103

103104
const currentUnixTime = Math.floor(Date.now() / 1000);

apps/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@wundercorp/openmodel",
3-
"version": "0.1.6",
3+
"version": "0.1.7",
44
"description": "Download, run, and serve local AI models through a gateway-first CLI with OpenAI- and Ollama-compatible APIs.",
55
"type": "module",
66
"bin": {

apps/cli/src/index.js

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import path from 'node:path';
21
import { mkdir, readFile, writeFile } from 'node:fs/promises';
32
import { parseArgs, getFlag } from './lib/args.js';
43
import { getPaths } from './lib/paths.js';
5-
import { downloadArtifact } from './lib/download.js';
64
import { readConfig, writeConfig } from './lib/config.js';
7-
import { findManifest, listManifests, removeManifest, safeModelId, saveManifest, setAlias } from './lib/model-store.js';
5+
import { installModel } from './lib/install-model.js';
6+
import { findManifest, listManifests, removeManifest } from './lib/model-store.js';
87
import { loadGateways, resolveReference } from './gateways/registry.js';
98
import { selectRuntime, runtimes } from './runtimes/index.js';
109
import { startLocalServer } from './server/http.js';
@@ -57,20 +56,10 @@ export async function main(argv) {
5756
async function pullCommand(positionals, flags) {
5857
const reference = positionals[0];
5958
if (!reference) throw new Error('Usage: om pull <reference> [--alias name]');
60-
const { gateway, model } = await resolveReference(reference);
61-
const paths = getPaths();
62-
const modelDirectory = path.join(paths.models, safeModelId(model.id));
63-
await mkdir(modelDirectory, { recursive: true });
64-
const artifactPaths = [];
65-
for (const artifact of model.artifacts) artifactPaths.push(await downloadArtifact(artifact, modelDirectory));
66-
if (model.native?.runtime === 'ollama') {
67-
const runtime = runtimes.find((candidate) => candidate.id === 'ollama');
68-
if (!(await runtime.available())) throw new Error('Ollama is required for ollama:// references.');
69-
await runtime.pull(model.native.model);
70-
}
71-
const manifest = await saveManifest(model, gateway.id, artifactPaths);
7259
const alias = getFlag(flags, 'alias');
73-
if (alias) await setAlias(String(alias), manifest.storedId);
60+
const manifest = await installModel(reference, {
61+
alias: alias ? String(alias) : undefined
62+
});
7463
process.stdout.write(`Installed ${manifest.storedId}${alias ? ` as ${alias}` : ''}.\n`);
7564
}
7665

apps/cli/src/lib/download.js

Lines changed: 121 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,47 +5,154 @@ import path from 'node:path';
55
import { pipeline } from 'node:stream/promises';
66
import { Readable, Transform } from 'node:stream';
77

8-
export async function downloadArtifact(artifact, destinationDirectory, signal) {
8+
export async function downloadArtifact(artifact, destinationDirectory, options = {}) {
9+
const normalizedOptions = normalizeDownloadOptions(options);
10+
const signal = normalizedOptions.signal;
11+
const onProgress = normalizedOptions.onProgress;
912
await mkdir(destinationDirectory, { recursive: true });
1013
const destinationPath = path.join(destinationDirectory, artifact.fileName);
1114
const temporaryPath = `${destinationPath}.partial`;
1215
let offset = 0;
13-
try { offset = (await stat(temporaryPath)).size; } catch {}
16+
try {
17+
offset = (await stat(temporaryPath)).size;
18+
} catch {}
19+
1420
const headers = { ...artifact.headers };
15-
if (offset > 0) headers.Range = `bytes=${offset}-`;
21+
if (offset > 0) {
22+
headers.Range = `bytes=${offset}-`;
23+
}
24+
1625
const response = await fetch(artifact.url, { headers, signal, redirect: 'follow' });
17-
if (!response.ok && response.status !== 206) throw new Error(`Download failed with HTTP ${response.status} for ${artifact.url}`);
18-
if (!response.body) throw new Error(`Download returned no body for ${artifact.url}`);
26+
if (!response.ok && response.status !== 206) {
27+
throw new Error(`Download failed with HTTP ${response.status} for ${artifact.url}`);
28+
}
29+
if (!response.body) {
30+
throw new Error(`Download returned no body for ${artifact.url}`);
31+
}
32+
1933
const append = response.status === 206 && offset > 0;
2034
if (!append) {
2135
offset = 0;
2236
await rm(temporaryPath, { force: true });
2337
}
24-
let received = offset;
25-
let lastPrinted = 0;
26-
const progress = new Transform({
38+
39+
const totalBytes = readTotalBytes(response, offset);
40+
let receivedBytes = offset;
41+
let lastReportedAt = 0;
42+
reportProgress(onProgress, {
43+
type: 'download-start',
44+
fileName: artifact.fileName,
45+
receivedBytes,
46+
totalBytes,
47+
progress: calculateProgress(receivedBytes, totalBytes),
48+
message: `Downloading ${artifact.fileName}.`
49+
});
50+
51+
const progressTransform = new Transform({
2752
transform(chunk, encoding, callback) {
28-
received += chunk.length;
29-
if (Date.now() - lastPrinted > 500) {
30-
process.stderr.write(`\rDownloading ${artifact.fileName}: ${(received / 1024 / 1024).toFixed(1)} MiB`);
31-
lastPrinted = Date.now();
53+
receivedBytes += chunk.length;
54+
const currentTime = Date.now();
55+
if (currentTime - lastReportedAt >= 150) {
56+
reportProgress(onProgress, {
57+
type: 'download-progress',
58+
fileName: artifact.fileName,
59+
receivedBytes,
60+
totalBytes,
61+
progress: calculateProgress(receivedBytes, totalBytes),
62+
message: `Downloading ${artifact.fileName}.`
63+
});
64+
if (!onProgress) {
65+
process.stderr.write(`\rDownloading ${artifact.fileName}: ${(receivedBytes / 1024 / 1024).toFixed(1)} MiB`);
66+
}
67+
lastReportedAt = currentTime;
3268
}
3369
callback(null, chunk);
3470
}
3571
});
36-
await pipeline(Readable.fromWeb(response.body), progress, createWriteStream(temporaryPath, { flags: append ? 'a' : 'w' }));
37-
process.stderr.write(`\rDownloading ${artifact.fileName}: ${(received / 1024 / 1024).toFixed(1)} MiB\n`);
72+
73+
await pipeline(
74+
Readable.fromWeb(response.body),
75+
progressTransform,
76+
createWriteStream(temporaryPath, { flags: append ? 'a' : 'w' })
77+
);
78+
79+
if (!onProgress) {
80+
process.stderr.write(`\rDownloading ${artifact.fileName}: ${(receivedBytes / 1024 / 1024).toFixed(1)} MiB\n`);
81+
}
82+
83+
reportProgress(onProgress, {
84+
type: 'download-complete',
85+
fileName: artifact.fileName,
86+
receivedBytes,
87+
totalBytes: totalBytes ?? receivedBytes,
88+
progress: 100,
89+
message: `Downloaded ${artifact.fileName}.`
90+
});
91+
3892
if (artifact.sha256) {
93+
reportProgress(onProgress, {
94+
type: 'verifying',
95+
fileName: artifact.fileName,
96+
receivedBytes,
97+
totalBytes: totalBytes ?? receivedBytes,
98+
progress: 100,
99+
message: `Verifying ${artifact.fileName}.`
100+
});
39101
const digest = await hashFile(temporaryPath);
40102
if (digest !== artifact.sha256) {
41103
await rm(temporaryPath, { force: true });
42104
throw new Error(`SHA-256 mismatch for ${artifact.fileName}.`);
43105
}
44106
}
107+
45108
await rename(temporaryPath, destinationPath);
46109
return destinationPath;
47110
}
48111

112+
function normalizeDownloadOptions(options) {
113+
if (
114+
options &&
115+
typeof options === 'object' &&
116+
typeof options.aborted === 'boolean' &&
117+
typeof options.addEventListener === 'function'
118+
) {
119+
return { signal: options, onProgress: undefined };
120+
}
121+
122+
return {
123+
signal: options?.signal,
124+
onProgress: typeof options?.onProgress === 'function' ? options.onProgress : undefined
125+
};
126+
}
127+
128+
function readTotalBytes(response, offset) {
129+
const contentRange = response.headers.get('content-range');
130+
const contentRangeMatch = contentRange?.match(/\/([0-9]+)$/);
131+
if (contentRangeMatch) {
132+
return Number(contentRangeMatch[1]);
133+
}
134+
135+
const contentLength = Number(response.headers.get('content-length'));
136+
if (!Number.isFinite(contentLength) || contentLength <= 0) {
137+
return undefined;
138+
}
139+
140+
return offset + contentLength;
141+
}
142+
143+
function calculateProgress(receivedBytes, totalBytes) {
144+
if (!Number.isFinite(totalBytes) || totalBytes <= 0) {
145+
return 0;
146+
}
147+
return Math.min(100, (receivedBytes / totalBytes) * 100);
148+
}
149+
150+
function reportProgress(onProgress, event) {
151+
if (onProgress) {
152+
onProgress(event);
153+
}
154+
}
155+
49156
async function hashFile(filePath) {
50157
const hash = createHash('sha256');
51158
await pipeline(createReadStream(filePath), hash);

0 commit comments

Comments
 (0)