@vmlts/angular
v0.1.1
Published
vmlts from Angular: files, prompts and generations on one key
Downloads
28
Readme
@vmlts/angular
Read a prompt, upload a file and generate an image from an Angular application, with one API key. The operator changes the words in the dashboard, and your application sees them without a deploy. Angular 13 to current, standalone or NgModule.
Day 0
One thing comes from whoever runs vmlts for you: an API key. It names your app, and it stays on your server.
1. Install.
bun add @vmlts/angular # or: npm i @vmlts/angular2. Add one route to your backend, which forwards to prompter.
The browser never holds the API key, so the browser never talks to prompter directly. It talks to your own origin, and your backend attaches the key.
browser your backend prompt Worker
─────── ──────────── ─────────────
GET /api/vmlts/prompt/v1/resolve/support/greeting
├──── same origin ────────────► add x-api-key
│ no token ├── GET /v1/resolve/... ──► the prompt
◄────────────────────────────── ◄── {key, content, ...} ──Anything under /api/vmlts/prompt/v1/ forwards unchanged: same paths, same
query parameters.
// One route. Hono, Express and Nest all shape the same.
app.all("/api/vmlts/prompt/v1/*", (c) => {
const url = new URL(c.req.url);
url.host = "prompts.vmlts.com";
url.pathname = url.pathname.replace("/api/vmlts/prompt", "");
return fetch(new Request(url, c.req.raw), {
headers: { ...c.req.header(), "x-api-key": process.env.VMLTS_TOKEN! },
});
});That route is also where you decide which prompts a visitor may read: the key is the leading path segment, so it is a string comparison.
Filestore takes the same route against cdn.vmlts.com, on a path of its own.
There the leading segments are the bucket and the tenant, so the same comparison
decides where a visitor may write and what they may name it. gen takes it
against gen.vmlts.com, and there the body names the model and the bucket, so
the route is where you decide who may spend a generation and on what.
A generation holds the connection for as long as the model takes, so give that route a timeout of at least a minute. A proxy that gives up at 30 seconds turns every slow picture into a failure nobody can name.
3. Provide the SDK, next to an HttpClient. baseUrl is the path on your
own origin, not the service URL. An HttpClient is not optional: the SDK sends
through it, so your interceptors apply to a prompt request like any other.
Each tool provides itself, and every one hands back a plain Provider[], so an
application spreads the arrays for the tools it uses side by side and the same
call works in either world.
// Standalone.
bootstrapApplication(App, {
providers: [
provideHttpClient(),
...providePrompt({ baseUrl: "/api/vmlts/prompt" }),
...provideFilestore({ baseUrl: "/api/files", bucket: "photos" }),
...provideGen({ baseUrl: "/api/vmlts/gen" }),
],
});
// NgModule, before Angular 15.
@NgModule({
imports: [HttpClientModule],
providers: [
...providePrompt({ baseUrl: "/api/vmlts/prompt" }),
...provideFilestore({ baseUrl: "/api/files", bucket: "photos" }),
...provideGen({ baseUrl: "/api/vmlts/gen" }),
],
})
export class AppModule {}4. Read a prompt.
import { Component } from "@angular/core";
import { PromptService } from "@vmlts/angular";
import { Observable, map } from "rxjs";
@Component({
selector: "app-greeting",
template: `<p *ngIf="greeting | async as text">{{ text }}</p>`,
})
export class Greeting {
readonly greeting: Observable<string>;
constructor(prompter: PromptService) {
this.greeting = prompter
.use("support/greeting")
.pipe(map((prompt) => prompt.render({ name: "Ada" }).text));
}
}Choosing a version
Without a selector the prompt's rollout decides, and unit keeps one caller on
one version: pass a user id, a session id, an account id — anything stable — and
that caller sees the same wording on every request until the operator moves the
split.
prompter.use("support/greeting", { unit: userId });
prompter.use("support/greeting", { version: 3 }); // or "v3"
prompter.use("support/greeting", { label: "concise" });A pin bypasses the rollout, so a unit beside it is ignored rather than
refused. Naming both a version and a label is refused before any request.
Filling the placeholders
A prompt declares the {{placeholders}} its text may use. render fills each
one with the value you passed, then the variable's default, then "" when the
variable is optional.
// "Hi {{name}}, you are on {{plan}}." name required, plan defaulting to "free"
prompt.render({ name: "Ada" }); // { text: "Hi Ada, you are on free.", missing: [] }
prompt.render({}); // { text: "Hi {{name}}, you are on free.", missing: ["name"] }A required variable with neither a value nor a default keeps its placeholder and
is named in missing. Hi , you are on free. reads like working text;
{{name}} does not.
The Observable
use() is cold, like every HttpClient call: the request goes out when someone
subscribes, and letting go of the subscription cancels it. The async pipe,
takeUntil() and takeUntilDestroyed() all work on it as they do on anything
else.
prompter.use("support/greeting").subscribe((prompt) => {
prompt.content; // the text, as it was served
prompt.version; // which version answered
prompt.id; // the answer a rating names
});Two subscribes are two requests. Nothing is held between them unless you ask for
a cacheTtl.
feedback is the one exception in the package, and it is eager: see
Rating what you were served.
When the prompt is off
An operator can turn a prompt off, and a prompt can have nothing rolled out.
Either way use emits rather than failing: you get the prompt's fallback text
with enabled: false, version: null and id: null.
prompt.content; // the fallback text. Always something to send.It fails for a key no prompt has, a key the service refused, and a transport
failure. Every failure is a VmltsError carrying code, status, message
and requestId — quote the requestId when you ask what happened.
prompter.use("support/greeting").subscribe({
next: (prompt) => (this.text = prompt.content),
error: (failure: VmltsError) => (this.problem = failure.code),
});code is why it was refused, in one word: prompt_not_found,
version_not_found, selector_invalid, unauthorized, forbidden,
rate_limited, internal, unavailable. Branch on it rather than on the
status — seven of them share a status with another — or on the sentence, which
can change. A code the service adds later still arrives whole.
requestId is the service's own name for the request that failed, and it is the
last attempt's: each retry is its own request with an id of its own. It travels
in a header the browser may not read, x-vmlts-request-id, so your forwarding
route either passes it back under access-control-expose-headers or leaves it
to the failure body, which carries the same id. Both reach this field.
Rating what you were served
The operator's whole reason for splitting traffic is to see which version earned better ratings. Rate the prompt you hold, and the SDK names the answer for you.
prompter.use("support/greeting").subscribe((prompt) => {
this.text = prompt.render({ name: "Ada" }).text;
this.rate = (verdict: "good" | "bad") => prompt.feedback(verdict);
});
prompt.feedback("good");
prompt.feedback("bad", "it named the wrong product");A rating is eager, and it is the only eager Observable here. It goes out on
the call, not on a subscribe, so prompt.feedback("good") on a line of its own
sends the rating. Cold would mean a caller who walked away rated nothing at all,
silently, and the operator read an empty dashboard instead of an error.
Subscribe when you want the row back. It replays, so a subscriber that arrives after the answer still receives it, and unsubscribing cancels nothing.
prompt.feedback("good").subscribe((row) => (this.rated = row!.createdAt));Nobody has to subscribe, so a refused rating needs somewhere to go. Pass
onError and every refusal reaches it, whether you subscribed or not.
providePrompt({ baseUrl: "/api/vmlts/prompt", onError: (failure) => log(failure.code) });A prompt the operator turned off has no version to rate: feedback then sends
nothing and answers null, so you write no branch for it.
A caller who rendered its own text, or whose prompt crossed a boundary as JSON, names the version itself:
prompter.feedback({ versionId, rating: "bad", requestId });
prompter.feedback({ key: "support/greeting", version: 3, rating: "good", requestId });requestId is your own idempotency key: the same one twice returns the first
row and writes nothing. Without one the rating is not retried, because a second
attempt would be a second rating.
Rating as the page closes
beacon: true sends every rating with navigator.sendBeacon, which the browser
delivers even after the page is gone. It reports nothing back, so feedback
answers null instead of the row — that is the trade, and it is why the setting
is off by default.
providePrompt({ baseUrl: "/api/vmlts/prompt", beacon: true });Where the browser has no beacon, or will not queue this one, the rating goes
through HttpClient as usual. Reading a prompt always does.
A beacon sets no headers, so it cannot carry a token and the forwarding route is
the only thing that can attach the key. beacon beside a token is refused at
construction.
Uploading a file
put takes a File off an input and hands back a cold Observable. Each event
says how far the upload has got; the last one carries the stored object, and
then it completes.
@Component({
selector: "app-uploader",
template: `
<input type="file" (change)="upload($event)" />
<progress [value]="percent" max="100"></progress>
`,
})
export class Uploader implements OnDestroy {
percent = 0;
url: string | null = null;
private running: Subscription | null = null;
constructor(private files: FilestoreService) {}
upload(event: Event): void {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
this.running = this.files.put(file).subscribe({
next: (progress) => {
this.percent = progress.percent;
if (progress.result) this.url = progress.result.url;
},
error: (failure: VmltsError) => (this.problem = failure.code),
});
}
// Unsubscribing abandons the upload, and the parts already sent are dropped.
ngOnDestroy(): void {
this.running?.unsubscribe();
}
}The percentage is Angular's own: the SDK asks HttpClient for progress events,
so the bytes are counted by the browser rather than guessed.
A file with no key of its own gets one that keeps the filename, prefixed with
twelve random characters so two people uploading photo.jpg do not collide.
Name it yourself with put(file, { key: "avatars/ada.jpg" }).
Beyond put there is list(prefix?), which follows every page and emits once,
delete(key), and url(key, image?), which builds a read URL without asking
the service:
this.files.url("ada.jpg", { view: "thumb" });
this.files.url("ada.jpg", { width: 400, height: 400, fit: "cover", format: "webp" });url needs the app slug, the leading segment of every read URL, which no
write ever spells. Every put and every list already carries the URL back, so
url is for a key you hold and have not asked about.
There is no signed URL and no bucket registry here. Minting a signature needs
the API key, which the browser must not hold, and a registry is the operator's
job — both live in @vmlts/node, on your server.
Generating a picture
image names a model and the words, and hands back a cold Observable that
completes with the picture: an id, the model that answered, the file on your own
domain and where the time went.
@Component({
selector: "app-poster",
template: `<img *ngIf="url" [src]="url" alt="" />`,
})
export class Poster implements OnDestroy {
url: string | null = null;
private running: Subscription | null = null;
constructor(private gen: GenService) {}
make(): void {
this.running = this.gen
.image({ model: "bfl/flux-2-pro", prompt: "a red bicycle", size: "1024x1024" })
.subscribe({
next: (made) => (this.url = made.output.files[0].url),
error: (failure: VmltsError) => (this.problem = failure.code),
});
}
// Unsubscribing abandons the generation.
ngOnDestroy(): void {
this.running?.unsubscribe();
}
}The words may name a prompt instead of writing themselves, and the answer says which wording made the picture:
this.gen.image({
model: "bfl/flux-2-pro",
prompt: { key: "poster/hero", variables: { season: "winter" }, unit: userId },
});
made.prompt?.text; // the words the model was sent
made.prompt?.useId; // rate that wording: prompter.feedback({ ... })One subscribe is one generation. The Observable is cold, so two subscribers
buy two pictures and the async pipe in two places buys two. Share an answer
with shareReplay(1), or take a promise with firstValueFrom. The SDK does
neither for you: guessing which you meant spends money either way.
A generation is not retried
Everywhere else in this package a 500 or a 502 is tried again. A generation is not. gen holds the connection while the provider works and has already retried that provider behind it, so a second request is not a second try at a failed thing: it is a second picture, and it may be a second charge.
this.gen.image({ model, prompt, attempts: 3 }); // the ordinary loop, if you can afford itmodels() is a read that spends nothing, so it retries as usual.
It runs for as long as gen does
gen's own budget for one request is 50 seconds, and it answers 504 itself when that runs out. So a generation here is given 60 seconds, above that budget, and nothing shortens it: a client that gave up first would turn an answer it could have read into a transport failure, while the picture is made, stored and billed anyway.
Give up sooner by passing your own signal, or deadlineMs on the service or
on the call. deadlineMs: 0 leaves a generation without one.
Words, the roster and a preset
this.gen.text({
model: "openai/gpt-5.1-mini",
messages: [{ role: "user", content: "say hello" }],
}); // said.output.text, said.output.tool_calls
this.gen.models(); // what this key may invoke, with each model's limits
this.gen.custom("necklace", { style: "studio" });A conversation stores nothing, so it names no bucket and no key. models() is
filtered by your key's policies rather than refused against them: a model
missing from the list is not an error, and an empty list is an answer.
custom runs a preset an operator wrote and named. The model, the prompt and
the placement stayed with them, and you post the values their parameters
declare; the dashboard is where those are documented.
gen also streams a conversation, as named events. This package does not read
them, and refuses stream rather than sending a request it cannot read back.
Where the picture lands
With nothing named it lands in the gen bucket under a key minted from the id
and the type. Name a bucket, a prefix and a key on the call, or set the
first two on the provider and let every picture land in one place.
...provideGen({ baseUrl: "/api/vmlts/gen", bucket: "renders", prefix: tenantId }),
this.gen.image({ model, prompt, bucket: "posters", key: "hero.png" });A bucket and a prefix set on the provider ride on image() alone. A
conversation stores nothing, and a preset was stored with the placement its
operator chose.
Settings
providePrompt:
| Setting | Default | What it does |
|---|---|---|
| baseUrl | https://prompts.vmlts.com | the path on your own origin that forwards |
| token | none | refused here; the forwarding route holds it |
| unit | none; each call draws again | the unit every call sends unless it names its own |
| cacheTtl | 0 seconds, no cache | serve one answer out of memory for that many seconds |
| attempts | 3 tries, the first one included | |
| beacon | off | send a rating with navigator.sendBeacon, which outlives the page |
| onError | none | where a refused rating goes when nobody subscribed |
A cacheTtl above 0 serves one answer out of memory, prompt.id included. Rate
what you resolved: two ratings against one id are one rating. enabled is a
kill switch, so nothing is cached by default.
provideFilestore:
| Setting | Default | What it does |
|---|---|---|
| baseUrl | https://cdn.vmlts.com | the path on your own origin that forwards |
| token | none | refused here; the forwarding route holds it |
| bucket | none; keys then spell their own | the bucket every key is relative to |
| prefix | none | the tenant, under that bucket |
| slug | none; url() fails without it | your app, the leading segment of every read URL |
| ttl | none; the bucket's own default, or forever | how long every object this service writes lives |
| ensureBucket | off | make the bucket on a write that finds it missing |
| partSize | measured from the file | bytes per part, and the size above which a file splits |
| concurrency | measured from the link | parts in flight at once |
A put may name its own key, ttl, contentType, partSize, concurrency
and ensureBucket, and each beats the service's.
provideGen:
| Setting | Default | What it does |
|---|---|---|
| baseUrl | https://gen.vmlts.com | the path on your own origin that forwards |
| token | none | refused here; the forwarding route holds it |
| bucket | none; gen writes into gen | where every picture lands |
| prefix | none | the tenant, under that bucket |
| attempts | 1 for a generation, 3 for models() | |
| deadlineMs | 60000, above gen's own 50 s budget | 0 leaves a generation without one |
An image may name its own bucket, prefix, key, attempts and
deadlineMs, and each beats the service's.
There is no environment to read in a browser, so every setting is passed to the provider.
Why the token never reaches the browser
/v1 sends no CORS headers and answers no preflight. A browser cannot reach it
on another origin, and a token would not help: a token in a browser is a token
every visitor holds, and it reaches every tool your app opted into.
So the SDK refuses a token beside a base URL that is not your own origin, and it
refuses it at construction rather than at the first request. Point baseUrl at
your own origin and keep the key on the server.
Under the bar
The same engine @vmlts/node runs, sending over HttpClient.
| | Why |
|---|---|
| It sends through HttpClient, not fetch | Your interceptors — auth, CSRF, tracing — apply to a vmlts request like any other, and fetch cannot report how far a request body has gone. |
| A 4xx or a 5xx does not reject the wire | HttpClient calls every one of those an error. The retry loop above the wire needs the status to decide whether to try again. |
| The provider functions return Provider[], and no class carries @Injectable | The package is built by tsup rather than by the Angular compiler, so a decorated class would carry no ɵfac and would only resolve under JIT. A plain class behind a factory provider needs no compiler and no EnvironmentProviders, so one array serves every Angular from 13 up. |
| A file over 64 MiB goes up in parts, and the pool widens itself | The Worker exposes multipart as four independent requests and remembers nothing between them, so the split, the retry and the cleanup are the client's. No browser reports bandwidth, so the SDK measures the link by timing each generation of parts. |
| A 4xx other than 408, 425 and 429 is not retried | The service read the request and refused it, so an identical second one earns an identical refusal. A code narrows that and never widens it. |
| A generation is not retried at all, and runs for a minute | It is the one call here that costs money to send twice, and the one that legitimately takes tens of seconds. gen answers 504 itself at 50 s, so a client deadline under that would throw away an answer it could have read. |
