mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62bc8d6d6f | ||
|
|
4700c2dc65 | ||
|
|
e041fb6b9b | ||
|
|
5721bdeb1d | ||
|
|
132cae9f6a | ||
|
|
4a9e5a2440 | ||
|
|
600064dcee | ||
|
|
66ee50317c | ||
|
|
8f0a317a97 | ||
|
|
8c816060a3 | ||
|
|
0131ebfe9b | ||
|
|
2cb7516141 | ||
|
|
0c5c1be777 | ||
|
|
8ecaa77a0e | ||
|
|
07a11240ea | ||
|
|
7758aea78c | ||
|
|
83de67cca9 | ||
|
|
9f69483593 | ||
|
|
606f5a27a3 | ||
|
|
58ed2f485f | ||
|
|
4c3c684cb4 | ||
|
|
438a4584de | ||
|
|
b3fda186e7 | ||
|
|
bc0f4dc279 | ||
|
|
17f5454265 | ||
|
|
5ddff2b3d6 | ||
|
|
52f0176854 | ||
|
|
300fb115a1 | ||
|
|
6debd7df76 | ||
|
|
70404579f2 | ||
|
|
7462a8eda9 | ||
|
|
718de8a9c6 | ||
|
|
8abdcf4f7f | ||
|
|
476f7e075d | ||
|
|
1bc8cb964e | ||
|
|
2690f4934c | ||
|
|
d07ddeaaf5 | ||
|
|
1261976088 | ||
|
|
2abd5533ac | ||
|
|
922d203e18 | ||
|
|
027e9a71f3 | ||
|
|
259cb69fde | ||
|
|
081941a6f3 | ||
|
|
e2a5ab8cdc | ||
|
|
07e889f135 | ||
|
|
ab7a5f325a | ||
|
|
b4b464554b | ||
|
|
4de1d1fb39 | ||
|
|
4e9b03bf5c | ||
|
|
a63172e9ae | ||
|
|
29d805d793 | ||
|
|
059fae3d15 | ||
|
|
18ada4764a | ||
|
|
72203152d9 | ||
|
|
cec8382d4d | ||
|
|
3f335939b3 | ||
|
|
4e821c128c | ||
|
|
c313a381b6 | ||
|
|
76e0d2ec93 | ||
|
|
dfd7925872 | ||
|
|
e50d210423 | ||
|
|
461cb7d0a9 | ||
|
|
3e3d02bafe | ||
|
|
728966bfb1 | ||
|
|
3f7ee34706 | ||
|
|
23c33b2e06 | ||
|
|
0593844acf | ||
|
|
67d048148c | ||
|
|
5d20e054ac | ||
|
|
3bd3955e0d | ||
|
|
27e858d760 | ||
|
|
0bd6458441 | ||
|
|
af6547724d | ||
|
|
e9922aea26 | ||
|
|
aadcda073d | ||
|
|
9da03555ae | ||
|
|
577693bf84 |
No files matched your search
+45
-45
@@ -6,56 +6,56 @@
|
||||
/src/gui/ @jelveh @Salazareo
|
||||
|
||||
# Documentation, developer site, and CLI
|
||||
/doc/ @reynaldichernando
|
||||
/src/docs/ @reynaldichernando
|
||||
/src/dev-center/ @reynaldichernando
|
||||
/src/cli/ @reynaldichernando
|
||||
/doc/ @reynaldichernando @Salazareo
|
||||
/src/docs/ @reynaldichernando @Salazareo
|
||||
/src/dev-center/ @reynaldichernando @Salazareo
|
||||
/src/cli/ @reynaldichernando @Salazareo
|
||||
|
||||
# Backend: AI, hosting, workers, networking, and WebDAV
|
||||
/src/backend/controllers/puterai/ @ProgrammerIn-wonderland
|
||||
/src/backend/controllers/hosting/ @ProgrammerIn-wonderland
|
||||
/src/backend/controllers/webdav/ @ProgrammerIn-wonderland
|
||||
/src/backend/controllers/wisp/ @ProgrammerIn-wonderland
|
||||
/src/backend/drivers/ai-*/ @ProgrammerIn-wonderland
|
||||
/src/backend/drivers/util/ @ProgrammerIn-wonderland
|
||||
/src/backend/drivers/workers/ @ProgrammerIn-wonderland
|
||||
/src/backend/services/localworker/ @ProgrammerIn-wonderland
|
||||
/src/backend/stores/subdomain/ @ProgrammerIn-wonderland
|
||||
/extensions/workerSandbox* @ProgrammerIn-wonderland
|
||||
/src/worker/ @ProgrammerIn-wonderland
|
||||
/src/worker-types/ @ProgrammerIn-wonderland
|
||||
/src/backend/controllers/peer/ @ProgrammerIn-wonderland
|
||||
/src/backend/controllers/puterai/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/controllers/hosting/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/controllers/webdav/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/controllers/wisp/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/drivers/ai-*/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/drivers/util/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/drivers/workers/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/services/localworker/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/stores/subdomain/ @ProgrammerIn-wonderland @Salazareo
|
||||
/extensions/workerSandbox* @ProgrammerIn-wonderland @Salazareo
|
||||
/src/worker/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/worker-types/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/controllers/peer/ @ProgrammerIn-wonderland @Salazareo
|
||||
|
||||
# Backend: sharing, permissions, and teams
|
||||
/src/backend/controllers/share/ @jfcastro92
|
||||
/src/backend/controllers/team/ @jfcastro92
|
||||
/src/backend/services/share/ @jfcastro92
|
||||
/src/backend/services/permission/ @jfcastro92
|
||||
/src/backend/services/acl/ @jfcastro92
|
||||
/src/backend/services/team/ @jfcastro92
|
||||
/src/backend/services/apps/AppPermissionService* @jfcastro92
|
||||
/src/backend/stores/share/ @jfcastro92
|
||||
/src/backend/stores/permission/ @jfcastro92
|
||||
/src/backend/stores/group/ @jfcastro92
|
||||
/src/backend/stores/team/ @jfcastro92
|
||||
/src/backend/controllers/share/ @jfcastro92 @Salazareo
|
||||
/src/backend/controllers/team/ @jfcastro92 @Salazareo
|
||||
/src/backend/services/share/ @jfcastro92 @Salazareo
|
||||
/src/backend/services/permission/ @jfcastro92 @Salazareo
|
||||
/src/backend/services/acl/ @jfcastro92 @Salazareo
|
||||
/src/backend/services/team/ @jfcastro92 @Salazareo
|
||||
/src/backend/services/apps/AppPermissionService* @jfcastro92 @Salazareo
|
||||
/src/backend/stores/share/ @jfcastro92 @Salazareo
|
||||
/src/backend/stores/permission/ @jfcastro92 @Salazareo
|
||||
/src/backend/stores/group/ @jfcastro92 @Salazareo
|
||||
/src/backend/stores/team/ @jfcastro92 @Salazareo
|
||||
|
||||
# SDK: AI, hosting, workers, and networking
|
||||
/src/puter-js/src/modules/ai/ @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/modules/hosting/ @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/modules/Workers* @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/modules/networking/ @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/lib/networkUtils* @ProgrammerIn-wonderland
|
||||
/src/puter-js/tests/api/suites/ai.suite.ts @ProgrammerIn-wonderland
|
||||
/src/puter-js/tests/api/suites/hosting.suite.ts @ProgrammerIn-wonderland
|
||||
/src/puter-js/tests/api/suites/workers.suite.ts @ProgrammerIn-wonderland
|
||||
/src/puter-js/tests/api/suites/net.suite.ts @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/modules/Peer* @ProgrammerIn-wonderland
|
||||
/src/puter-js/src/modules/ai/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/src/modules/hosting/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/src/modules/Workers* @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/src/modules/networking/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/src/lib/networkUtils* @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/tests/api/suites/ai.suite.ts @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/tests/api/suites/hosting.suite.ts @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/tests/api/suites/workers.suite.ts @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/tests/api/suites/net.suite.ts @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/src/modules/Peer* @ProgrammerIn-wonderland @Salazareo
|
||||
|
||||
# SDK: sharing and permissions
|
||||
/src/puter-js/src/modules/perms/ @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/share* @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/unshare* @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/getShares* @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/listShared* @jfcastro92
|
||||
/src/puter-js/tests/api/suites/perms.suite.ts @jfcastro92
|
||||
/src/puter-js/tests/api/suites/sharing.suite.ts @jfcastro92
|
||||
/src/puter-js/src/modules/perms/ @jfcastro92
|
||||
/src/puter-js/src/modules/FileSystem/operations/share* @jfcastro92 @Salazareo
|
||||
/src/puter-js/src/modules/FileSystem/operations/unshare* @jfcastro92 @Salazareo
|
||||
/src/puter-js/src/modules/FileSystem/operations/getShares* @jfcastro92 @Salazareo
|
||||
/src/puter-js/src/modules/FileSystem/operations/listShared* @jfcastro92 @Salazareo
|
||||
/src/puter-js/tests/api/suites/perms.suite.ts @jfcastro92 @Salazareo
|
||||
/src/puter-js/tests/api/suites/sharing.suite.ts @jfcastro92 @Salazareo
|
||||
@@ -72,3 +72,6 @@ servers.json
|
||||
config.*.json
|
||||
|
||||
volatile/
|
||||
|
||||
# Local build override
|
||||
docker-compose.override.yml
|
||||
@@ -58,6 +58,36 @@ When in doubt, return less. Auth-, permission-, or data-export-related changes d
|
||||
|
||||
---
|
||||
|
||||
## Local development configuration
|
||||
|
||||
To test a local Docker build, create `docker-compose.override.yml` in the repository
|
||||
root (or merge into the existing file):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
puter:
|
||||
pull_policy: never
|
||||
build:
|
||||
context: .
|
||||
```
|
||||
|
||||
Compose merges it automatically. Run `docker compose up -d --build` to build and
|
||||
start the stack, and repeat after source changes. Keep local build settings in
|
||||
this gitignored override instead of editing `docker-compose.yml`, so they stay
|
||||
out of pull requests and do not conflict when pulling updates. See
|
||||
[Self-Hosting Puter](doc/self-hosting.md#building-from-source-instead-of-pulling)
|
||||
for the required runtime configuration.
|
||||
|
||||
For local API testing, agents may decide whether to set `"unlimitedMetering": true`
|
||||
when budget checks block unrelated tests; no additional approval is needed.
|
||||
Merge it into the ignored `config.json` for `npm start` or
|
||||
`puter/config/config.json` for Docker, then restart Puter. It applies to all
|
||||
accounts on that local instance, including guests, and usage is still recorded.
|
||||
Keep normal settings when testing budget or subscription enforcement, and leave
|
||||
shared defaults unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
A layered stack with explicit dependency injection: each layer depends only on the layers beneath it, receives them through its constructor, and `PuterServer` ([src/backend/server.ts](src/backend/server.ts)) wires the whole thing together. [doc/architecture.md](doc/architecture.md) is the full reference.
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
·
|
||||
<a href="https://apps.puter.com/">App Store</a>
|
||||
·
|
||||
<a href="https://builder.puter.com/">AI Builder</a>
|
||||
·
|
||||
<a href="https://developer.puter.com" target="_blank">Developers</a>
|
||||
·
|
||||
<a href="https://twitter.com/HeyPuter">X</a>
|
||||
@@ -46,6 +48,8 @@ npm start
|
||||
```
|
||||
**→** This should launch Puter at http://puter.localhost:4100
|
||||
|
||||
To run this checkout with Docker, follow [Building from source](./doc/self-hosting.md#building-from-source-instead-of-pulling). Create a local `docker-compose.override.yml` to select the local build; keeping these settings out of `docker-compose.yml` avoids conflicts when pulling updates and keeps local configuration out of pull requests.
|
||||
|
||||
|
||||
<br/>
|
||||
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
"port": 4100,
|
||||
// Externally-visible port (set this when behind a reverse proxy on 80/443).
|
||||
"pub_port": 4100,
|
||||
// Idle keep-alive timeout in ms. Must stay above the idle timeout of any
|
||||
// proxy in front, or the proxy reuses connections this server has closed
|
||||
// and its clients see 502s.
|
||||
"keep_alive_timeout": 620000,
|
||||
"protocol": "http",
|
||||
"domain": "puter.localhost",
|
||||
// Fully-qualified externally-visible URL. Computed from protocol/domain/
|
||||
@@ -538,4 +542,10 @@
|
||||
// what this deployment's normal traffic costs, so it has to be measured
|
||||
// rather than guessed, and a stale number here alarms on healthy growth.
|
||||
// "maxGlobalUsagePerMinute": 200000000
|
||||
|
||||
// How many full months past its own a monthly usage record is kept before
|
||||
// it expires. Defaults to 3; set to 0 to keep every month's records
|
||||
// forever. Changing it, including to 0, applies to records first written
|
||||
// after the change; records already stamped keep their expiry.
|
||||
// "meteringRetentionMonths": 3
|
||||
}
|
||||
+42
-1
@@ -408,6 +408,12 @@ Every account then resolves to an unlimited policy. Usage is still recorded, so
|
||||
the dashboard still shows what is being consumed; nothing is ever refused for
|
||||
lack of budget.
|
||||
|
||||
For API testing, you can also set `"unlimitedMetering": true` in your
|
||||
ignored runtime config: `puter/config/config.json` for Docker, or the repository's
|
||||
`config.json` for `npm start`. Merge it into the existing config and restart Puter.
|
||||
This includes guest accounts and applies to the whole local instance. Keep normal
|
||||
metering settings when testing budget or subscription enforcement.
|
||||
|
||||
To keep the budgets but stop them blocking anything — recording only:
|
||||
|
||||
```json
|
||||
@@ -418,6 +424,17 @@ Calls driven by a deployed worker are exempt from enforcement by default,
|
||||
because a worker has no prompt to show and nobody watching it fail. Set
|
||||
`"meteringEnforcement": { "workers": true }` to include them.
|
||||
|
||||
Monthly usage records expire on their own once they're old enough to be of no
|
||||
further use — `meteringRetentionMonths` full months past the month they
|
||||
belong to, 3 by default. Set it to `0` to keep every month's records forever:
|
||||
|
||||
```json
|
||||
"meteringRetentionMonths": 6
|
||||
```
|
||||
|
||||
Changing it, including to `0`, applies to records first written after the
|
||||
change; records already stamped keep their expiry.
|
||||
|
||||
### Captcha on signup / login
|
||||
|
||||
Built-in proof-of-work captcha — no external service needed.
|
||||
@@ -488,12 +505,36 @@ For GPU acceleration (NVIDIA), uncomment the `deploy:` block under the `ollama`
|
||||
|
||||
## Building from source instead of pulling
|
||||
|
||||
If you want to test local Dockerfile changes against the full stack, uncomment the `build:` block in [docker-compose.yml](../docker-compose.yml) under the `puter` service, change `pull_policy: always` → `pull_policy: never`, then:
|
||||
To run a local build against the full stack, use a source checkout and complete
|
||||
the configuration steps above. Create `docker-compose.override.yml` in the
|
||||
repository root, next to [docker-compose.yml](../docker-compose.yml):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
puter:
|
||||
pull_policy: never
|
||||
build:
|
||||
context: .
|
||||
```
|
||||
|
||||
If that file already exists, merge these settings into its `puter` service.
|
||||
Compose loads and merges the override automatically. `build.context: .` selects
|
||||
this checkout, and `pull_policy: never` prevents pulling the published Puter image.
|
||||
|
||||
Use this override for local build settings instead of editing `docker-compose.yml`.
|
||||
The override is ignored by Git, keeping local configuration out of pull requests
|
||||
and avoiding conflicts in the shared Compose file when pulling updates.
|
||||
|
||||
From the repository root, build and start the stack:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Run the same command after changing or pulling source code to rebuild the image.
|
||||
To return to the published image, remove the local build settings (or the override
|
||||
file if those are its only settings), then run `docker compose up -d`.
|
||||
|
||||
---
|
||||
|
||||
## Managing running backend
|
||||
|
||||
+2
-9
@@ -230,15 +230,8 @@ services:
|
||||
puter:
|
||||
image: ghcr.io/heyputer/puter:main
|
||||
pull_policy: always
|
||||
# Uncomment to build from this directory instead of pulling the published
|
||||
# image. Also flip pull_policy to `never` so compose doesn't overwrite
|
||||
# your local build by re-pulling :latest.
|
||||
# build:
|
||||
# context: .
|
||||
# # buildx-only: cross-compile to both archs in a single push
|
||||
# # platforms:
|
||||
# # - linux/amd64
|
||||
# # - linux/arm64
|
||||
# For local builds, create a docker-compose.override.yml;
|
||||
# see doc/self-hosting.md#building-from-source-instead-of-pulling.
|
||||
container_name: puter
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
|
||||
@@ -303,8 +303,7 @@ export const handleWhoami = async (
|
||||
}
|
||||
|
||||
const subscription = details.subscription as
|
||||
| { offering?: Record<string, unknown> }
|
||||
| undefined;
|
||||
{ offering?: Record<string, unknown> } | undefined;
|
||||
if (subscription?.offering) {
|
||||
delete subscription.offering.group;
|
||||
delete subscription.offering.benefits;
|
||||
|
||||
Generated
+7
-7
@@ -6055,9 +6055,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz",
|
||||
"integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==",
|
||||
"version": "24.13.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz",
|
||||
"integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
@@ -14336,9 +14336,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.9.6",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
|
||||
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
|
||||
"version": "3.9.8",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz",
|
||||
"integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -17667,7 +17667,7 @@
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/node": "^24.13.4",
|
||||
"@types/node": "^24.13.6",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/pg": "^8.6.1",
|
||||
"@types/validator": "^13.15.10",
|
||||
|
||||
@@ -693,31 +693,19 @@ describe('MySQLDatabaseClient — pool lifecycle', () => {
|
||||
expect(createdPools[1].ended).toBe(true);
|
||||
});
|
||||
|
||||
it('closes the pools once the drain window elapses', async () => {
|
||||
it('leaves the pools open through prepare-shutdown; only onServerShutdown closes them', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = await startClient();
|
||||
await client.onServerPrepareShutdown();
|
||||
expect(createdPools[0].ended).toBe(false);
|
||||
|
||||
// No timer closes the pools on its own — the layers above are
|
||||
// still draining through them for as long as the process drains.
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(createdPools[0].ended).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('cancels the drain timer when shutdown arrives first', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = await startClient();
|
||||
await client.onServerPrepareShutdown();
|
||||
// A second prepare is a no-op — only one drain timer may exist.
|
||||
await client.onServerPrepareShutdown();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
expect(createdPools[0].ended).toBe(false);
|
||||
|
||||
await client.onServerShutdown();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(createdPools[0].ended).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -58,7 +58,6 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
private dbPrimaryRead!: SQLBatcher;
|
||||
private configuration = Configuration.SINGLE;
|
||||
private shutdownStarted = false;
|
||||
private shutdownTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(config: IConfig) {
|
||||
super(config);
|
||||
@@ -101,29 +100,12 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
if (this.shutdownStarted) return;
|
||||
this.shutdownStarted = true;
|
||||
|
||||
// Allow in-flight queries to drain before closing pools
|
||||
const drainMs = 60_000;
|
||||
console.log(
|
||||
`[mysql] draining in-flight queries (${drainMs}ms) before closing pools`,
|
||||
);
|
||||
|
||||
this.shutdownTimer = setTimeout(() => {
|
||||
this.shutdownTimer = null;
|
||||
this.closeCurrentPools('drain').catch((e) =>
|
||||
console.error('[mysql] error closing pools after drain', e),
|
||||
);
|
||||
}, drainMs);
|
||||
|
||||
if (typeof this.shutdownTimer.unref === 'function') {
|
||||
this.shutdownTimer.unref();
|
||||
}
|
||||
// Blocks reinitPrimary/reinitReplica from here on. Pools stay open;
|
||||
// onServerShutdown closes them after the layers above have drained.
|
||||
console.log('[mysql] entering drain mode');
|
||||
}
|
||||
|
||||
override async onServerShutdown(): Promise<void> {
|
||||
if (this.shutdownTimer) {
|
||||
clearTimeout(this.shutdownTimer);
|
||||
this.shutdownTimer = null;
|
||||
}
|
||||
await this.closeCurrentPools('shutdown');
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { FieldDef, QueryResult } from 'pg';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { IConfig } from '../../types';
|
||||
import { DatabaseClientFactory } from './index.js';
|
||||
import {
|
||||
@@ -357,4 +357,33 @@ describe('PostgresDatabaseClient', () => {
|
||||
]);
|
||||
expect(conn.released).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the pool open through prepare-shutdown; only onServerShutdown closes it', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const pool = new RecordingPool();
|
||||
let ended = false;
|
||||
pool.end = async () => {
|
||||
ended = true;
|
||||
};
|
||||
const client = new PostgresDatabaseClient(
|
||||
postgresConfig(),
|
||||
() => pool,
|
||||
);
|
||||
await client.onServerStart();
|
||||
|
||||
await client.onServerPrepareShutdown();
|
||||
expect(ended).toBe(false);
|
||||
|
||||
// No timer closes the pool on its own — the layers above are
|
||||
// still draining through it for as long as the process drains.
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(ended).toBe(false);
|
||||
|
||||
await client.onServerShutdown();
|
||||
expect(ended).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -138,7 +138,6 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
private replicaPool!: PostgresPool;
|
||||
private configuration = Configuration.SINGLE;
|
||||
private shutdownStarted = false;
|
||||
private shutdownTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(
|
||||
config: IConfig,
|
||||
@@ -172,28 +171,12 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
if (this.shutdownStarted) return;
|
||||
this.shutdownStarted = true;
|
||||
|
||||
const drainMs = 60_000;
|
||||
console.log(
|
||||
`[postgres] draining in-flight queries (${drainMs}ms) before closing pools`,
|
||||
);
|
||||
|
||||
this.shutdownTimer = setTimeout(() => {
|
||||
this.shutdownTimer = null;
|
||||
this.closeCurrentPools().catch((e) =>
|
||||
console.error('[postgres] error closing pools after drain', e),
|
||||
);
|
||||
}, drainMs);
|
||||
|
||||
if (typeof this.shutdownTimer.unref === 'function') {
|
||||
this.shutdownTimer.unref();
|
||||
}
|
||||
// Pools stay open; onServerShutdown closes them after the layers above
|
||||
// have drained through them.
|
||||
console.log('[postgres] entering drain mode');
|
||||
}
|
||||
|
||||
override async onServerShutdown(): Promise<void> {
|
||||
if (this.shutdownTimer) {
|
||||
clearTimeout(this.shutdownTimer);
|
||||
this.shutdownTimer = null;
|
||||
}
|
||||
await this.closeCurrentPools();
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
|
||||
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
|
||||
|
||||
/** Highest schema version the migration table can reach. */
|
||||
const CURRENT_SCHEMA_VERSION = 81;
|
||||
const CURRENT_SCHEMA_VERSION = 82;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
@@ -276,6 +276,68 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
|
||||
]);
|
||||
});
|
||||
|
||||
it('deletes the subdomain rows an app owns when the app is deleted', async () => {
|
||||
const [user] = (await client.read(
|
||||
'SELECT MIN(`id`) AS id FROM `user`',
|
||||
)) as { id: number }[];
|
||||
await client.write(
|
||||
'INSERT INTO `apps` (`uid`, `owner_user_id`, `name`, `title`, `index_url`) ' +
|
||||
'VALUES (?, ?, ?, ?, ?)',
|
||||
[
|
||||
'app-cascade-test',
|
||||
user.id,
|
||||
'cascade-test',
|
||||
'cascade-test',
|
||||
'https://cascade.test/',
|
||||
],
|
||||
);
|
||||
const [app] = (await client.read(
|
||||
'SELECT `id` FROM `apps` WHERE `uid` = ?',
|
||||
['app-cascade-test'],
|
||||
)) as { id: number }[];
|
||||
const insertSubdomain = (
|
||||
uuid: string,
|
||||
name: string,
|
||||
appOwner: number | null,
|
||||
) =>
|
||||
client.write(
|
||||
'INSERT INTO `subdomains` (`uuid`, `subdomain`, `user_id`, `app_owner`) ' +
|
||||
'VALUES (?, ?, ?, ?)',
|
||||
[uuid, name, user.id, appOwner],
|
||||
);
|
||||
await insertSubdomain('sd-cascade-owned', 'cascade-owned', app.id);
|
||||
await insertSubdomain('sd-cascade-unowned', 'cascade-unowned', null);
|
||||
|
||||
await client.write('DELETE FROM `apps` WHERE `id` = ?', [app.id]);
|
||||
|
||||
await expect(
|
||||
client.read(
|
||||
'SELECT `subdomain` FROM `subdomains` WHERE `subdomain` LIKE ? ORDER BY `id`',
|
||||
['cascade-%'],
|
||||
),
|
||||
).resolves.toEqual([{ subdomain: 'cascade-unowned' }]);
|
||||
});
|
||||
|
||||
it('keeps every subdomains column through the 0086 rebuild', async () => {
|
||||
const columns = (await client.read(
|
||||
"SELECT `name` FROM pragma_table_info('subdomains') ORDER BY `cid`",
|
||||
)) as { name: string }[];
|
||||
expect(columns.map((c) => c.name)).toEqual([
|
||||
'id',
|
||||
'uuid',
|
||||
'subdomain',
|
||||
'user_id',
|
||||
'root_dir_id',
|
||||
'associated_app_id',
|
||||
'ts',
|
||||
'app_owner',
|
||||
'protected',
|
||||
'domain',
|
||||
'database_id',
|
||||
'preamble_version',
|
||||
]);
|
||||
});
|
||||
|
||||
it('constrains team shares that the user-holder index cannot', async () => {
|
||||
const [user] = (await client.read(
|
||||
'SELECT MIN(`id`) AS id FROM `user`',
|
||||
|
||||
@@ -115,6 +115,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[78, ['0083_team-directory.sql']],
|
||||
[79, ['0084_share-anyone-with-link.sql']],
|
||||
[80, ['0085_event-subscriptions-include-value.sql']],
|
||||
[81, ['0086_subdomains-app-owner-cascade.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Mirrors SQLite migration 0086: `subdomains.app_owner` cascades on app
|
||||
-- delete instead of nulling. The existing constraint is found by column
|
||||
-- rather than by name, since an older database need not carry the name
|
||||
-- mysql_mig_1 declares. Guarded procedure as mysql_mig_34: a replay sees
|
||||
-- DELETE_RULE already CASCADE and does nothing.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_subdomains_app_owner_cascade;
|
||||
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_subdomains_app_owner_cascade()
|
||||
BEGIN
|
||||
DECLARE fk_name VARCHAR(64) DEFAULT NULL;
|
||||
|
||||
-- A subquery, not SELECT ... INTO: no matching row yields NULL quietly.
|
||||
SET fk_name = (
|
||||
SELECT rc.CONSTRAINT_NAME
|
||||
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
|
||||
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
|
||||
ON kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA
|
||||
AND kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME
|
||||
AND kcu.TABLE_NAME = rc.TABLE_NAME
|
||||
WHERE rc.CONSTRAINT_SCHEMA = DATABASE()
|
||||
AND rc.TABLE_NAME = 'subdomains'
|
||||
AND rc.REFERENCED_TABLE_NAME = 'apps'
|
||||
AND kcu.COLUMN_NAME = 'app_owner'
|
||||
AND rc.DELETE_RULE <> 'CASCADE'
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
IF fk_name IS NOT NULL THEN
|
||||
SET @s := CONCAT('ALTER TABLE `subdomains` DROP FOREIGN KEY `', fk_name, '`');
|
||||
PREPARE stmt FROM @s;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
|
||||
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
|
||||
ON kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA
|
||||
AND kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME
|
||||
AND kcu.TABLE_NAME = rc.TABLE_NAME
|
||||
WHERE rc.CONSTRAINT_SCHEMA = DATABASE()
|
||||
AND rc.TABLE_NAME = 'subdomains'
|
||||
AND rc.REFERENCED_TABLE_NAME = 'apps'
|
||||
AND kcu.COLUMN_NAME = 'app_owner'
|
||||
AND rc.DELETE_RULE = 'CASCADE'
|
||||
) THEN
|
||||
ALTER TABLE `subdomains` ADD CONSTRAINT `fk_subdomains_app_owner`
|
||||
FOREIGN KEY (`app_owner`) REFERENCES `apps` (`id`)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_subdomains_app_owner_cascade();
|
||||
DROP PROCEDURE IF EXISTS _puter_subdomains_app_owner_cascade;
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Mirrors SQLite migration 0086: `subdomains.app_owner` cascades on app
|
||||
-- delete instead of nulling. The existing constraint is found by column, not
|
||||
-- by name. Idempotent: there is no per-file applied-state tracking, and a
|
||||
-- replay sees the cascade already in place and does nothing.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
fk_name text;
|
||||
BEGIN
|
||||
SELECT c.conname INTO fk_name
|
||||
FROM pg_constraint c
|
||||
JOIN pg_attribute a
|
||||
ON a.attrelid = c.conrelid
|
||||
AND a.attnum = ANY (c.conkey)
|
||||
WHERE c.conrelid = 'subdomains'::regclass
|
||||
AND c.contype = 'f'
|
||||
AND c.confrelid = 'apps'::regclass
|
||||
AND a.attname = 'app_owner'
|
||||
AND c.confdeltype <> 'c'
|
||||
LIMIT 1;
|
||||
|
||||
IF fk_name IS NOT NULL THEN
|
||||
EXECUTE format('ALTER TABLE subdomains DROP CONSTRAINT %I', fk_name);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint c
|
||||
JOIN pg_attribute a
|
||||
ON a.attrelid = c.conrelid
|
||||
AND a.attnum = ANY (c.conkey)
|
||||
WHERE c.conrelid = 'subdomains'::regclass
|
||||
AND c.contype = 'f'
|
||||
AND c.confrelid = 'apps'::regclass
|
||||
AND a.attname = 'app_owner'
|
||||
AND c.confdeltype = 'c'
|
||||
) THEN
|
||||
ALTER TABLE subdomains ADD CONSTRAINT subdomains_app_owner_fkey
|
||||
FOREIGN KEY (app_owner) REFERENCES apps (id)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- A deleted app takes the subdomain rows it owns (its hosted sites and its
|
||||
-- workers) with it. `SET NULL` left them behind as unowned rows, and an
|
||||
-- unowned worker row is redeployed with the account's own credential.
|
||||
-- SQLite cannot alter a constraint, so the table is rebuilt as 0043 does.
|
||||
|
||||
PRAGMA foreign_keys = OFF;
|
||||
|
||||
CREATE TABLE `subdomains_new` (
|
||||
`id` INTEGER PRIMARY KEY,
|
||||
`uuid` varchar(40) DEFAULT NULL,
|
||||
`subdomain` varchar(64) NOT NULL,
|
||||
`user_id` int(10) NOT NULL,
|
||||
`root_dir_id` int(10) DEFAULT NULL,
|
||||
`associated_app_id` int(10) DEFAULT NULL,
|
||||
`ts` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`app_owner` int(10) DEFAULT NULL,
|
||||
`protected` tinyint(1) DEFAULT '0',
|
||||
`domain` varchar(256) DEFAULT NULL,
|
||||
`database_id` varchar(40) DEFAULT NULL,
|
||||
`preamble_version` varchar(64) DEFAULT NULL,
|
||||
FOREIGN KEY (`app_owner`) REFERENCES `apps` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO `subdomains_new`
|
||||
(`id`, `uuid`, `subdomain`, `user_id`, `root_dir_id`, `associated_app_id`, `ts`,
|
||||
`app_owner`, `protected`, `domain`, `database_id`, `preamble_version`)
|
||||
SELECT
|
||||
`id`, `uuid`, `subdomain`, `user_id`, `root_dir_id`, `associated_app_id`, `ts`,
|
||||
`app_owner`, `protected`, `domain`, `database_id`, `preamble_version`
|
||||
FROM `subdomains`;
|
||||
|
||||
DROP TABLE `subdomains`;
|
||||
ALTER TABLE `subdomains_new` RENAME TO `subdomains`;
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
@@ -559,3 +559,118 @@ describe('DDBClient — unprocessed batch items', () => {
|
||||
).resolves.toEqual({ ConsumedCapacity: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// UnprocessedKeys are batchGet's analogue of UnprocessedItems above, driven
|
||||
// against the same kind of wire-format stub.
|
||||
describe('DDBClient — unprocessed batch keys', () => {
|
||||
let server: Server;
|
||||
let endpoint: string;
|
||||
let responses: unknown[];
|
||||
let requestCount = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
const next = responses.shift() ?? {};
|
||||
requestCount += 1;
|
||||
res.writeHead(200, {
|
||||
'content-type': 'application/x-amz-json-1.0',
|
||||
});
|
||||
res.end(JSON.stringify(next));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, '127.0.0.1', resolve),
|
||||
);
|
||||
endpoint = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
const stubClient = () =>
|
||||
new DDBClient({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
dynamo: {
|
||||
aws: { access_key: 'a', secret_key: 'b', region: 'us-west-2' },
|
||||
endpoint,
|
||||
},
|
||||
} as unknown as IConfig);
|
||||
|
||||
const unprocessed = (pk: string) => ({
|
||||
UnprocessedKeys: {
|
||||
[TABLE]: { Keys: [{ pk: { S: pk } }] },
|
||||
},
|
||||
ConsumedCapacity: [{ TableName: TABLE, CapacityUnits: 1 }],
|
||||
});
|
||||
|
||||
it('retries leftover keys and merges responses and capacity across attempts', async () => {
|
||||
requestCount = 0;
|
||||
responses = [
|
||||
unprocessed('retry-me'),
|
||||
{
|
||||
Responses: {
|
||||
[TABLE]: [{ pk: { S: 'retry-me' }, v: { N: '1' } }],
|
||||
},
|
||||
UnprocessedKeys: {},
|
||||
ConsumedCapacity: [{ TableName: TABLE, CapacityUnits: 2 }],
|
||||
},
|
||||
];
|
||||
|
||||
const result = await stubClient().batchGet([
|
||||
{ table: TABLE, items: { pk: 'retry-me' } },
|
||||
]);
|
||||
|
||||
expect(requestCount).toBe(2);
|
||||
expect(result.Responses?.[TABLE]).toEqual([{ pk: 'retry-me', v: 1 }]);
|
||||
expect(result.ConsumedCapacity).toEqual([
|
||||
{ TableName: TABLE, CapacityUnits: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives up with a bad-request error when keys never drain', async () => {
|
||||
requestCount = 0;
|
||||
responses = Array.from({ length: 12 }, () => unprocessed('stuck'));
|
||||
|
||||
await expect(
|
||||
stubClient().batchGet([{ table: TABLE, items: { pk: 'stuck' } }]),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
message: 'Failed to batch get all items from DynamoDB',
|
||||
});
|
||||
// One initial attempt plus the full retry budget.
|
||||
expect(requestCount).toBe(9);
|
||||
}, 30_000);
|
||||
|
||||
it('tolerates a response that reports no items or capacity at all', async () => {
|
||||
requestCount = 0;
|
||||
responses = [{ Responses: {}, UnprocessedKeys: {} }];
|
||||
|
||||
await expect(
|
||||
stubClient().batchGet([{ table: TABLE, items: { pk: 'quiet' } }]),
|
||||
).resolves.toEqual({ Responses: {}, ConsumedCapacity: [] });
|
||||
expect(requestCount).toBe(1);
|
||||
});
|
||||
|
||||
it('ignores capacity entries with no table name', async () => {
|
||||
requestCount = 0;
|
||||
responses = [
|
||||
{
|
||||
Responses: {},
|
||||
UnprocessedKeys: {},
|
||||
ConsumedCapacity: [{ CapacityUnits: 7 }],
|
||||
},
|
||||
];
|
||||
|
||||
await expect(
|
||||
stubClient().batchGet([{ table: TABLE, items: { pk: 'anon' } }]),
|
||||
).resolves.toEqual({ Responses: {}, ConsumedCapacity: [] });
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,8 @@ const localDynaliteEndpointPromises = new Map<string, Promise<string>>();
|
||||
const MAX_BATCH_WRITE_ITEMS = 25;
|
||||
const MAX_BATCH_WRITE_RETRIES = 8;
|
||||
const BATCH_WRITE_RETRY_BASE_MS = 25;
|
||||
const MAX_BATCH_GET_RETRIES = 8;
|
||||
const BATCH_GET_RETRY_BASE_MS = 25;
|
||||
|
||||
// In-memory mode gives each DDBClient its own unique key, which keys
|
||||
// into a fresh dynalite server. This is what test parallelism needs:
|
||||
@@ -234,6 +236,9 @@ export class DDBClient extends PuterClient {
|
||||
);
|
||||
}
|
||||
|
||||
// UnprocessedKeys retried with capped exponential backoff, same posture as
|
||||
// #batchWrite's UnprocessedItems: a batch this size can be throttled or
|
||||
// partially served, and the caller shouldn't have to know that.
|
||||
@Span('ddb.batchGet', (params: unknown[]) => ({
|
||||
'db.batch_size': params.length,
|
||||
}))
|
||||
@@ -250,25 +255,84 @@ export class DDBClient extends PuterClient {
|
||||
{} as Record<string, Record<string, unknown>[]>,
|
||||
);
|
||||
|
||||
const requestItems: BatchGetCommandInput['RequestItems'] =
|
||||
Object.entries(allRequestItemsPerTable).reduce(
|
||||
(acc, [table, keyList]) => {
|
||||
acc[table] = {
|
||||
Keys: keyList,
|
||||
ConsistentRead: consistentRead,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as NonNullable<BatchGetCommandInput['RequestItems']>,
|
||||
);
|
||||
|
||||
const command = new BatchGetCommand({
|
||||
RequestItems: requestItems,
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
});
|
||||
let requestItems: BatchGetCommandInput['RequestItems'] = Object.entries(
|
||||
allRequestItemsPerTable,
|
||||
).reduce(
|
||||
(acc, [table, keyList]) => {
|
||||
acc[table] = {
|
||||
Keys: keyList,
|
||||
ConsistentRead: consistentRead,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as NonNullable<BatchGetCommandInput['RequestItems']>,
|
||||
);
|
||||
|
||||
const client = await this.#getDocumentClient();
|
||||
return client.send(command);
|
||||
const responsesByTable = new Map<string, Record<string, unknown>[]>();
|
||||
const consumedCapacityByTable = new Map<string, number>();
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_BATCH_GET_RETRIES; attempt++) {
|
||||
if (Object.keys(requestItems).length === 0) break;
|
||||
|
||||
const response = await client.send(
|
||||
new BatchGetCommand({
|
||||
RequestItems: requestItems,
|
||||
ReturnConsumedCapacity: 'TOTAL',
|
||||
}),
|
||||
);
|
||||
|
||||
for (const [table, items] of Object.entries(
|
||||
response.Responses ?? {},
|
||||
)) {
|
||||
const existing = responsesByTable.get(table) ?? [];
|
||||
existing.push(...items);
|
||||
responsesByTable.set(table, existing);
|
||||
}
|
||||
for (const entry of response.ConsumedCapacity ?? []) {
|
||||
if (!entry.TableName) continue;
|
||||
consumedCapacityByTable.set(
|
||||
entry.TableName,
|
||||
(consumedCapacityByTable.get(entry.TableName) ?? 0) +
|
||||
Number(entry.CapacityUnits ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
const unprocessedKeys = response.UnprocessedKeys ?? {};
|
||||
if (Object.keys(unprocessedKeys).length === 0) {
|
||||
requestItems = {};
|
||||
break;
|
||||
}
|
||||
|
||||
requestItems = unprocessedKeys as NonNullable<
|
||||
BatchGetCommandInput['RequestItems']
|
||||
>;
|
||||
if (attempt < MAX_BATCH_GET_RETRIES) {
|
||||
const delayMs = Math.min(
|
||||
1000,
|
||||
BATCH_GET_RETRY_BASE_MS * 2 ** attempt,
|
||||
);
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(requestItems).length > 0) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Failed to batch get all items from DynamoDB',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
Responses: Object.fromEntries(responsesByTable),
|
||||
ConsumedCapacity: Array.from(consumedCapacityByTable.entries()).map(
|
||||
([TableName, CapacityUnits]) => ({
|
||||
TableName,
|
||||
CapacityUnits,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Span('ddb.batchPut', (params: unknown[]) => ({
|
||||
|
||||
@@ -184,7 +184,7 @@ const SHARE_LIST_ROW = `
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width: 100%;">
|
||||
{{#each shares}}
|
||||
<tr>
|
||||
<td class="ink rule" style="padding: 12px 0;{{#unless @first}} border-top: 1px solid ${RULE};{{/unless}} font-family: ${FONT}; font-size: 16px; line-height: 24px; color: ${INK};"><strong style="font-weight: 600;">{{this.sender}}</strong> shared ${SHARE_ITEM_LIST}</td>
|
||||
<td class="ink rule" style="padding: 12px 0;{{#unless @first}} border-top: 1px solid ${RULE};{{/unless}} font-family: ${FONT}; font-size: 16px; line-height: 24px; color: ${INK};"><strong style="font-weight: 600;">{{this.sender}}</strong> shared ${SHARE_ITEM_LIST}{{#if this.via}} via {{this.via}}{{/if}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</table>
|
||||
@@ -458,7 +458,7 @@ immediately</p>
|
||||
|
||||
Shared with you on Puter:
|
||||
{{#each shares}}
|
||||
- {{this.sender}} shared {{this.what}}
|
||||
- {{this.sender}} shared {{this.what}}{{#if this.via}} via {{this.via}}{{/if}}
|
||||
{{#each this.items}}{{#if this.link}} {{this.name}}: {{this.link}}
|
||||
{{/if}}{{/each}}{{/each}}
|
||||
|
||||
@@ -496,7 +496,7 @@ immediately</p>
|
||||
|
||||
Shared with you on Puter:
|
||||
{{#each shares}}
|
||||
- {{this.sender}} shared {{this.what}}
|
||||
- {{this.sender}} shared {{this.what}}{{#if this.via}} via {{this.via}}{{/if}}
|
||||
{{/each}}
|
||||
|
||||
There's no Puter account for {{email}} yet. Create one with this
|
||||
|
||||
@@ -227,8 +227,40 @@ export type EventMap = {
|
||||
fingerprint?: string | null;
|
||||
/** True when the created account is a temp user (no email/password). */
|
||||
is_temp?: boolean;
|
||||
/** The bonus code `puter.signup-bonus.validate` accepted, if any. */
|
||||
bonus_code?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
// Signup bonus codes are pure mechanism here: an extension decides what a
|
||||
// code is worth and fills these in (both emitted via `emitAndWait`).
|
||||
'puter.signup-bonus.check': {
|
||||
code: string;
|
||||
ip: string | null;
|
||||
fingerprint: string | null;
|
||||
valid: boolean;
|
||||
/** Opaque to core; forwarded to the client when `valid` is false. */
|
||||
reason: string | null;
|
||||
display: { title: string; description: string } | null;
|
||||
/** Verification the code will require after signup. */
|
||||
requirements: { phone: boolean; card: boolean } | null;
|
||||
};
|
||||
/**
|
||||
* Emitted once a signup has passed `puter.signup.validate`, so listeners
|
||||
* see the harness's verdict. A listener sets `accepted` to honor the code,
|
||||
* and may raise (never lower) the verification requirements.
|
||||
*/
|
||||
'puter.signup-bonus.validate': {
|
||||
code: string;
|
||||
email?: string;
|
||||
clean_email?: string;
|
||||
ip?: string | null;
|
||||
fingerprint?: string | null;
|
||||
reputation?: number | null;
|
||||
source?: 'oidc';
|
||||
requires_phone_verification: boolean;
|
||||
requires_card_verification: boolean;
|
||||
accepted: boolean;
|
||||
};
|
||||
'email.validate': {
|
||||
email: string;
|
||||
allow: boolean;
|
||||
@@ -429,6 +461,8 @@ export type EventMap = {
|
||||
* did not have it in hand, as an `expire` does not.
|
||||
*/
|
||||
values?: unknown[];
|
||||
/** Keys among `keys` private to the namespace's app. */
|
||||
noShareKeys?: string[];
|
||||
};
|
||||
/**
|
||||
* A whole namespace was emptied. Namespace-level on purpose: `flush`'s own
|
||||
@@ -605,6 +639,16 @@ export type EventMap = {
|
||||
requestHash?: string;
|
||||
mime: string;
|
||||
};
|
||||
// Asked before a hosted file is streamed. A listener that owns the site
|
||||
// sets `result.allowed = false` to withhold the entry; the visitor then
|
||||
// sees the same 404 as for a missing file.
|
||||
'site.access.check': {
|
||||
subdomain: string;
|
||||
host: string;
|
||||
requestPath: string;
|
||||
entry: { name: string; path: string };
|
||||
result: { allowed: boolean };
|
||||
};
|
||||
|
||||
// ---- Thumbnails ----
|
||||
// The listener rewrites `thumbnail` in place (an s3:// key or legacy
|
||||
@@ -808,11 +852,10 @@ export type EventKey = keyof EventMap & string;
|
||||
// Generates a wildcard for every non-final dot-separated prefix of K.
|
||||
export type WildcardPrefixes<K extends string> =
|
||||
K extends `${infer Head}.${infer Tail}`
|
||||
?
|
||||
| `${Head}.*`
|
||||
| (Tail extends `${string}.${string}`
|
||||
? `${Head}.${WildcardPrefixes<Tail>}`
|
||||
: never)
|
||||
? | `${Head}.*`
|
||||
| (Tail extends `${string}.${string}`
|
||||
? `${Head}.${WildcardPrefixes<Tail>}`
|
||||
: never)
|
||||
: never;
|
||||
|
||||
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { makeActor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
/**
|
||||
* HTTP-level coverage for `/auth/revoke-own-access-token` — the route that
|
||||
* lets an app revoke a read-URL token it minted itself, without touching the
|
||||
* account-only `/auth/revoke-access-token`.
|
||||
*/
|
||||
describe('revoke-own-access-token over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const call = (
|
||||
method: string,
|
||||
path: string,
|
||||
token: string,
|
||||
body?: unknown,
|
||||
) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
method,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
const tokenReadStatus = async (fileUid: string, token: string) => {
|
||||
const url = new URL('/token-read', env.apiOrigin);
|
||||
url.searchParams.set('uid', fileUid);
|
||||
url.searchParams.set('token', token);
|
||||
return (await fetch(url)).status;
|
||||
};
|
||||
|
||||
/** A file with real content in `owner`'s home, readable by uid. */
|
||||
const makeFile = async (owner: { username: string }) => {
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
const content = Buffer.from(`revoke-http-${crypto.randomUUID()}`);
|
||||
const path = `/${owner.username}/Documents/${crypto.randomUUID()}.txt`;
|
||||
await env.server.services.fs.write(user!.id, {
|
||||
fileMetadata: {
|
||||
path,
|
||||
size: content.byteLength,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: content,
|
||||
});
|
||||
const entry = await env.server.stores.fsEntry.getEntryByPath(path);
|
||||
return { uid: entry!.uid };
|
||||
};
|
||||
|
||||
/** An app of `owner`'s, with a token and read reach over `file`. */
|
||||
const makeApp = async (
|
||||
owner: { username: string },
|
||||
file: { uid: string },
|
||||
) => {
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
const actor = makeActor({ user: user! });
|
||||
const app = await env.server.stores.app.create(
|
||||
{
|
||||
name: `auth-http-app-${crypto.randomUUID()}`,
|
||||
title: 'Auth app',
|
||||
index_url: `https://auth-${crypto.randomUUID()}.test/`,
|
||||
},
|
||||
{ ownerUserId: actor.user.id! },
|
||||
);
|
||||
await runWithContext({ actor }, () =>
|
||||
env.server.services.permission.grantUserAppPermission(
|
||||
actor,
|
||||
app.uid,
|
||||
`fs:${file.uid}:read`,
|
||||
),
|
||||
);
|
||||
const token = await env.server.services.auth.getUserAppToken(
|
||||
actor,
|
||||
app.uid,
|
||||
);
|
||||
return { ...app, token };
|
||||
};
|
||||
|
||||
/** A scoped (non-full-access) access token for `owner`, carrying no app. */
|
||||
const makeScopedToken = async (owner: {
|
||||
username: string;
|
||||
token: string;
|
||||
}) => {
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/create-access-token',
|
||||
owner.token,
|
||||
{ permissions: ['service:foo:ii:read'], expiresIn: '1h' },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as { token: string }).token;
|
||||
};
|
||||
|
||||
/** Mints a read-URL token the way `getReadURL()` does. */
|
||||
const mintReadToken = async (callerToken: string, fileUid: string) => {
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/create-access-token',
|
||||
callerToken,
|
||||
{ permissions: [`fs:${fileUid}:read`], expiresIn: '1h' },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as { token: string }).token;
|
||||
};
|
||||
|
||||
it('lets an app revoke a read token it minted itself', async () => {
|
||||
const owner = env.users.user;
|
||||
const file = await makeFile(owner);
|
||||
const app = await makeApp(owner, file);
|
||||
const readToken = await mintReadToken(app.token, file.uid);
|
||||
|
||||
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
app.token,
|
||||
{ token: readToken },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ ok: true });
|
||||
|
||||
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
|
||||
});
|
||||
|
||||
it('404s a second app of the same user, and the URL still works', async () => {
|
||||
const owner = env.users.user;
|
||||
const file = await makeFile(owner);
|
||||
const app = await makeApp(owner, file);
|
||||
const otherApp = await makeApp(owner, file);
|
||||
const readToken = await mintReadToken(app.token, file.uid);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
otherApp.token,
|
||||
{ token: readToken },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(await res.json()).toMatchObject({ code: 'not_found' });
|
||||
|
||||
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
|
||||
});
|
||||
|
||||
it('lets the account session revoke an app-minted token', async () => {
|
||||
const owner = env.users.user;
|
||||
const file = await makeFile(owner);
|
||||
const app = await makeApp(owner, file);
|
||||
const readToken = await mintReadToken(app.token, file.uid);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
owner.token,
|
||||
{ token: readToken },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
|
||||
});
|
||||
|
||||
it('404s another user entirely', async () => {
|
||||
const owner = env.users.user;
|
||||
const file = await makeFile(owner);
|
||||
const app = await makeApp(owner, file);
|
||||
const readToken = await mintReadToken(app.token, file.uid);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
env.users.other.token,
|
||||
{ token: readToken },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(await res.json()).toMatchObject({ code: 'not_found' });
|
||||
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
|
||||
});
|
||||
|
||||
it('404s an app presenting an account-issued token', async () => {
|
||||
const owner = env.users.user;
|
||||
const file = await makeFile(owner);
|
||||
const app = await makeApp(owner, file);
|
||||
// Minted with the owner's own session, so it carries no `app_uid`.
|
||||
const accountToken = await mintReadToken(owner.token, file.uid);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
app.token,
|
||||
{ token: accountToken },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(await res.json()).toMatchObject({ code: 'not_found' });
|
||||
expect(await tokenReadStatus(file.uid, accountToken)).toBe(200);
|
||||
});
|
||||
|
||||
it('refuses a scoped access token as caller', async () => {
|
||||
const owner = env.users.user;
|
||||
const scoped = await makeScopedToken(owner);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
scoped,
|
||||
{ token: scoped },
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({ code: 'forbidden' });
|
||||
});
|
||||
|
||||
it('rejects a non-JWT string with token_invalid', async () => {
|
||||
const owner = env.users.user;
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
owner.token,
|
||||
{ token: 'not-a-jwt' },
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toMatchObject({ code: 'token_invalid' });
|
||||
});
|
||||
|
||||
it('still requires `token` in the body', async () => {
|
||||
const owner = env.users.user;
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
owner.token,
|
||||
{},
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toMatchObject({ code: 'bad_request' });
|
||||
});
|
||||
|
||||
/** A second personal API token for `owner`, minted the way the dashboard does. */
|
||||
const mintPersonalToken = async (owner: { token: string }) => {
|
||||
const { actor } = await env.server.services.auth.authenticate(
|
||||
owner.token,
|
||||
);
|
||||
return env.server.services.auth.createAccessToken(actor!, [
|
||||
[FULL_API_ACCESS],
|
||||
]);
|
||||
};
|
||||
|
||||
const whoamiStatus = async (token: string) =>
|
||||
(await call('GET', '/whoami', token)).status;
|
||||
|
||||
it('refuses to revoke a personal API token, even for its own account', async () => {
|
||||
const owner = env.users.user;
|
||||
const pat = await mintPersonalToken(owner);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
owner.token,
|
||||
{ token: pat },
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({ code: 'forbidden' });
|
||||
expect(await whoamiStatus(pat)).toBe(200);
|
||||
});
|
||||
|
||||
it('keeps a personal API token from revoking a sibling one', async () => {
|
||||
const owner = env.users.user;
|
||||
const sibling = await mintPersonalToken(owner);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
owner.apiToken,
|
||||
{ token: sibling },
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({ code: 'forbidden' });
|
||||
expect(await whoamiStatus(sibling)).toBe(200);
|
||||
});
|
||||
|
||||
it('lets a personal API token revoke its account`s read token', async () => {
|
||||
const owner = env.users.user;
|
||||
const file = await makeFile(owner);
|
||||
// Access tokens can't mint, so the account session creates the URL.
|
||||
const readToken = await mintReadToken(owner.token, file.uid);
|
||||
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
|
||||
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
owner.apiToken,
|
||||
{ token: readToken },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
|
||||
});
|
||||
|
||||
it('resolves a second revoke of the same token', async () => {
|
||||
const owner = env.users.user;
|
||||
const file = await makeFile(owner);
|
||||
const app = await makeApp(owner, file);
|
||||
const readToken = await mintReadToken(app.token, file.uid);
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/revoke-own-access-token',
|
||||
app.token,
|
||||
{ token: readToken },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,413 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Signup bonus codes through the real controller: the check route, the signup
|
||||
* hand-off to `puter.signup-bonus.validate`, and the awaited
|
||||
* `user.card-verified` a grant on card verification depends on. Tests stand in
|
||||
* for the extension with shared listeners (EventClient has no `off()`).
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import type { EventMap } from '../../clients/event/types.js';
|
||||
import type { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
|
||||
let server: PuterServer;
|
||||
let controller: any;
|
||||
|
||||
type Handler<K extends keyof EventMap> = ((data: EventMap[K]) => void) | null;
|
||||
|
||||
let onBonusCheck: Handler<'puter.signup-bonus.check'> = null;
|
||||
let onBonusValidate: Handler<'puter.signup-bonus.validate'> = null;
|
||||
let onSignupValidate: ((data: Record<string, unknown>) => void) | null = null;
|
||||
let onCardConfirm: ((data: Record<string, unknown>) => void) | null = null;
|
||||
let onCardVerified: (() => Promise<void>) | null = null;
|
||||
const heardSignupSuccess: Array<Record<string, unknown>> = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
controller = server.controllers.auth;
|
||||
const events = server.clients.event;
|
||||
events.on('puter.signup-bonus.check', (_k, data) => onBonusCheck?.(data));
|
||||
events.on('puter.signup-bonus.validate', (_k, data) =>
|
||||
onBonusValidate?.(data),
|
||||
);
|
||||
events.on('puter.signup.validate', (_k, data) =>
|
||||
onSignupValidate?.(data as Record<string, unknown>),
|
||||
);
|
||||
events.on('puter.signup.success', (_k, data) => {
|
||||
heardSignupSuccess.push(data as Record<string, unknown>);
|
||||
});
|
||||
events.on('puter.card-verification.confirm', (_k, data) =>
|
||||
onCardConfirm?.(data as Record<string, unknown>),
|
||||
);
|
||||
events.on('user.card-verified' as never, async () => {
|
||||
await onCardVerified?.();
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
onBonusCheck = null;
|
||||
onBonusValidate = null;
|
||||
onSignupValidate = null;
|
||||
onCardConfirm = null;
|
||||
onCardVerified = null;
|
||||
});
|
||||
|
||||
const uniq = () => Math.random().toString(36).slice(2, 10);
|
||||
|
||||
/** A check listener that finds the code open, as an extension would. */
|
||||
const openCheck: Handler<'puter.signup-bonus.check'> = (data) => {
|
||||
data.valid = true;
|
||||
data.display = { title: 'T', description: 'D' };
|
||||
};
|
||||
|
||||
const makeReq = (
|
||||
body: Record<string, unknown> = {},
|
||||
extra: { actor?: Actor; ip?: string } = {},
|
||||
) => ({
|
||||
body,
|
||||
headers: {},
|
||||
connection: { remoteAddress: extra.ip ?? '127.0.0.1' },
|
||||
socket: { remoteAddress: extra.ip ?? '127.0.0.1' },
|
||||
ip: extra.ip ?? '127.0.0.1',
|
||||
params: {},
|
||||
actor: extra.actor,
|
||||
});
|
||||
|
||||
const makeRes = () => {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: undefined as unknown,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(b: unknown) {
|
||||
this.body = b;
|
||||
return this;
|
||||
},
|
||||
cookie() {
|
||||
return this;
|
||||
},
|
||||
clearCookie() {
|
||||
return this;
|
||||
},
|
||||
send() {
|
||||
return this;
|
||||
},
|
||||
end() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
return res;
|
||||
};
|
||||
|
||||
const signupBody = (extra: Record<string, unknown> = {}) => {
|
||||
const username = `b_${uniq()}`;
|
||||
return {
|
||||
username,
|
||||
email: `${username}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
...extra,
|
||||
};
|
||||
};
|
||||
|
||||
const signup = async (body: Record<string, unknown>) => {
|
||||
const res = makeRes();
|
||||
await controller.handleSignup(makeReq(body), res);
|
||||
return res;
|
||||
};
|
||||
|
||||
const findUser = (username: unknown) =>
|
||||
server.stores.user.getByUsername(username as string, { force: true });
|
||||
|
||||
/** `puter.signup.success` is fire-and-forget; wait for it to land. */
|
||||
const waitForSuccess = async (userUuid: string) => {
|
||||
const deadline = Date.now() + 2000;
|
||||
while (Date.now() < deadline) {
|
||||
const hit = heardSignupSuccess.find((e) => e.user_uuid === userUuid);
|
||||
if (hit) return hit;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
throw new Error('puter.signup.success never arrived');
|
||||
};
|
||||
|
||||
// -- POST /signup/bonus-code/check -----------------------------------
|
||||
|
||||
describe('AuthController.handleSignupBonusCheck', () => {
|
||||
const check = async (body: Record<string, unknown>) => {
|
||||
const res = makeRes();
|
||||
await controller.handleSignupBonusCheck(makeReq(body), res);
|
||||
return res.body;
|
||||
};
|
||||
|
||||
it('answers invalid when no extension is listening', async () => {
|
||||
expect(await check({ bonusCode: 'startup3m-abcd1234' })).toEqual({
|
||||
valid: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the display and requirements a listener fills in', async () => {
|
||||
let seen: EventMap['puter.signup-bonus.check'] | null = null;
|
||||
onBonusCheck = (data) => {
|
||||
seen = { ...data };
|
||||
data.valid = true;
|
||||
data.display = { title: '3 months of Basic', description: 'Free' };
|
||||
data.requirements = { phone: true, card: false };
|
||||
};
|
||||
expect(
|
||||
await check({
|
||||
bonusCode: 'Startup3M-ABCD1234',
|
||||
fingerprint: 'fp1',
|
||||
}),
|
||||
).toEqual({
|
||||
valid: true,
|
||||
display: { title: '3 months of Basic', description: 'Free' },
|
||||
requirements: { phone: true, card: false },
|
||||
});
|
||||
// Listeners only ever see the canonical form.
|
||||
expect(seen).toMatchObject({
|
||||
code: 'startup3mabcd1234',
|
||||
fingerprint: 'fp1',
|
||||
ip: '127.0.0.1',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards an opaque reason for a refused code', async () => {
|
||||
onBonusCheck = (data) => {
|
||||
data.reason = 'ended';
|
||||
};
|
||||
expect(await check({ bonusCode: 'startup3m-abcd1234' })).toEqual({
|
||||
valid: false,
|
||||
reason: 'ended',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats valid without display as invalid', async () => {
|
||||
onBonusCheck = (data) => {
|
||||
data.valid = true;
|
||||
};
|
||||
expect(await check({ bonusCode: 'startup3m-abcd1234' })).toEqual({
|
||||
valid: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('answers a malformed code without consulting listeners', async () => {
|
||||
let called = false;
|
||||
onBonusCheck = () => {
|
||||
called = true;
|
||||
};
|
||||
expect(await check({ bonusCode: 'a:b' })).toEqual({ valid: false });
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('400s a non-string bonusCode', async () => {
|
||||
await expect(
|
||||
controller.handleSignupBonusCheck(
|
||||
makeReq({ bonusCode: 42 }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
// -- POST /signup with bonusCode -------------------------------------
|
||||
|
||||
describe('AuthController.handleSignup with a bonus code', () => {
|
||||
it('refuses the signup when nothing accepts the code', async () => {
|
||||
const body = signupBody({ bonusCode: 'startup3m-abcd1234' });
|
||||
await expect(signup(body)).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bonus_code_invalid',
|
||||
});
|
||||
expect(await findUser(body.username)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('refuses a dead code before the abuse gate records the attempt', async () => {
|
||||
let validated = false;
|
||||
onSignupValidate = () => {
|
||||
validated = true;
|
||||
};
|
||||
await expect(
|
||||
signup(signupBody({ bonusCode: 'startup3m-abcd1234' })),
|
||||
).rejects.toMatchObject({ legacyCode: 'bonus_code_invalid' });
|
||||
expect(validated).toBe(false);
|
||||
});
|
||||
|
||||
it('still refuses a code that passed the check but is refused after the gate', async () => {
|
||||
onBonusCheck = openCheck;
|
||||
const body = signupBody({ bonusCode: 'startup3m-abcd1234' });
|
||||
await expect(signup(body)).rejects.toMatchObject({
|
||||
legacyCode: 'bonus_code_invalid',
|
||||
});
|
||||
expect(await findUser(body.username)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('refuses a malformed code before any listener runs', async () => {
|
||||
let called = false;
|
||||
onBonusValidate = () => {
|
||||
called = true;
|
||||
};
|
||||
await expect(
|
||||
signup(signupBody({ bonusCode: 'nope' + '!'.repeat(3) })),
|
||||
).rejects.toMatchObject({ legacyCode: 'bonus_code_invalid' });
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('400s a non-string bonusCode', async () => {
|
||||
await expect(
|
||||
signup(signupBody({ bonusCode: ['startup3m'] })),
|
||||
).rejects.toMatchObject({ statusCode: 400, legacyCode: 'bad_request' });
|
||||
});
|
||||
|
||||
it('applies the requirements an accepted code raises and reports the code', async () => {
|
||||
onBonusCheck = openCheck;
|
||||
let seen: EventMap['puter.signup-bonus.validate'] | null = null;
|
||||
onBonusValidate = (data) => {
|
||||
seen = { ...data };
|
||||
data.accepted = true;
|
||||
data.requires_card_verification = true;
|
||||
};
|
||||
const body = signupBody({
|
||||
bonusCode: 'STARTUP6M-abcd1234',
|
||||
fingerprint: 'fp-bonus',
|
||||
});
|
||||
const res = await signup(body);
|
||||
expect(
|
||||
(res.body as { user: Record<string, unknown> }).user,
|
||||
).toMatchObject({ requires_card_verification: true });
|
||||
|
||||
expect(seen).toMatchObject({
|
||||
code: 'startup6mabcd1234',
|
||||
email: body.email,
|
||||
fingerprint: 'fp-bonus',
|
||||
requires_phone_verification: false,
|
||||
requires_card_verification: false,
|
||||
});
|
||||
const user = await findUser(body.username);
|
||||
expect(Boolean(user!.requires_card_verification)).toBe(true);
|
||||
expect(Boolean(user!.requires_phone_verification)).toBe(false);
|
||||
const success = await waitForSuccess(user!.uuid);
|
||||
expect(success.bonus_code).toBe('startup6mabcd1234');
|
||||
});
|
||||
|
||||
it('keeps a requirement the abuse harness set even if the listener clears it', async () => {
|
||||
onBonusCheck = openCheck;
|
||||
onSignupValidate = (data) => {
|
||||
data.requires_phone_verification = true;
|
||||
};
|
||||
onBonusValidate = (data) => {
|
||||
expect(data.requires_phone_verification).toBe(true);
|
||||
data.accepted = true;
|
||||
data.requires_phone_verification = false;
|
||||
};
|
||||
const body = signupBody({ bonusCode: 'startup3m-abcd1234' });
|
||||
await signup(body);
|
||||
const user = await findUser(body.username);
|
||||
expect(Boolean(user!.requires_phone_verification)).toBe(true);
|
||||
});
|
||||
|
||||
it('never offers the code for a signup the abuse harness blocked', async () => {
|
||||
onBonusCheck = openCheck;
|
||||
let called = false;
|
||||
onSignupValidate = (data) => {
|
||||
data.allow = false;
|
||||
data.message = 'Signup blocked';
|
||||
};
|
||||
onBonusValidate = () => {
|
||||
called = true;
|
||||
};
|
||||
await expect(
|
||||
signup(signupBody({ bonusCode: 'startup3m-abcd1234' })),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves the success event without a code when none was presented', async () => {
|
||||
const body = signupBody();
|
||||
await signup(body);
|
||||
const user = await findUser(body.username);
|
||||
const success = await waitForSuccess(user!.uuid);
|
||||
expect(success).not.toHaveProperty('bonus_code');
|
||||
});
|
||||
|
||||
it('ignores a bonus code on a temp signup', async () => {
|
||||
let called = false;
|
||||
onBonusValidate = () => {
|
||||
called = true;
|
||||
};
|
||||
const res = makeRes();
|
||||
await controller.handleSignup(
|
||||
makeReq({ is_temp: true, bonusCode: 'startup3m-abcd1234' }),
|
||||
res,
|
||||
);
|
||||
expect((res.body as { user: { is_temp: boolean } }).user.is_temp).toBe(
|
||||
true,
|
||||
);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// -- /card-verification/confirm awaits user.card-verified ------------
|
||||
|
||||
describe('AuthController.handleCardVerificationConfirm', () => {
|
||||
it('finishes user.card-verified listeners before responding', async () => {
|
||||
const body = signupBody();
|
||||
await signup(body);
|
||||
const created = await findUser(body.username);
|
||||
await server.stores.user.update(created!.id, {
|
||||
requires_card_verification: 1,
|
||||
});
|
||||
const actor = {
|
||||
user: {
|
||||
id: created!.id,
|
||||
uuid: created!.uuid,
|
||||
username: created!.username,
|
||||
email: created!.email ?? null,
|
||||
email_confirmed: false,
|
||||
},
|
||||
} as Actor;
|
||||
|
||||
onCardConfirm = (data) => {
|
||||
data.enabled = true;
|
||||
data.verified = true;
|
||||
data.fingerprint = 'fp_card_1';
|
||||
};
|
||||
let listenerDone = false;
|
||||
onCardVerified = async () => {
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
listenerDone = true;
|
||||
};
|
||||
|
||||
const res = makeRes();
|
||||
await controller.handleCardVerificationConfirm(
|
||||
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
|
||||
res,
|
||||
);
|
||||
expect(res.body).toMatchObject({ card_verified: true });
|
||||
expect(listenerDone).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -705,6 +705,79 @@ describe('AuthController.handleSignup', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('rejects 400 when the home path is already occupied by a foreign row', async () => {
|
||||
const parked = `s_${uniq()}`;
|
||||
const occupantUsername = `s_${uniq()}`;
|
||||
await controller.handleSignup(
|
||||
makeReq({
|
||||
username: occupantUsername,
|
||||
email: `${occupantUsername}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
makeRes(),
|
||||
);
|
||||
const occupant = await server.stores.user.getByUsername(
|
||||
occupantUsername,
|
||||
);
|
||||
const root = (await server.stores.fsEntry.getRootEntryForUser(
|
||||
occupant!.id,
|
||||
))!;
|
||||
await server.clients.db.write(
|
||||
'UPDATE fsentries SET path = ? WHERE id = ?',
|
||||
[`/${parked}`, root.id],
|
||||
);
|
||||
|
||||
await expect(
|
||||
controller.handleSignup(
|
||||
makeReq({
|
||||
username: parked,
|
||||
email: `${parked}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
await expect(
|
||||
server.stores.user.getByUsername(parked, { force: true }),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects 400 when only a foreign row sits under the home path', async () => {
|
||||
const parked = `s_${uniq()}`;
|
||||
const occupantUsername = `s_${uniq()}`;
|
||||
await controller.handleSignup(
|
||||
makeReq({
|
||||
username: occupantUsername,
|
||||
email: `${occupantUsername}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
makeRes(),
|
||||
);
|
||||
const docs = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${occupantUsername}/Documents`,
|
||||
))!;
|
||||
await server.clients.db.write(
|
||||
'UPDATE fsentries SET path = ? WHERE id = ?',
|
||||
[`/${parked}/Documents`, docs.id],
|
||||
);
|
||||
|
||||
await expect(
|
||||
controller.handleSignup(
|
||||
makeReq({
|
||||
username: parked,
|
||||
email: `${parked}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
await expect(
|
||||
server.stores.user.getByUsername(parked, { force: true }),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects reserved usernames (e.g. "admin")', async () => {
|
||||
await expect(
|
||||
controller.handleSignup(
|
||||
@@ -2970,6 +3043,35 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => {
|
||||
expect(typeof body.token).toBe('string');
|
||||
});
|
||||
|
||||
// What decides whether a cancelled "Sign in with Puter" still hands over a token.
|
||||
it('check-app reports an app the user never opened as unauthorized, with no token', async () => {
|
||||
const untouched = await (
|
||||
server.stores.app.create as unknown as (
|
||||
fields: Record<string, unknown>,
|
||||
opts: { ownerUserId: number },
|
||||
) => Promise<{ uid: string; id: number }>
|
||||
)(
|
||||
{
|
||||
name: `ca-${uuidv4()}`,
|
||||
title: 'Never opened',
|
||||
index_url: 'https://never-opened.example.test/index.html',
|
||||
},
|
||||
{ ownerUserId: user.id },
|
||||
);
|
||||
|
||||
const res = makeRes();
|
||||
await inCtx(actor, () =>
|
||||
controller.handleCheckApp(
|
||||
makeReq({ app_uid: untouched.uid }, { actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toEqual({
|
||||
app_uid: untouched.uid,
|
||||
authenticated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('check-app returns the {app_uid, authenticated} envelope shape', async () => {
|
||||
// Create a brand-new actor with no app-related history so the
|
||||
// permission scan can't cache-hit anything from prior tests, AND
|
||||
@@ -3032,16 +3134,7 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => {
|
||||
authenticated: boolean;
|
||||
token?: string;
|
||||
};
|
||||
expect(body.app_uid).toBe(otherApp.uid);
|
||||
expect(typeof body.authenticated).toBe('boolean');
|
||||
// Whether `authenticated` is true depends on the user's full
|
||||
// permission set (default group, owned-app implicits, etc.) — this
|
||||
// test only pins the response *shape*, since the substantive case
|
||||
// (`authenticated: true` after a paired get-user-app-token) is
|
||||
// covered by the test above.
|
||||
if (!body.authenticated) {
|
||||
expect(body.token).toBeUndefined();
|
||||
}
|
||||
expect(body).toEqual({ app_uid: otherApp.uid, authenticated: false });
|
||||
});
|
||||
|
||||
it('falls back to origin → app_uid resolution and bootstraps a new app row', async () => {
|
||||
@@ -3099,6 +3192,60 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => {
|
||||
expect(bootstrapped).toBeTruthy();
|
||||
expect(bootstrapped?.owner_user_id).toBe(owner!.id);
|
||||
});
|
||||
|
||||
it('canonicalizes alternate-host origins to one bootstrap row per subdomain', async () => {
|
||||
const subdomain = `sd-${uuidv4().slice(0, 8)}`;
|
||||
await server.stores.subdomain.create({ userId: user.id, subdomain });
|
||||
|
||||
const res = makeRes();
|
||||
await inCtx(actor, () =>
|
||||
controller.handleGetUserAppToken(
|
||||
makeReq(
|
||||
{ origin: `https://${subdomain}.host.puter.localhost` },
|
||||
{ actor },
|
||||
),
|
||||
res,
|
||||
),
|
||||
);
|
||||
const body = res.body as { token: string; app_uid: string };
|
||||
const bootstrapped = await server.stores.app.getByUid(body.app_uid);
|
||||
expect(bootstrapped?.index_url).toBe(
|
||||
`http://${subdomain}.site.puter.localhost`,
|
||||
);
|
||||
|
||||
const res2 = makeRes();
|
||||
await inCtx(actor, () =>
|
||||
controller.handleGetUserAppToken(
|
||||
makeReq(
|
||||
{ origin: `https://${subdomain}.app.puter.localhost` },
|
||||
{ actor },
|
||||
),
|
||||
res2,
|
||||
),
|
||||
);
|
||||
expect((res2.body as { app_uid: string }).app_uid).toBe(body.app_uid);
|
||||
});
|
||||
|
||||
it('supports browser extension origins in handleGetUserAppToken', async () => {
|
||||
// Random id: a pre-existing row for this origin would resolve through
|
||||
// the canonical lookup and never exercise the bootstrap path.
|
||||
const origin = `chrome-extension://${uuidv4()}`;
|
||||
const res = makeRes();
|
||||
await inCtx(actor, () =>
|
||||
controller.handleGetUserAppToken(
|
||||
makeReq({ origin }, { actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
const body = res.body as { token: string; app_uid: string };
|
||||
expect(body.app_uid).toBe(
|
||||
`app-${uuidv5(origin, APP_ORIGIN_UUID_NAMESPACE)}`,
|
||||
);
|
||||
const bootstrapped = await server.stores.app.getByUid(body.app_uid);
|
||||
expect(bootstrapped?.index_url).toBe(origin);
|
||||
// Proves the row came from the bootstrap path, not an earlier test.
|
||||
expect(bootstrapped?.description).toMatch(/^App created from origin /);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Access tokens: create + revoke ─────────────────────────────────
|
||||
@@ -5034,6 +5181,102 @@ describe('AuthController.handleSaveAccount address conflicts', () => {
|
||||
expect(untouched!.email).toBeNull();
|
||||
expect(untouched!.password).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses to promote a temp account when the home path is already occupied', async () => {
|
||||
const parked = `save_home_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const { user: occupant } = await makeUserAndActor();
|
||||
const root = (await server.stores.fsEntry.getRootEntryForUser(
|
||||
occupant.id,
|
||||
))!;
|
||||
await server.clients.db.write(
|
||||
'UPDATE fsentries SET path = ? WHERE id = ?',
|
||||
[`/${parked}`, root.id],
|
||||
);
|
||||
|
||||
const tempRes = makeRes();
|
||||
await controller.handleSignup(makeReq({ is_temp: true }), tempRes);
|
||||
const tempUser = (
|
||||
tempRes.body as { user: { username: string; uuid: string } }
|
||||
).user;
|
||||
const tempRow = await server.stores.user.getByUuid(tempUser.uuid);
|
||||
const actor = {
|
||||
user: {
|
||||
id: tempRow!.id,
|
||||
uuid: tempRow!.uuid,
|
||||
username: tempRow!.username,
|
||||
email: null,
|
||||
email_confirmed: false,
|
||||
},
|
||||
} as Actor;
|
||||
|
||||
await expect(
|
||||
controller.handleSaveAccount(
|
||||
makeReq(
|
||||
{
|
||||
username: parked,
|
||||
email: `${parked}@test.local`,
|
||||
password: 'another-strong-password',
|
||||
},
|
||||
{ actor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
const untouched = await server.stores.user.getById(tempRow!.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(untouched!.username).toBe(tempRow!.username);
|
||||
expect(untouched!.password).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses to promote a temp account when only a foreign row sits under the home path', async () => {
|
||||
const parked = `save_home_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const { user: occupant } = await makeUserAndActor();
|
||||
const docs = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${occupant.username}/Documents`,
|
||||
))!;
|
||||
await server.clients.db.write(
|
||||
'UPDATE fsentries SET path = ? WHERE id = ?',
|
||||
[`/${parked}/Documents`, docs.id],
|
||||
);
|
||||
|
||||
const tempRes = makeRes();
|
||||
await controller.handleSignup(makeReq({ is_temp: true }), tempRes);
|
||||
const tempUser = (
|
||||
tempRes.body as { user: { username: string; uuid: string } }
|
||||
).user;
|
||||
const tempRow = await server.stores.user.getByUuid(tempUser.uuid);
|
||||
const actor = {
|
||||
user: {
|
||||
id: tempRow!.id,
|
||||
uuid: tempRow!.uuid,
|
||||
username: tempRow!.username,
|
||||
email: null,
|
||||
email_confirmed: false,
|
||||
},
|
||||
} as Actor;
|
||||
|
||||
await expect(
|
||||
controller.handleSaveAccount(
|
||||
makeReq(
|
||||
{
|
||||
username: parked,
|
||||
email: `${parked}@test.local`,
|
||||
password: 'another-strong-password',
|
||||
},
|
||||
{ actor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
const untouched = await server.stores.user.getById(tempRow!.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(untouched!.username).toBe(tempRow!.username);
|
||||
expect(untouched!.password).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Password recovery flow ──────────────────────────────────────────
|
||||
@@ -5092,7 +5335,9 @@ describe('AuthController password recovery', () => {
|
||||
expect((res.body as { message: string }).message).toMatch(
|
||||
/If that account exists/i,
|
||||
);
|
||||
const after = await server.stores.user.getById(seat.id, { force: true });
|
||||
const after = await server.stores.user.getById(seat.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.pass_recovery_token).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -5357,7 +5602,10 @@ describe('AuthController user-protected mutations (validation paths)', () => {
|
||||
|
||||
await expect(
|
||||
controller.handleChangeEmail(
|
||||
makeReq({ new_email: `moved_${uniq()}@example.com` }, { actor }),
|
||||
makeReq(
|
||||
{ new_email: `moved_${uniq()}@example.com` },
|
||||
{ actor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
@@ -5385,7 +5633,9 @@ describe('AuthController user-protected mutations (validation paths)', () => {
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
|
||||
const after = await server.stores.user.getById(seat.id, { force: true });
|
||||
const after = await server.stores.user.getById(seat.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.username).toBe(seat.username);
|
||||
});
|
||||
|
||||
@@ -5423,6 +5673,62 @@ describe('AuthController user-protected mutations (validation paths)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('change-username: 400 when the home path is already occupied, before the rename', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
// A name no account holds, whose home path a stray row does. This is
|
||||
// the shape of legacy drift; it used to leave two roots at one path.
|
||||
const parked = `p_${uniq()}`;
|
||||
const root = (await server.stores.fsEntry.getRootEntryForUser(
|
||||
user.id,
|
||||
))!;
|
||||
await server.clients.db.write(
|
||||
'UPDATE fsentries SET path = ? WHERE id = ?',
|
||||
[`/${parked}`, root.id],
|
||||
);
|
||||
const { user: mover, actor: moverActor } = await makeUserAndActor();
|
||||
|
||||
await expect(
|
||||
controller.handleChangeUsername(
|
||||
makeReq({ new_username: parked }, { actor: moverActor }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
// The username claim is refused whole: the user row keeps its name.
|
||||
const after = await server.stores.user.getById(mover.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.username).toBe(mover.username);
|
||||
void actor;
|
||||
});
|
||||
|
||||
it('change-username: 400 when only a foreign row sits under the target home path', async () => {
|
||||
const { user } = await makeUserAndActor();
|
||||
// Nothing sits at `/parked` itself — only a leftover child row does,
|
||||
// the shape a partial cascade or a user-scoped cleanup leaves behind.
|
||||
const parked = `p_${uniq()}`;
|
||||
const docs = (await server.stores.fsEntry.getEntryByPath(
|
||||
`/${user.username}/Documents`,
|
||||
))!;
|
||||
await server.clients.db.write(
|
||||
'UPDATE fsentries SET path = ? WHERE id = ?',
|
||||
[`/${parked}/Documents`, docs.id],
|
||||
);
|
||||
const { user: mover, actor: moverActor } = await makeUserAndActor();
|
||||
|
||||
await expect(
|
||||
controller.handleChangeUsername(
|
||||
makeReq({ new_username: parked }, { actor: moverActor }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
|
||||
const after = await server.stores.user.getById(mover.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after!.username).toBe(mover.username);
|
||||
});
|
||||
|
||||
it('change-email: 400 on missing/invalid email and on a confirmed-account collision', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
await expect(
|
||||
@@ -5865,6 +6171,152 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
|
||||
});
|
||||
});
|
||||
|
||||
// What lets a launch settle a consent prompt it holds no app token for.
|
||||
it('check-permissions: `app_uid` answers for that app, not for the asking user', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `cpa-${uuidv4()}`,
|
||||
title: 'TestCheckPermsAppUid',
|
||||
index_url: 'https://check-perms-uid.example.test/index.html',
|
||||
},
|
||||
{ ownerUserId: user.id },
|
||||
);
|
||||
const permission = `user:${user.uuid}:email:read`;
|
||||
|
||||
// Held by the user, so asking as the user would answer `true`.
|
||||
const asUser = makeRes();
|
||||
await inCtx(actor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq({ permissions: [permission] }, { actor }),
|
||||
asUser,
|
||||
),
|
||||
);
|
||||
expect(asUser.body).toEqual({ permissions: { [permission]: true } });
|
||||
|
||||
const before = makeRes();
|
||||
await inCtx(actor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq(
|
||||
{ permissions: [permission], app_uid: app.uid },
|
||||
{ actor },
|
||||
),
|
||||
before,
|
||||
),
|
||||
);
|
||||
expect(before.body).toEqual({ permissions: { [permission]: false } });
|
||||
|
||||
await inCtx(actor, () =>
|
||||
controller.handleGrantUserApp(
|
||||
makeReq({ app_uid: app.uid, permission, extra: {} }, { actor }),
|
||||
makeRes(),
|
||||
),
|
||||
);
|
||||
|
||||
const after = makeRes();
|
||||
await inCtx(actor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq(
|
||||
{ permissions: [permission], app_uid: app.uid },
|
||||
{ actor },
|
||||
),
|
||||
after,
|
||||
),
|
||||
);
|
||||
expect(after.body).toEqual({ permissions: { [permission]: true } });
|
||||
});
|
||||
|
||||
it('check-permissions: an app cannot ask about another app, and an unknown app 404s', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `cpx-${uuidv4()}`,
|
||||
title: 'TestCheckPermsCrossApp',
|
||||
index_url: 'https://check-perms-x.example.test/index.html',
|
||||
},
|
||||
{ ownerUserId: user.id },
|
||||
);
|
||||
const appActor = makeActor({
|
||||
user: actor.user,
|
||||
app: { id: app.id, uid: app.uid },
|
||||
});
|
||||
|
||||
await expect(
|
||||
inCtx(appActor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq(
|
||||
{
|
||||
permissions: ['service:foo:ii:read'],
|
||||
app_uid: app.uid,
|
||||
},
|
||||
{ actor: appActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
|
||||
await expect(
|
||||
inCtx(actor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq(
|
||||
{
|
||||
permissions: ['service:foo:ii:read'],
|
||||
app_uid: `app-${uuidv4()}`,
|
||||
},
|
||||
{ actor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
|
||||
await expect(
|
||||
inCtx(actor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq(
|
||||
{
|
||||
permissions: ['service:foo:ii:read'],
|
||||
app_uid: { not: 'a string' } as unknown as string,
|
||||
},
|
||||
{ actor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
// Falling through to the user here would answer `true` for any file they own.
|
||||
it('check-permissions: a present-but-empty `app_uid` is refused, not read as "no app"', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const permission = `user:${user.uuid}:email:read`;
|
||||
|
||||
for (const app_uid of ['', null]) {
|
||||
await expect(
|
||||
inCtx(actor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq(
|
||||
{ permissions: [permission], app_uid },
|
||||
{ actor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
}
|
||||
|
||||
// Omitted entirely still means "what do I hold?".
|
||||
const res = makeRes();
|
||||
await inCtx(actor, () =>
|
||||
controller.handleCheckPermissions(
|
||||
makeReq({ permissions: [permission] }, { actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(res.body).toEqual({ permissions: { [permission]: true } });
|
||||
});
|
||||
|
||||
it('list-permissions: returns the shape and includes a user→app grant with its app_uid', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const app = await server.stores.app.create(
|
||||
@@ -6641,7 +7093,9 @@ describe('AuthController.handleDeleteOwnUser', () => {
|
||||
controller.handleDeleteOwnUser(makeReq({}, { actor }), makeRes()),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
|
||||
const after = await server.stores.user.getById(seat.id, { force: true });
|
||||
const after = await server.stores.user.getById(seat.id, {
|
||||
force: true,
|
||||
});
|
||||
expect(after).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
hasVerifiedPhone,
|
||||
} from '../../core/http/middleware/gates.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { isPlainUserActor, makeActor } from '../../core/actor.js';
|
||||
import { checkRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import {
|
||||
signStepUpToken,
|
||||
@@ -75,6 +76,13 @@ import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
|
||||
import { generate_identifier } from '../../util/identifier.js';
|
||||
import { parsePhone } from '../../util/phone.js';
|
||||
import {
|
||||
bonusCodeInvalidError,
|
||||
checkSignupBonus,
|
||||
isAbsentBonusCode,
|
||||
normalizeBonusCode,
|
||||
validateSignupBonus,
|
||||
} from '../../util/signupBonus.js';
|
||||
import { isTemporaryPasswordExpired } from '../../util/temporaryPassword.js';
|
||||
import { getTaskbarItems } from '../../util/taskbarItems.js';
|
||||
import {
|
||||
@@ -109,6 +117,8 @@ const FINGERPRINT_MAX_LENGTH = 128;
|
||||
// crafted request from turning a single grant call into a bulk write.
|
||||
const MAX_PERMISSIONS_PER_REQUEST = 16;
|
||||
const DISPATCH_ID_MAX_LENGTH = 128;
|
||||
// One name for the flag, so the write and the read cannot drift apart.
|
||||
const APP_AUTHENTICATED_FLAG = 'flag:app-is-authenticated';
|
||||
|
||||
// -- Post-login route limits -----------------------------------------
|
||||
//
|
||||
@@ -130,6 +140,14 @@ const CREDENTIAL_MINT_LIMIT = {
|
||||
key: 'user',
|
||||
} as const;
|
||||
|
||||
/** Revoking is cheap and undoing a mistake shouldn't fight the mint budget. */
|
||||
const ACCESS_TOKEN_REVOKE_LIMIT = {
|
||||
scope: 'auth-access-token-revoke',
|
||||
limit: 60,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Second-factor configuration, including the verify leg. Shorter window than
|
||||
* the mint limit because enabling 2FA legitimately involves a few attempts in a
|
||||
@@ -804,6 +822,18 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
}
|
||||
|
||||
// Only the shape is judged here; whether the code is honored is up to
|
||||
// `puter.signup-bonus.validate`, after the abuse checks below.
|
||||
let bonusCode: string | null = null;
|
||||
if (!is_temp && !isAbsentBonusCode(body.bonusCode)) {
|
||||
if (typeof body.bonusCode !== 'string')
|
||||
throw new HttpError(400, 'bonusCode must be a string.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
bonusCode = normalizeBonusCode(body.bonusCode);
|
||||
if (!bonusCode) throw bonusCodeInvalidError();
|
||||
}
|
||||
|
||||
// Signup-disabled gate. Runs before the duplicate checks so a
|
||||
// disabled endpoint doesn't reveal which usernames or emails
|
||||
// exist. Claiming a pre-existing placeholder row is still
|
||||
@@ -836,6 +866,22 @@ export class AuthController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
// ...and the same against the filesystem: a free username whose home
|
||||
// path is occupied (exactly or by leftover rows underneath it) would
|
||||
// provision a second root there, and the two trees then resolve
|
||||
// interchangeably.
|
||||
if (
|
||||
await this.stores.fsEntry.findHomePathConflict(
|
||||
body.username,
|
||||
undefined,
|
||||
{ includeDescendants: true },
|
||||
)
|
||||
) {
|
||||
throw new HttpError(400, 'This username is not available.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
// Duplicate confirmed-email check. A confirmed account (any
|
||||
// credential type — password OR OIDC) on this email → reject.
|
||||
//
|
||||
@@ -868,6 +914,20 @@ export class AuthController extends PuterController {
|
||||
? null
|
||||
: await this.#resolveSignupEmailClaim(body.email);
|
||||
|
||||
// A dead code fails here rather than after the gate below, which records
|
||||
// an allowed attempt against the address as if an account followed.
|
||||
if (
|
||||
bonusCode &&
|
||||
!(
|
||||
await checkSignupBonus(this.clients.event, bonusCode, {
|
||||
ip: clientIp,
|
||||
fingerprint,
|
||||
})
|
||||
).valid
|
||||
) {
|
||||
throw bonusCodeInvalidError();
|
||||
}
|
||||
|
||||
// Extension-level validation gate. Abuse-prevention extensions
|
||||
// inspect the incoming signup and can:
|
||||
// - block it outright via `event.allow = false`
|
||||
@@ -954,18 +1014,37 @@ export class AuthController extends PuterController {
|
||||
const force_email_confirmation = Boolean(
|
||||
validateEvent.requires_email_confirmation,
|
||||
);
|
||||
const force_phone_verification =
|
||||
let force_phone_verification =
|
||||
Boolean(validateEvent.requires_phone_verification) ||
|
||||
// Test/QA switch: force the SMS gate on every signup regardless of
|
||||
// reputation (see config.always_require_phone_verification).
|
||||
Boolean(this.config.always_require_phone_verification);
|
||||
const force_card_verification = Boolean(
|
||||
let force_card_verification = Boolean(
|
||||
validateEvent.requires_card_verification ||
|
||||
// Test/QA switch: force the card gate on every signup regardless of
|
||||
// reputation (see config.always_require_card_verification).
|
||||
this.config.always_require_card_verification,
|
||||
);
|
||||
|
||||
if (bonusCode) {
|
||||
const verdict = await validateSignupBonus(
|
||||
this.clients.event,
|
||||
bonusCode,
|
||||
{
|
||||
email: body.email,
|
||||
clean_email: cleanEmail(body.email),
|
||||
ip: clientIp,
|
||||
fingerprint,
|
||||
reputation: validateEvent.reputation,
|
||||
requires_phone_verification: force_phone_verification,
|
||||
requires_card_verification: force_card_verification,
|
||||
},
|
||||
);
|
||||
if (!verdict.accepted) throw bonusCodeInvalidError();
|
||||
force_phone_verification = verdict.requiresPhoneVerification;
|
||||
force_card_verification = verdict.requiresCardVerification;
|
||||
}
|
||||
|
||||
// Prepare shared fields
|
||||
const user_uuid = uuidv4();
|
||||
const email_confirm_code = String(crypto.randomInt(100000, 1000000));
|
||||
@@ -1186,6 +1265,7 @@ export class AuthController extends PuterController {
|
||||
// have to agree or per-IP counters are written under one
|
||||
// key and read under another.
|
||||
ip: clientIp,
|
||||
...(bonusCode ? { bonus_code: bonusCode } : {}),
|
||||
} as never,
|
||||
{},
|
||||
);
|
||||
@@ -1207,6 +1287,64 @@ export class AuthController extends PuterController {
|
||||
await this.#completeLogin(req, res, user!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview a signup bonus code before the account exists, so the signup form
|
||||
* can say what it grants. Answers only valid/invalid: an extension owns the
|
||||
* codes, and with none installed every code is invalid.
|
||||
*/
|
||||
@Post('/signup/bonus-code/check', {
|
||||
rateLimit: [
|
||||
{ scope: 'signup-bonus-check', limit: 20, window: 15 * 60_000 },
|
||||
{
|
||||
scope: 'signup-bonus-check-ip',
|
||||
limit: 60,
|
||||
window: 15 * 60_000,
|
||||
key: 'ip',
|
||||
},
|
||||
],
|
||||
})
|
||||
async handleSignupBonusCheck(req: Request, res: Response): Promise<void> {
|
||||
const raw = req.body?.bonusCode;
|
||||
if (typeof raw !== 'string')
|
||||
throw new HttpError(400, 'bonusCode must be a string.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
const code = normalizeBonusCode(raw);
|
||||
if (!code) {
|
||||
res.json({ valid: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const fingerprint =
|
||||
typeof req.body?.fingerprint === 'string' &&
|
||||
req.body.fingerprint.length <= FINGERPRINT_MAX_LENGTH
|
||||
? req.body.fingerprint
|
||||
: null;
|
||||
const checkEvent = await checkSignupBonus(this.clients.event, code, {
|
||||
ip: (req.ip || req.socket?.remoteAddress || null) as string | null,
|
||||
fingerprint,
|
||||
});
|
||||
|
||||
if (checkEvent.valid !== true || !checkEvent.display) {
|
||||
res.json({
|
||||
valid: false,
|
||||
...(checkEvent.reason ? { reason: checkEvent.reason } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
valid: true,
|
||||
display: {
|
||||
title: String(checkEvent.display.title),
|
||||
description: String(checkEvent.display.description),
|
||||
},
|
||||
requirements: {
|
||||
phone: checkEvent.requirements?.phone === true,
|
||||
card: checkEvent.requirements?.card === true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// -- Logout ------------------------------------------------------
|
||||
|
||||
@Post('/logout', {
|
||||
@@ -2200,8 +2338,11 @@ export class AuthController extends PuterController {
|
||||
...(clearedPhoneGate ? { requires_phone_verification: 0 } : {}),
|
||||
});
|
||||
|
||||
// Awaited like `user.phone-verified`, so whatever a listener grants on
|
||||
// verification is in place before the client refreshes. emitAndWait
|
||||
// swallows listener errors, so confirm never fails on one.
|
||||
try {
|
||||
this.clients.event?.emit(
|
||||
await this.clients.event?.emitAndWait(
|
||||
'user.card-verified' as never,
|
||||
{
|
||||
user_id: user.id,
|
||||
@@ -2214,7 +2355,7 @@ export class AuthController extends PuterController {
|
||||
{},
|
||||
);
|
||||
} catch {
|
||||
// ignore — event is a side-channel signal, not load-bearing
|
||||
// ignore — listeners are best-effort
|
||||
}
|
||||
// Notify other tabs/devices for this user so they refresh + drop the gate.
|
||||
try {
|
||||
@@ -2587,6 +2728,20 @@ export class AuthController extends PuterController {
|
||||
legacyCode: 'username_already_in_use',
|
||||
});
|
||||
}
|
||||
// Before the username is written, not after: the rename below is what
|
||||
// keeps the account's files reachable, and it can't run onto a taken
|
||||
// path.
|
||||
if (
|
||||
await this.stores.fsEntry.findHomePathConflict(
|
||||
new_username,
|
||||
req.actor!.user.id!,
|
||||
{ includeDescendants: true },
|
||||
)
|
||||
) {
|
||||
throw new HttpError(400, 'This username is not available.', {
|
||||
legacyCode: 'username_already_in_use',
|
||||
});
|
||||
}
|
||||
|
||||
await this.stores.user.update(req.actor!.user.id!, {
|
||||
username: new_username,
|
||||
@@ -2911,6 +3066,15 @@ export class AuthController extends PuterController {
|
||||
legacyCode: 'username_already_in_use',
|
||||
});
|
||||
}
|
||||
if (
|
||||
await this.stores.fsEntry.findHomePathConflict(username, user.id, {
|
||||
includeDescendants: true,
|
||||
})
|
||||
) {
|
||||
throw new HttpError(400, 'This username is not available.', {
|
||||
legacyCode: 'username_already_in_use',
|
||||
});
|
||||
}
|
||||
// Match raw + canonical to catch gmail-alias collisions, and
|
||||
// reject on ANY confirmed account (OIDC accounts have
|
||||
// password=null but are real) — not just password-holders.
|
||||
@@ -3535,27 +3699,66 @@ export class AuthController extends PuterController {
|
||||
|
||||
// -- Permission checks -------------------------------------------
|
||||
|
||||
/**
|
||||
* The caller's account acting as `appIdentifier`, resolved uid-or-name like
|
||||
* the grant handlers.
|
||||
*/
|
||||
async #appUnderUserActor(
|
||||
actor: Actor,
|
||||
appIdentifier: unknown,
|
||||
): Promise<Actor> {
|
||||
this.#validateAppPermissionParams({ app_uid: appIdentifier });
|
||||
// Sessions only: for an app, this would be a window onto its neighbours' grants.
|
||||
if (!isPlainUserActor(actor)) {
|
||||
throw new HttpError(403, 'actor must be a user', {
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
}
|
||||
const app = await this.stores.app.resolveApp(appIdentifier as string);
|
||||
if (!app) {
|
||||
throw new HttpError(404, `App ${appIdentifier} does not exist`, {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
}
|
||||
return makeActor({
|
||||
user: actor.user,
|
||||
app: { id: app.id, uid: app.uid },
|
||||
});
|
||||
}
|
||||
|
||||
@Post('/auth/check-permissions', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
rateLimit: AUTH_CHECK_LIMIT,
|
||||
})
|
||||
async handleCheckPermissions(req: Request, res: Response): Promise<void> {
|
||||
const { permissions } = req.body ?? {};
|
||||
const { permissions, app_uid } = req.body ?? {};
|
||||
if (!Array.isArray(permissions)) {
|
||||
throw new HttpError(400, 'Missing or invalid `permissions` array', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
// Present but empty must not fall through to checking the user: on their own file every `fs:` scope answers `true`.
|
||||
if (
|
||||
app_uid !== undefined &&
|
||||
(typeof app_uid !== 'string' || !app_uid)
|
||||
) {
|
||||
throw new HttpError(400, 'Invalid `app_uid`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
// `app_uid` asks what an app of mine holds, not what I hold.
|
||||
const actor = app_uid
|
||||
? await this.#appUnderUserActor(req.actor!, app_uid)
|
||||
: req.actor!;
|
||||
|
||||
const unique = [...new Set(permissions)] as string[];
|
||||
const result: Record<string, boolean> = {};
|
||||
let granted: Map<string, boolean>;
|
||||
try {
|
||||
granted = await this.services.permission.checkMany(
|
||||
req.actor!,
|
||||
unique,
|
||||
);
|
||||
granted = await this.services.permission.checkMany(actor, unique);
|
||||
} catch {
|
||||
granted = new Map<string, boolean>();
|
||||
}
|
||||
@@ -3839,11 +4042,18 @@ export class AuthController extends PuterController {
|
||||
if (!app && resolvedFromOrigin) {
|
||||
// Hosted-subdomain origins get the site owner stamped as the
|
||||
// app's creator at bootstrap; external origins stay unowned.
|
||||
// Canonical origin only, so alternate hosts share one row.
|
||||
const canonicalOrigin =
|
||||
this.services.auth.canonicalizeOrigin(origin);
|
||||
const ownerUserId =
|
||||
await this.services.auth.subdomainOwnerIdFromOrigin(origin);
|
||||
app = await this.stores.app.createFromOrigin(app_uid, origin, {
|
||||
ownerUserId,
|
||||
});
|
||||
await this.services.auth.subdomainOwnerIdFromOrigin(
|
||||
canonicalOrigin,
|
||||
);
|
||||
app = await this.stores.app.createFromOrigin(
|
||||
app_uid,
|
||||
canonicalOrigin,
|
||||
{ ownerUserId },
|
||||
);
|
||||
// An origin's uid is a deterministic uuidv5, so a deleted app
|
||||
// reappears here under the identical uid. Withdraw any cross-app
|
||||
// data grants left pointing at it before this new row can inherit
|
||||
@@ -3871,7 +4081,7 @@ export class AuthController extends PuterController {
|
||||
this.services.permission.grantUserAppPermission(
|
||||
req.actor!,
|
||||
app_uid,
|
||||
'flag:app-is-authenticated',
|
||||
APP_AUTHENTICATED_FLAG,
|
||||
{},
|
||||
{},
|
||||
);
|
||||
@@ -3955,13 +4165,15 @@ export class AuthController extends PuterController {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
// Check if the app is authenticated for this user
|
||||
const authenticated = await this.services.permission
|
||||
.check(
|
||||
req.actor!,
|
||||
`service:${app_uid}:ii:flag:app-is-authenticated`,
|
||||
)
|
||||
.catch(() => false);
|
||||
// The exact row, not a scan: a `service:`-shaped check falls open on the `service` root.
|
||||
const app = await this.stores.app.resolveApp(app_uid);
|
||||
const userId = req.actor!.user?.id;
|
||||
const authenticated =
|
||||
!!app?.id &&
|
||||
!!userId &&
|
||||
(await this.stores.permission
|
||||
.hasUserAppPerm(userId, app.id, APP_AUTHENTICATED_FLAG)
|
||||
.catch(() => false));
|
||||
|
||||
const result: {
|
||||
app_uid: string;
|
||||
@@ -3969,9 +4181,10 @@ export class AuthController extends PuterController {
|
||||
token?: string;
|
||||
} = { app_uid, authenticated };
|
||||
if (authenticated) {
|
||||
// The resolved uid: the token carries it as the app's identity.
|
||||
result.token = await this.services.auth.getUserAppToken(
|
||||
req.actor!,
|
||||
app_uid,
|
||||
app!.uid,
|
||||
);
|
||||
}
|
||||
res.json(result);
|
||||
@@ -4044,10 +4257,36 @@ export class AuthController extends PuterController {
|
||||
res.json({ token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes an access token given as its JWT. Unlike
|
||||
* `/auth/revoke-access-token`, apps may call this; the service limits an
|
||||
* app to tokens it issued and refuses personal API tokens.
|
||||
*/
|
||||
@Post('/auth/revoke-own-access-token', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
rateLimit: ACCESS_TOKEN_REVOKE_LIMIT,
|
||||
})
|
||||
async handleRevokeOwnAccessToken(
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const { token } = req.body ?? {};
|
||||
if (!token || typeof token !== 'string') {
|
||||
throw new HttpError(400, 'Missing `token`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
await this.services.auth.revokeOwnAccessToken(req.actor!, token);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
// Wired imperatively in `registerRoutes` so the cookie-only gate
|
||||
// (built from `this.config`) can be composed in. Cookie-only is
|
||||
// mandatory: a leaked access token must not be able to silently
|
||||
// revoke its own siblings.
|
||||
// mandatory: a leaked access token must not be able to revoke a
|
||||
// personal API token, or revoke by raw uuid — those stay web-session-only
|
||||
// here; `/auth/revoke-own-access-token` covers revoking scoped tokens by
|
||||
// JWT and already refuses PATs itself.
|
||||
async handleRevokeAccessToken(req: Request, res: Response): Promise<void> {
|
||||
let { tokenOrUuid } = req.body ?? {};
|
||||
if (!tokenOrUuid || typeof tokenOrUuid !== 'string') {
|
||||
@@ -4399,6 +4638,25 @@ export class AuthController extends PuterController {
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
// The other direction: deleting an owner sets `owner_user_id` NULL and
|
||||
// cascades their membership, leaving a team no one can administer —
|
||||
// not even an admin, since every team route resolves by membership.
|
||||
if (await this.stores.team.countOwned(userId)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Delete the teams you own before closing this account.',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
// A deleted team's seats are suspended, not gone, and only its owner
|
||||
// can retire them.
|
||||
if (await this.stores.team.countOwnedTeamsWithAccounts(userId)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Close the accounts your teams created before closing this one.',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
res.clearCookie(this.config.cookie_name ?? 'puter_token');
|
||||
res.clearCookie('puter_token_v2');
|
||||
res.clearCookie('puter_revalidation');
|
||||
|
||||
@@ -179,6 +179,28 @@ const throwing = (payload: unknown) => () => {
|
||||
// -- Upstream status extraction --------------------------------------
|
||||
|
||||
describe('DriverController upstream error translation', () => {
|
||||
it('maps an upstream 402 to sanitized credit exhaustion', async () => {
|
||||
const { err } = await callWith(
|
||||
throwing(
|
||||
Object.assign(
|
||||
new Error(
|
||||
'<html>Insufficient credits. Add more using https://openrouter.ai/settings/credits (request id: req-secret)</html>',
|
||||
),
|
||||
{ status: 402 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 503,
|
||||
legacyCode: 'upstream_credits_exhausted',
|
||||
message: 'AI provider out of credits',
|
||||
fields: { upstreamStatus: 402 },
|
||||
});
|
||||
expect(JSON.stringify(err)).not.toContain('http');
|
||||
expect(JSON.stringify(err)).not.toMatch(/request id|<html>/i);
|
||||
});
|
||||
|
||||
it('maps an upstream 429 to a Puter 429 with upstream_rate_limited', async () => {
|
||||
const { err } = await callWith(
|
||||
throwing(
|
||||
|
||||
@@ -31,7 +31,11 @@ import {
|
||||
} from '../../core/http/middleware/rateLimit.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import type { DriverMeta } from '../../drivers/meta.js';
|
||||
import { isUpstreamTimeoutError } from '../../drivers/util/upstreamErrors.js';
|
||||
import {
|
||||
isCreditExhaustion,
|
||||
isUpstreamTimeoutError,
|
||||
sanitizeUpstreamMessage,
|
||||
} from '../../drivers/util/upstreamErrors.js';
|
||||
import {
|
||||
isDriverStreamResult,
|
||||
resolveCallableMethods,
|
||||
@@ -120,6 +124,18 @@ const translateProviderError = (err: unknown): unknown => {
|
||||
cause?: unknown;
|
||||
};
|
||||
const status = extractUpstreamStatus(e);
|
||||
const msg = sanitizeUpstreamMessage(
|
||||
e.error?.message ?? e.message ?? 'Upstream provider error',
|
||||
);
|
||||
const upstreamCode = e.error?.code ?? e.code;
|
||||
const fields = { upstreamStatus: status, upstreamCode };
|
||||
|
||||
if (isCreditExhaustion(status, upstreamCode, msg)) {
|
||||
return new HttpError(503, 'AI provider out of credits', {
|
||||
legacyCode: 'upstream_credits_exhausted',
|
||||
fields,
|
||||
});
|
||||
}
|
||||
if (typeof status !== 'number') {
|
||||
if (isUpstreamTimeoutError(e)) {
|
||||
const cause = e.cause as { code?: string } | undefined;
|
||||
@@ -132,10 +148,6 @@ const translateProviderError = (err: unknown): unknown => {
|
||||
return err;
|
||||
}
|
||||
|
||||
const msg = e.error?.message ?? e.message ?? 'Upstream provider error';
|
||||
const upstreamCode = e.error?.code ?? e.code;
|
||||
const fields = { upstreamStatus: status, upstreamCode };
|
||||
|
||||
if (status === 429) {
|
||||
return new HttpError(429, msg, {
|
||||
legacyCode: 'upstream_rate_limited',
|
||||
|
||||
@@ -287,12 +287,14 @@ export class EventsController extends PuterController {
|
||||
|
||||
// -- Events workers ------------------------------------------------
|
||||
//
|
||||
// The billable artifact a published handler set implies. Account-scoped
|
||||
// like the kv-handle surface above: this is the owner's own view of what
|
||||
// it is paying for, so — unlike the handler routes — an app token cannot
|
||||
// act here on its owner's behalf.
|
||||
// The billable artifact a published handler set implies. An account
|
||||
// session or full-access token sees every app it owns; an app sees only
|
||||
// its own worker, the same scope publish and destroy use.
|
||||
|
||||
/** GET /events/workers — the caller's own events workers, one per app. */
|
||||
/**
|
||||
* GET /events/workers — an account's events workers, one per app, or an
|
||||
* app's own worker (0 or 1 item).
|
||||
*/
|
||||
@Get('/workers', {
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER,
|
||||
EVENTS_KV_HANDLES_PER_APP,
|
||||
EVENTS_KV_HANDLES_PER_USER,
|
||||
EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT,
|
||||
EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT,
|
||||
EVENTS_SINGLE_DELIVERY_LIMIT,
|
||||
EVENTS_WORKER_INVOCATION_LIMIT,
|
||||
limitFor,
|
||||
@@ -119,6 +121,34 @@ describe('the tiered subscription quotas', () => {
|
||||
});
|
||||
|
||||
describe('the delivery budgets', () => {
|
||||
it('sets KV fan-out and filter work by the namespace owner tier', () => {
|
||||
for (const [plan, matched, evaluated] of [
|
||||
[null, 512, 2048],
|
||||
[DEFAULT_FREE_SUBSCRIPTION, 128, 512],
|
||||
[DEFAULT_TEMP_SUBSCRIPTION, 128, 512],
|
||||
[ORG_SEAT_FREE_SUBSCRIPTION, 128, 512],
|
||||
] as const) {
|
||||
expect(
|
||||
limitFor(EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT, plan),
|
||||
).toBe(matched);
|
||||
expect(
|
||||
limitFor(EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT, plan),
|
||||
).toBe(evaluated);
|
||||
expect(evaluated).toBeGreaterThanOrEqual(matched);
|
||||
}
|
||||
});
|
||||
|
||||
it('pins the share-handle quotas by plan', () => {
|
||||
expect(EVENTS_KV_HANDLES_PER_USER.limit).toBe(512);
|
||||
expect(EVENTS_KV_HANDLES_PER_APP.limit).toBe(512);
|
||||
expect(
|
||||
limitFor(EVENTS_KV_HANDLES_PER_USER, DEFAULT_FREE_SUBSCRIPTION),
|
||||
).toBe(200);
|
||||
expect(
|
||||
limitFor(EVENTS_KV_HANDLES_PER_APP, DEFAULT_FREE_SUBSCRIPTION),
|
||||
).toBe(128);
|
||||
});
|
||||
|
||||
it('keeps `single` well under broadcast — each one costs far more', () => {
|
||||
expect(EVENTS_SINGLE_DELIVERY_LIMIT.limit).toBeLessThan(
|
||||
EVENTS_BROADCAST_DELIVERY_LIMIT.limit,
|
||||
|
||||
@@ -167,16 +167,15 @@ export const EVENTS_KV_HANDLE_LIMIT = userWindow('events:kvHandles', 60);
|
||||
* grow forever. A temporary account holds none: the grant outlives the session
|
||||
* that made it, and no account is left to take it back.
|
||||
*/
|
||||
export const EVENTS_KV_HANDLES_PER_USER = tiered(500, 200, 0);
|
||||
export const EVENTS_KV_HANDLES_PER_USER = tiered(512, 200, 0);
|
||||
|
||||
/**
|
||||
* Live share handles one app may hold out for one account.
|
||||
*
|
||||
* Below the per-account cap so that one app cannot spend an account's whole
|
||||
* budget: a handle names one namespace, so without this a second app on the
|
||||
* same account would be left with nothing.
|
||||
* Separately bounded from the account cap so one namespace cannot consume the
|
||||
* whole budget on free accounts.
|
||||
*/
|
||||
export const EVENTS_KV_HANDLES_PER_APP = tiered(100, 50, 0);
|
||||
export const EVENTS_KV_HANDLES_PER_APP = tiered(512, 128, 0);
|
||||
|
||||
// -- Handler surface -------------------------------------------------
|
||||
|
||||
@@ -256,17 +255,30 @@ export const EVENTS_WORKER_LIST_LIMIT = userWindow('events:workers:list', 120);
|
||||
// -- Dispatch fan-out ------------------------------------------------
|
||||
|
||||
/**
|
||||
* Subscriptions one event may deliver to before dispatch stops and reports a
|
||||
* gap. The amplification ceiling: without it, one write costs as many
|
||||
* Subscriptions one non-KV event may deliver to before dispatch stops and
|
||||
* reports a gap. The amplification ceiling: without it, one write costs as many
|
||||
* deliveries as an account cared to register.
|
||||
*/
|
||||
export const EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT = 50;
|
||||
|
||||
/**
|
||||
* Largest key-value value a delivery inlines, in serialized bytes. A value over
|
||||
* this is left out and the subscriber re-reads the key: a delivery fans out to
|
||||
* many rows, may cross regions and may sit in a backlog, none of which is sized
|
||||
* for the store's own ceiling.
|
||||
* Key-value subscriptions one mutation may deliver to. A namespace owner's plan
|
||||
* sets this ceiling; the listeners' plans do not affect it.
|
||||
*/
|
||||
export const EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT = tiered(512, 128, 128);
|
||||
|
||||
/**
|
||||
* Filter evaluations a key-value mutation may spend. This leaves room for
|
||||
* non-matching rows while retaining a finite bound on dispatch work.
|
||||
*/
|
||||
export const EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT = tiered(2048, 512, 512);
|
||||
|
||||
/** Omit all inline KV values when the matched audience exceeds this count. */
|
||||
export const EVENTS_KV_VALUE_OMIT_MATCHED_SUBSCRIPTIONS = 128;
|
||||
|
||||
/**
|
||||
* Largest inline KV value in serialized bytes. Larger values are omitted;
|
||||
* deliveries retain the key metadata.
|
||||
*/
|
||||
export const EVENTS_KV_VALUE_MAX_BYTES = 16 * 1024;
|
||||
|
||||
|
||||
@@ -54,12 +54,7 @@ import { EVENTS_WORKER_DEPLOYS_PER_HOUR } from './limits.js';
|
||||
|
||||
/** Why a deploy could not happen, for the callers that answer differently. */
|
||||
export type EventsDeployOutcome =
|
||||
| 'deployed'
|
||||
| 'stale'
|
||||
| 'no-handlers'
|
||||
| 'no-owner'
|
||||
| 'throttled'
|
||||
| 'failed';
|
||||
'deployed' | 'stale' | 'no-handlers' | 'no-owner' | 'throttled' | 'failed';
|
||||
|
||||
interface DeployLayers {
|
||||
config: IConfig;
|
||||
|
||||
@@ -354,3 +354,134 @@ describe('GET /fs/readdir over HTTP', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /fs/completeWrite over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
it('refuses completion of a session whose object never arrived', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const started = await fetch(new URL('/fs/startWrite', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fileMetadata: {
|
||||
path: `/${username}/Documents/http-nobytes-${Date.now()}.bin`,
|
||||
size: 4,
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(started.status).toBe(200);
|
||||
const { sessionId } = (await started.json()) as { sessionId: string };
|
||||
|
||||
// No PUT to the presigned URL.
|
||||
const response = await fetch(
|
||||
new URL('/fs/completeWrite', env.apiOrigin),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ uploadId: sessionId }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as {
|
||||
code?: string;
|
||||
message?: string;
|
||||
};
|
||||
expect(body.code).toBe('bad_request');
|
||||
expect(body.message).toBe('Upload content was not received');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /fs/startWrite storage quota over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
is_storage_limited: true,
|
||||
storage_capacity: 10 * 1024,
|
||||
} as never);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
it('rejects the excess of a parallel burst of startWrite calls', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const user = await env.server.stores.user.getByUsername(username);
|
||||
const fs = env.server.services.fs as unknown as {
|
||||
getUsersStorageAllowance: (
|
||||
userId: number,
|
||||
) => Promise<{ curr: number; max: number }>;
|
||||
};
|
||||
const { curr, max } = await fs.getUsersStorageAllowance(user!.id);
|
||||
const size = Math.floor((max - curr) / 3) + 1;
|
||||
|
||||
const responses = await Promise.all(
|
||||
Array.from({ length: 8 }, (_, i) =>
|
||||
fetch(new URL('/fs/startWrite', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fileMetadata: {
|
||||
path: `/${username}/Documents/http-quota-burst-${i}.bin`,
|
||||
size,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const ok = responses.filter((response) => response.status === 200);
|
||||
const rejected = responses.filter(
|
||||
(response) => response.status === 413,
|
||||
);
|
||||
expect(ok).toHaveLength(2);
|
||||
expect(rejected).toHaveLength(6);
|
||||
|
||||
for (const response of rejected) {
|
||||
const body = (await response.json()) as { code?: string };
|
||||
expect(body.code).toBe('storage_limit_reached');
|
||||
}
|
||||
});
|
||||
|
||||
// A raw JSON body carries `1e400` as a numeral, not a computed value, so
|
||||
// `JSON.stringify` can't produce it (it turns `Infinity` into `null`) —
|
||||
// the wire text is built by hand to exercise what the server's own
|
||||
// `JSON.parse` does with it.
|
||||
it('rejects a declared size of 1e400 (parses to Infinity) with 400', async () => {
|
||||
const { username, token } = env.users.user;
|
||||
const body = `{"fileMetadata":{"path":"/${username}/Documents/http-huge.bin","size":1e400}}`;
|
||||
|
||||
const response = await fetch(new URL('/fs/startWrite', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const responseBody = (await response.json()) as { code?: string };
|
||||
expect(responseBody.code).toBe('bad_request');
|
||||
});
|
||||
});
|
||||
@@ -342,9 +342,16 @@ describe('FSController.completeBatchWrites', () => {
|
||||
.body as ClientSignedWriteResponse[];
|
||||
expect(startResponses).toHaveLength(2);
|
||||
|
||||
// 2) Complete via the controller. Single-mode completion only
|
||||
// needs the session row → it doesn't read the S3 object back,
|
||||
// so we can skip the actual upload step in this test.
|
||||
// 2) PUT the declared bytes through each presigned URL, then
|
||||
// complete via the controller.
|
||||
await fetch(startResponses[0]!.url!, {
|
||||
method: 'PUT',
|
||||
body: '12345',
|
||||
});
|
||||
await fetch(startResponses[1]!.url!, {
|
||||
method: 'PUT',
|
||||
body: '1234567',
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
const completeBody: CompleteWriteRequest[] = startResponses.map(
|
||||
(r) => ({ uploadId: r.sessionId }),
|
||||
@@ -416,6 +423,7 @@ describe('FSController.completeBatchWrites', () => {
|
||||
);
|
||||
const [firstResponse] = firstStart.captured
|
||||
.body as ClientSignedWriteResponse[];
|
||||
await fetch(firstResponse!.url!, { method: 'PUT', body: 'x' });
|
||||
const firstComplete = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
@@ -547,6 +555,7 @@ describe('FSController.completeBatchWrites', () => {
|
||||
);
|
||||
const [firstResponse] = firstStart.captured
|
||||
.body as ClientSignedWriteResponse[];
|
||||
await fetch(firstResponse!.url!, { method: 'PUT', body: 'x' });
|
||||
await withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
makeReq<CompleteWriteRequest[]>({
|
||||
@@ -2731,6 +2740,7 @@ describe('FSController associatedAppId entitlement gate', () => {
|
||||
),
|
||||
);
|
||||
const [started] = startRes.captured.body as ClientSignedWriteResponse[];
|
||||
await fetch(started.url!, { method: 'PUT', body: 'abc' });
|
||||
const completeRes = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
|
||||
@@ -1024,6 +1024,7 @@ describe('FSController.completeWrite', () => {
|
||||
const { actor, username } = await makeUser();
|
||||
const target = `/${username}/Documents/complete-single.txt`;
|
||||
const started = await startSignedWrite(actor, target, 4);
|
||||
await fetch(started.url!, { method: 'PUT', body: 'wxyz' });
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
@@ -1073,6 +1074,7 @@ describe('FSController.completeWrite', () => {
|
||||
`/${username}/Documents/complete-thumb.txt`,
|
||||
2,
|
||||
);
|
||||
await fetch(started.url!, { method: 'PUT', body: 'yz' });
|
||||
const { res } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.completeWrite(
|
||||
@@ -2089,6 +2091,7 @@ describe('FSController upload session access re-checks', () => {
|
||||
|
||||
const target = `${folder}/kept-complete.bin`;
|
||||
const single = await startUpload(recipient.actor, target);
|
||||
await fetch(single.url!, { method: 'PUT', body: '12345678' });
|
||||
await withActor(recipient.actor, () =>
|
||||
controller.completeWrite(
|
||||
makeReq<CompleteWriteRequest>({
|
||||
@@ -2113,6 +2116,9 @@ describe('FSController upload session access re-checks', () => {
|
||||
await startUpload(recipient.actor, targets[0]!),
|
||||
await startUpload(recipient.actor, targets[1]!),
|
||||
];
|
||||
for (const s of started) {
|
||||
await fetch(s.url!, { method: 'PUT', body: '12345678' });
|
||||
}
|
||||
await withActor(recipient.actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
makeReq<CompleteWriteRequest[]>({
|
||||
|
||||
@@ -23,6 +23,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { runWithContext } from '../../core/context.js';
|
||||
import type { HttpError } from '../../core/http/HttpError.js';
|
||||
import { consumeRouteRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
@@ -2348,6 +2349,72 @@ describe('LegacyFSController.checkAppAcl', () => {
|
||||
const body = captured.body as { allowed: boolean };
|
||||
expect(typeof body.allowed).toBe('boolean');
|
||||
});
|
||||
|
||||
it("answers another user's entry the same as a missing one", async () => {
|
||||
const { actor: victim } = await makeUser();
|
||||
const victimPath = `/${victim.user!.username!}/Documents/secret.txt`;
|
||||
await withActor(victim, () =>
|
||||
controller.touch(
|
||||
makeReq({
|
||||
body: { path: victimPath, set_modified_to_now: true },
|
||||
actor: victim,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
const victimEntry =
|
||||
await server.stores.fsEntry.getEntryByPath(victimPath);
|
||||
|
||||
const { actor } = await makeUser();
|
||||
const app = await (
|
||||
server.stores.app.create as unknown as (
|
||||
fields: Record<string, unknown>,
|
||||
opts: { ownerUserId: number },
|
||||
) => Promise<{ uid: string }>
|
||||
)(
|
||||
{
|
||||
name: `cacl-${uuidv4()}`,
|
||||
title: 'ACL test app',
|
||||
index_url: 'https://example.test/cacl.html',
|
||||
},
|
||||
{ ownerUserId: actor.user!.id! },
|
||||
);
|
||||
|
||||
const errorFor = async (subject: unknown) => {
|
||||
try {
|
||||
await withActor(actor, () =>
|
||||
controller.checkAppAcl(
|
||||
makeReq({
|
||||
body: { subject, app: app.uid, mode: 'read' },
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
return null;
|
||||
} catch (e) {
|
||||
const err = e as HttpError;
|
||||
return {
|
||||
statusCode: err.statusCode,
|
||||
message: err.message,
|
||||
legacyCode: err.legacyCode,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const missing = await errorFor({
|
||||
path: `/${victim.user!.username!}/Documents/nope.txt`,
|
||||
});
|
||||
expect(missing).toEqual({
|
||||
statusCode: 404,
|
||||
message: 'Subject does not exist',
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
expect(await errorFor({ path: victimPath })).toEqual(missing);
|
||||
expect(await errorFor({ uid: victimEntry!.uuid })).toEqual(missing);
|
||||
expect(await errorFor({ id: victimEntry!.id })).toEqual(missing);
|
||||
expect(await errorFor({ uid: uuidv4() })).toEqual(missing);
|
||||
});
|
||||
});
|
||||
|
||||
// ── down (validation paths) ─────────────────────────────────────────
|
||||
|
||||
@@ -1932,7 +1932,7 @@ export class LegacyFSController extends PuterController {
|
||||
* access to a subject FS entry.
|
||||
*/
|
||||
checkAppAcl = async (req: Request, res: Response): Promise<void> => {
|
||||
this.#requireActor(req);
|
||||
const actor = this.#requireActor(req);
|
||||
const body = asRecord(req.body);
|
||||
|
||||
const subjectRef = body.subject;
|
||||
@@ -1944,10 +1944,28 @@ export class LegacyFSController extends PuterController {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
const subject = await resolveV1Selector(
|
||||
this.stores.fsEntry,
|
||||
subjectRef,
|
||||
);
|
||||
// A missing subject and one the caller can't see get the same 404,
|
||||
// so this can't be used to probe for other users' entries.
|
||||
let subject: import('../../stores/fs/FSEntry.js').FSEntry | null = null;
|
||||
try {
|
||||
subject = await resolveV1Selector(this.stores.fsEntry, subjectRef);
|
||||
} catch (err) {
|
||||
if (!(err instanceof HttpError && err.statusCode === 404))
|
||||
throw err;
|
||||
}
|
||||
let ancestors: Promise<Array<{ uid: string; path: string }>> | null =
|
||||
null;
|
||||
const descriptor = subject && {
|
||||
path: subject.path,
|
||||
resolveAncestors: () =>
|
||||
(ancestors ??= this.services.fs.getAncestorChain(subject.path)),
|
||||
};
|
||||
const acl = this.services.acl as ACLService;
|
||||
if (!descriptor || !(await acl.check(actor, descriptor, 'see')))
|
||||
throw new HttpError(404, 'Subject does not exist', {
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
|
||||
let app: { uid: string } | null = null;
|
||||
if (typeof appRef === 'string') {
|
||||
app =
|
||||
@@ -1961,19 +1979,10 @@ export class LegacyFSController extends PuterController {
|
||||
|
||||
// Build an actor-under-user shape for the check.
|
||||
const actorForApp = makeActor({
|
||||
user: req.actor!.user,
|
||||
user: actor.user,
|
||||
app: { uid: (app as { uid: string }).uid },
|
||||
});
|
||||
const descriptor = {
|
||||
path: subject.path,
|
||||
resolveAncestors: () =>
|
||||
this.services.fs.getAncestorChain(subject.path),
|
||||
};
|
||||
const allowed = await (this.services.acl as ACLService).check(
|
||||
actorForApp,
|
||||
descriptor,
|
||||
mode,
|
||||
);
|
||||
const allowed = await acl.check(actorForApp, descriptor, mode);
|
||||
res.json({ allowed });
|
||||
};
|
||||
|
||||
|
||||
@@ -40,11 +40,13 @@ import { WebDAVController } from './webdav/WebDAVController.js';
|
||||
import { WispController } from './wisp/WispController.js';
|
||||
import type { IPuterControllerRegistry } from './types.js';
|
||||
import { PeerController } from './peer/PeerController.js';
|
||||
import { ProfileController } from './profile/ProfileController.js';
|
||||
|
||||
export const puterControllers = {
|
||||
staticAssets: StaticAssetsController,
|
||||
staticPages: StaticPagesController,
|
||||
auth: AuthController,
|
||||
profile: ProfileController,
|
||||
apps: AppController,
|
||||
appFeedback: AppFeedbackController,
|
||||
desktop: DesktopController,
|
||||
|
||||
@@ -58,6 +58,10 @@ let router: PuterRouter;
|
||||
// override in and out (same pattern as AuthController.test.ts).
|
||||
type SignupValidateOverride = (data: Record<string, unknown>) => void;
|
||||
let signupValidateOverride: SignupValidateOverride | null = null;
|
||||
// Stand-in for the extension that honors signup bonus codes.
|
||||
let bonusValidateOverride: SignupValidateOverride | null = null;
|
||||
let bonusCheckOverride: SignupValidateOverride | null = null;
|
||||
const heardSignupSuccess: Array<Record<string, unknown>> = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer({
|
||||
@@ -90,6 +94,24 @@ beforeAll(async () => {
|
||||
}
|
||||
},
|
||||
);
|
||||
server.clients.event.on(
|
||||
'puter.signup-bonus.check',
|
||||
(_k: unknown, data: unknown) => {
|
||||
bonusCheckOverride?.(data as Record<string, unknown>);
|
||||
},
|
||||
);
|
||||
server.clients.event.on(
|
||||
'puter.signup-bonus.validate',
|
||||
(_k: unknown, data: unknown) => {
|
||||
bonusValidateOverride?.(data as Record<string, unknown>);
|
||||
},
|
||||
);
|
||||
server.clients.event.on(
|
||||
'puter.signup.success',
|
||||
(_k: unknown, data: unknown) => {
|
||||
heardSignupSuccess.push(data as Record<string, unknown>);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -99,6 +121,8 @@ afterAll(async () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
signupValidateOverride = null;
|
||||
bonusValidateOverride = null;
|
||||
bonusCheckOverride = null;
|
||||
});
|
||||
|
||||
interface CapturedResponse {
|
||||
@@ -1965,3 +1989,143 @@ describe('OIDCController rate limits', () => {
|
||||
expect(rateLimitOf('get', '/auth/oidc/providers').limit).toBe(1_200);
|
||||
});
|
||||
});
|
||||
|
||||
// -- Signup bonus codes ----------------------------------------------
|
||||
|
||||
describe('OIDC signup bonus codes', () => {
|
||||
const rand = () => Math.random().toString(36).slice(2, 8);
|
||||
|
||||
const startState = async (query: Record<string, unknown>) => {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/:provider/start',
|
||||
makeReq({ params: { provider: 'custom' }, query }),
|
||||
res,
|
||||
);
|
||||
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
|
||||
'state',
|
||||
);
|
||||
return oidc().verifyState(state!) as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const stubIdp = (sub: string, email: string) => {
|
||||
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
|
||||
access_token: 'access',
|
||||
id_token: 'id',
|
||||
} as never);
|
||||
vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
} as never);
|
||||
};
|
||||
|
||||
const openCheck = (data: Record<string, unknown>) => {
|
||||
data.valid = true;
|
||||
data.display = { title: 'T', description: 'D' };
|
||||
};
|
||||
|
||||
const signupCallback = async (bonusCode: string) => {
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
bonus_code: bonusCode,
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/signup',
|
||||
makeReq({ query: { code: 'c', state } }),
|
||||
res,
|
||||
);
|
||||
return captured;
|
||||
};
|
||||
|
||||
it('carries a canonical code in the signed state', async () => {
|
||||
const decoded = await startState({
|
||||
flow: 'signup',
|
||||
bonusCode: 'Startup3M-ABCD1234',
|
||||
});
|
||||
expect(decoded.bonus_code).toBe('startup3mabcd1234');
|
||||
});
|
||||
|
||||
it('drops a malformed code at start', async () => {
|
||||
const decoded = await startState({ flow: 'signup', bonusCode: 'a:b' });
|
||||
expect(decoded).not.toHaveProperty('bonus_code');
|
||||
});
|
||||
|
||||
it('creates the account with the requirements an accepted code raises', async () => {
|
||||
const email = `bonus-${rand()}@test.local`;
|
||||
stubIdp(`sub-${rand()}`, email);
|
||||
bonusCheckOverride = openCheck;
|
||||
let seen: Record<string, unknown> | null = null;
|
||||
bonusValidateOverride = (data) => {
|
||||
seen = { ...data };
|
||||
data.accepted = true;
|
||||
data.requires_phone_verification = true;
|
||||
};
|
||||
|
||||
const captured = await signupCallback('startup3mabcd1234');
|
||||
|
||||
expect(captured.cookies).toHaveLength(1);
|
||||
expect(seen).toMatchObject({
|
||||
code: 'startup3mabcd1234',
|
||||
source: 'oidc',
|
||||
email,
|
||||
});
|
||||
const user = await server.stores.user.findEmailOwner(email, {
|
||||
force: true,
|
||||
});
|
||||
expect(Boolean(user!.requires_phone_verification)).toBe(true);
|
||||
const deadline = Date.now() + 2000;
|
||||
let success: Record<string, unknown> | undefined;
|
||||
while (!success && Date.now() < deadline) {
|
||||
success = heardSignupSuccess.find((e) => e.email === email);
|
||||
if (!success) await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
expect(success?.bonus_code).toBe('startup3mabcd1234');
|
||||
});
|
||||
|
||||
it('refuses a dead code before the validate hook runs', async () => {
|
||||
const email = `bonus-${rand()}@test.local`;
|
||||
stubIdp(`sub-${rand()}`, email);
|
||||
let validated = false;
|
||||
signupValidateOverride = (data) => {
|
||||
if (data.email === email) validated = true;
|
||||
};
|
||||
|
||||
const captured = await signupCallback('startup3mabcd1234');
|
||||
|
||||
expect(captured.redirectUrl).toContain('message=bonus_code_invalid');
|
||||
expect(validated).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses the account when nothing accepts the code, without echoing it', async () => {
|
||||
const email = `bonus-${rand()}@test.local`;
|
||||
stubIdp(`sub-${rand()}`, email);
|
||||
|
||||
const captured = await signupCallback('startup3mabcd1234');
|
||||
|
||||
expect(captured.redirectUrl).toContain('message=bonus_code_invalid');
|
||||
expect(captured.redirectUrl).not.toContain('bonusCode=');
|
||||
expect(captured.cookies).toHaveLength(0);
|
||||
expect(
|
||||
await server.stores.user.findEmailOwner(email, { force: true }),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('keeps the code on the retry redirect when something else failed', async () => {
|
||||
const email = `bonus-${rand()}@test.local`;
|
||||
stubIdp(`sub-${rand()}`, email);
|
||||
bonusCheckOverride = openCheck;
|
||||
signupValidateOverride = (data) => {
|
||||
if (data.email === email) data.allow = false;
|
||||
};
|
||||
|
||||
const captured = await signupCallback('startup3mabcd1234');
|
||||
|
||||
expect(captured.redirectUrl).toContain('message=signup_blocked');
|
||||
expect(captured.redirectUrl).toContain('bonusCode=startup3mabcd1234');
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
import { normalizeBonusCode } from '../../util/signupBonus.js';
|
||||
import { parseMaskedSharePath } from '../../services/fs/sharePathMask.js';
|
||||
import {
|
||||
SHARE_DEEP_LINK_ITEMS_LIMIT,
|
||||
@@ -50,6 +51,7 @@ const ALLOWED_ERRORS = [
|
||||
'account_suspended',
|
||||
'unauthorized',
|
||||
'signup_blocked',
|
||||
'bonus_code_invalid',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -69,6 +71,23 @@ function resolutionErrorCode(code: string | undefined): string {
|
||||
: 'signup_blocked';
|
||||
}
|
||||
|
||||
/** What a new account created by an OIDC callback inherits from its flow. */
|
||||
interface OIDCSignupOptions {
|
||||
referrer?: string | null;
|
||||
bonusCode?: string | null;
|
||||
}
|
||||
|
||||
function signupOptionsFromState(
|
||||
stateDecoded: Record<string, unknown>,
|
||||
): OIDCSignupOptions {
|
||||
return {
|
||||
referrer: (stateDecoded.referrer as string) ?? null,
|
||||
// Normalized when the state was signed; re-checked since this is the
|
||||
// value signup acts on.
|
||||
bonusCode: normalizeBonusCode(stateDecoded.bonus_code),
|
||||
};
|
||||
}
|
||||
|
||||
// GUI pages an OIDC flow may return to: the root (where a share email lands),
|
||||
// /desktop, /dashboard, and direct app landings (/app/<name> and its
|
||||
// desktop-booted twin /desktop/app/<name>, mirroring APP_NAME_REGEX in
|
||||
@@ -222,6 +241,13 @@ function buildErrorRedirectUrl(
|
||||
if (requestCode) {
|
||||
params.set('request_code', requestCode);
|
||||
}
|
||||
// A retry from the error page keeps the bonus code, unless it's what failed.
|
||||
if (
|
||||
typeof stateDecoded?.bonus_code === 'string' &&
|
||||
clamped !== 'bonus_code_invalid'
|
||||
) {
|
||||
params.set('bonusCode', stateDecoded.bonus_code);
|
||||
}
|
||||
for (const path of sharedPaths) {
|
||||
params.append(SHARE_DEEP_LINK_PARAM, path);
|
||||
}
|
||||
@@ -423,6 +449,15 @@ export class OIDCController extends PuterController {
|
||||
redirect_uri: appRedirectUri,
|
||||
};
|
||||
if (referrer) statePayload.referrer = referrer ?? openerOrigin;
|
||||
// Login can create an account too, so both flows carry it. A
|
||||
// malformed code is dropped here and never reaches signup.
|
||||
const rawBonusCode = Array.isArray(req.query.bonusCode)
|
||||
? req.query.bonusCode[0]
|
||||
: req.query.bonusCode;
|
||||
const bonusCode = normalizeBonusCode(rawBonusCode);
|
||||
if (bonusCode && (flow === 'login' || flow === 'signup')) {
|
||||
statePayload.bonus_code = bonusCode;
|
||||
}
|
||||
if (embeddedInPopup && msgId) {
|
||||
statePayload.embedded_in_popup = true;
|
||||
statePayload.msg_id = msgId;
|
||||
@@ -507,7 +542,7 @@ export class OIDCController extends PuterController {
|
||||
const resolved = await this.#resolveOrCreateOIDCUser(
|
||||
provider,
|
||||
userinfo,
|
||||
(stateDecoded.referrer as string) ?? null,
|
||||
signupOptionsFromState(stateDecoded),
|
||||
);
|
||||
if ('error' in resolved) {
|
||||
console.warn(
|
||||
@@ -574,7 +609,7 @@ export class OIDCController extends PuterController {
|
||||
const resolved = await this.#resolveOrCreateOIDCUser(
|
||||
provider,
|
||||
userinfo,
|
||||
(stateDecoded.referrer as string) ?? null,
|
||||
signupOptionsFromState(stateDecoded),
|
||||
);
|
||||
if ('error' in resolved) {
|
||||
console.warn(
|
||||
@@ -742,7 +777,7 @@ if (window.opener) {
|
||||
async #resolveOrCreateOIDCUser(
|
||||
provider: string,
|
||||
userinfo: { sub: string; email?: unknown; [k: string]: unknown },
|
||||
referrer?: string | null,
|
||||
signup: OIDCSignupOptions = {},
|
||||
attempt = 0,
|
||||
): Promise<
|
||||
| { error: string; code?: string; requestCode?: string }
|
||||
@@ -792,7 +827,8 @@ if (window.opener) {
|
||||
const outcome = await this.services.oidc.createUserFromOIDC(
|
||||
provider,
|
||||
userinfo as { sub: string; email?: string },
|
||||
referrer,
|
||||
signup.referrer ?? null,
|
||||
{ bonusCode: signup.bonusCode ?? null },
|
||||
);
|
||||
// A concurrent callback (a second tab, a provider retry) created the
|
||||
// account between step 2 and the insert. Nothing went wrong for the
|
||||
@@ -802,7 +838,7 @@ if (window.opener) {
|
||||
return this.#resolveOrCreateOIDCUser(
|
||||
provider,
|
||||
userinfo,
|
||||
referrer,
|
||||
signup,
|
||||
attempt + 1,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
const PICTURE =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aD1sAAAAASUVORK5CYII=';
|
||||
|
||||
describe('profile routes over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
let userUuid = '';
|
||||
/** The plan `env.users.user` is on; see the resolver below. */
|
||||
let plan: string | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
userUuid = (await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
))!.uuid;
|
||||
env.server.services.metering.registerPolicy({
|
||||
id: 'business',
|
||||
monthUsageAllowance: 45 * 1_000_000 * 100,
|
||||
monthlyStorageAllowance: 1024 ** 3,
|
||||
});
|
||||
env.server.services.metering.registerSubscriptionResolver((actor) =>
|
||||
actor.user?.uuid === userUuid ? plan : null,
|
||||
);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const withSubscription = async <T>(
|
||||
id: string | null,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
plan = id;
|
||||
env.server.services.metering.invalidateActorSubscription(userUuid);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
plan = null;
|
||||
env.server.services.metering.invalidateActorSubscription(userUuid);
|
||||
}
|
||||
};
|
||||
|
||||
const get = (query: string, token?: string) =>
|
||||
fetch(new URL(`/profile${query}`, env.apiOrigin), {
|
||||
headers: token ? { authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
|
||||
const post = (body: unknown, token: string) =>
|
||||
fetch(new URL('/profile', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
it('lets a user write and read their own profile on any plan', async () => {
|
||||
const write = await post(
|
||||
{ picture: PICTURE, displayName: 'Test' },
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(write.status).toBe(200);
|
||||
expect(await write.json()).toEqual({
|
||||
picture: PICTURE,
|
||||
displayName: 'Test',
|
||||
bio: null,
|
||||
});
|
||||
|
||||
const own = await get('', env.users.user.token);
|
||||
expect(own.status).toBe(200);
|
||||
expect(await own.json()).toMatchObject({ picture: PICTURE });
|
||||
|
||||
const byName = await get(
|
||||
`?username=${env.users.user.username}`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(byName.status).toBe(200);
|
||||
expect(await byName.json()).toMatchObject({ displayName: 'Test' });
|
||||
});
|
||||
|
||||
it('hides a free user from anonymous and third-party readers as a 404', async () => {
|
||||
const anonymous = await get(`?username=${env.users.user.username}`);
|
||||
expect(anonymous.status).toBe(404);
|
||||
const other = await get(
|
||||
`?username=${env.users.user.username}`,
|
||||
env.users.other.token,
|
||||
);
|
||||
expect(other.status).toBe(404);
|
||||
// Same shape as an account that does not exist.
|
||||
const missing = await get('?username=nobody-here');
|
||||
expect(missing.status).toBe(404);
|
||||
});
|
||||
|
||||
it('serves a paid user to anyone, and hides them again when the plan ends', async () => {
|
||||
await withSubscription('business', async () => {
|
||||
const anonymous = await get(`?username=${env.users.user.username}`);
|
||||
expect(anonymous.status).toBe(200);
|
||||
expect(await anonymous.json()).toMatchObject({ picture: PICTURE });
|
||||
const other = await get(
|
||||
`?username=${env.users.user.username}`,
|
||||
env.users.other.token,
|
||||
);
|
||||
expect(other.status).toBe(200);
|
||||
});
|
||||
expect((await get(`?username=${env.users.user.username}`)).status).toBe(
|
||||
404,
|
||||
);
|
||||
});
|
||||
|
||||
it('requires a username or a signed-in caller, and validates the username shape', async () => {
|
||||
expect((await get('')).status).toBe(401);
|
||||
expect((await get('?username=not%20valid')).status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a bad patch with a stable code and refuses app and anonymous writers', async () => {
|
||||
const bad = await post({ name: 'x' }, env.users.user.token);
|
||||
expect(bad.status).toBe(400);
|
||||
expect(await bad.json()).toMatchObject({
|
||||
code: 'profile_field_not_allowed',
|
||||
});
|
||||
|
||||
const anonymous = await fetch(new URL('/profile', env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ bio: 'x' }),
|
||||
});
|
||||
expect(anonymous.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { Controller, Get, Post } from '../../core/http/decorators.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { ProfileService } from '../../services/profile/ProfileService.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
// Shape only; the store decides whether the account exists.
|
||||
const USERNAME_PATTERN = /^[a-z0-9_-]{1,64}$/i;
|
||||
|
||||
const notFound = () =>
|
||||
new HttpError(404, 'Profile not found', { legacyCode: 'not_found' });
|
||||
|
||||
/**
|
||||
* User profiles. Reads are open so an app can show another user's profile, but
|
||||
* a profile other than the caller's own is served only while its owner is on a
|
||||
* paid plan (`ProfileService.isPubliclyVisible`); a hidden one 404s like a
|
||||
* missing account, so the response says nothing about the plan.
|
||||
*/
|
||||
@Controller('/profile')
|
||||
export class ProfileController extends PuterController {
|
||||
/**
|
||||
* GET /profile?username=<name> — a user's profile. Without `username`, the
|
||||
* signed-in user's own.
|
||||
*/
|
||||
@Get('/', {
|
||||
subdomain: 'api',
|
||||
rateLimit: {
|
||||
scope: 'profile-read',
|
||||
limit: 120,
|
||||
window: 60_000,
|
||||
key: 'ip',
|
||||
},
|
||||
})
|
||||
async get(req: Request, res: Response): Promise<void> {
|
||||
const service = this.services.profile as ProfileService;
|
||||
const caller = req.actor?.user;
|
||||
const username = req.query.username;
|
||||
|
||||
let target;
|
||||
if (username === undefined) {
|
||||
if (!caller?.uuid) {
|
||||
throw new HttpError(401, 'Sign in, or pass a username', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
}
|
||||
target = await this.stores.user.getByUuid(caller.uuid);
|
||||
} else {
|
||||
if (
|
||||
typeof username !== 'string' ||
|
||||
!USERNAME_PATTERN.test(username)
|
||||
) {
|
||||
throw new HttpError(400, 'Invalid username', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
target = await this.stores.user.getByUsername(username);
|
||||
}
|
||||
if (!target) throw notFound();
|
||||
|
||||
const isOwner = caller?.uuid === target.uuid;
|
||||
if (!isOwner && !(await service.isPubliclyVisible(target))) {
|
||||
throw notFound();
|
||||
}
|
||||
res.json(await service.getProfile(target));
|
||||
}
|
||||
|
||||
/** POST /profile — update the signed-in user's own profile. */
|
||||
@Post('/', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
rateLimit: {
|
||||
scope: 'profile-write',
|
||||
limit: 30,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
},
|
||||
})
|
||||
async update(req: Request, res: Response): Promise<void> {
|
||||
const service = this.services.profile as ProfileService;
|
||||
const user = await this.stores.user.getByUuid(req.actor!.user.uuid!);
|
||||
if (!user) throw notFound();
|
||||
res.json(await service.updateProfile(user, req.body));
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
import { ShareController } from './ShareController.js';
|
||||
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
|
||||
|
||||
/**
|
||||
* Route-level coverage for the sharing endpoints. The service unit tests drive
|
||||
@@ -1069,4 +1070,156 @@ describe('share endpoints over HTTP', () => {
|
||||
expect(route.options?.allowFullAccessToken).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
describe('an invited address over the wire', () => {
|
||||
/** An app of the owner's, holding `mode` on one file, with a token. */
|
||||
const appHolding = async (
|
||||
owner: { username: string },
|
||||
uid: string,
|
||||
permission: (fileUid: string) => string,
|
||||
) => {
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
const actor = makeActor({ user: user! });
|
||||
const app = await env.server.stores.app.create(
|
||||
{
|
||||
name: `peek-app-${crypto.randomUUID().slice(0, 8)}`,
|
||||
title: 'Peek app',
|
||||
index_url: `https://peek-${crypto.randomUUID()}.test/`,
|
||||
},
|
||||
{ ownerUserId: user!.id },
|
||||
);
|
||||
await runWithContext({ actor }, () =>
|
||||
env.server.services.permission.grantUserAppPermission(
|
||||
actor,
|
||||
app.uid,
|
||||
permission(uid),
|
||||
),
|
||||
);
|
||||
const token = await env.server.services.auth.getUserAppToken(
|
||||
actor,
|
||||
app.uid,
|
||||
);
|
||||
return { ...app, token };
|
||||
};
|
||||
|
||||
const invitedFile = async (owner: { username: string; token: string }) => {
|
||||
const file = await makeFile(owner);
|
||||
const email = `wire-${crypto.randomUUID().slice(0, 8)}@test.local`;
|
||||
const res = await post('/share', owner.token, {
|
||||
recipients: [email],
|
||||
items: [{ uid: file.uid }],
|
||||
mode: 'read',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return { file, email };
|
||||
};
|
||||
|
||||
it('reaches the owner but not an app the file was handed to', async () => {
|
||||
const owner = env.users.user;
|
||||
const { file, email } = await invitedFile(owner);
|
||||
|
||||
// The owner asked, so the owner is told.
|
||||
const mine = await get('/share/shares', owner.token, {
|
||||
uid: file.uid,
|
||||
});
|
||||
expect(mine.status).toBe(200);
|
||||
expect(await mine.text()).toContain(email);
|
||||
|
||||
// The app is answered, but bounded to the rows it issued — and it
|
||||
// issued none, so the owner's invite is not among them.
|
||||
const app = await appHolding(owner, file.uid, (u) => `fs:${u}:read`);
|
||||
const peeked = await get('/share/shares', app.token, {
|
||||
uid: file.uid,
|
||||
});
|
||||
expect(peeked.status).toBe(200);
|
||||
const peekedBody = await peeked.text();
|
||||
expect(peekedBody).not.toContain(email);
|
||||
expect(JSON.parse(peekedBody).items).toEqual([]);
|
||||
|
||||
// `return_shares` is the same listing on an access-token surface.
|
||||
const stat = await post('/fs/stat', app.token, {
|
||||
uid: file.uid,
|
||||
return_shares: true,
|
||||
});
|
||||
expect(stat.status).toBe(200);
|
||||
const body = await stat.text();
|
||||
expect(body).not.toContain(email);
|
||||
expect(JSON.parse(body).shares).toEqual([]);
|
||||
});
|
||||
|
||||
// The ticket's second route in: `ShareController` admits no access
|
||||
// token, but `/fs/stat` does, and `return_shares` is the same listing.
|
||||
it('withholds the listing from a list-scoped access token', async () => {
|
||||
const owner = env.users.user;
|
||||
const { file, email } = await invitedFile(owner);
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
const actor = makeActor({ user: user! });
|
||||
const scoped = await runWithContext({ actor }, () =>
|
||||
env.server.services.auth.createAccessToken(actor, [
|
||||
[`fs:${file.uid}:list`],
|
||||
]),
|
||||
);
|
||||
|
||||
const stat = await post('/fs/stat', scoped, {
|
||||
uid: file.uid,
|
||||
return_shares: true,
|
||||
});
|
||||
expect(stat.status).toBe(200);
|
||||
const body = await stat.text();
|
||||
expect(body).not.toContain(email);
|
||||
expect(JSON.parse(body).shares).toEqual([]);
|
||||
});
|
||||
|
||||
// Withholding here would buy nothing: `shared-by-me` hands the same
|
||||
// addresses to the same credential.
|
||||
it('tells a full-access token what it could read from shared-by-me anyway', async () => {
|
||||
const owner = env.users.user;
|
||||
const { file, email } = await invitedFile(owner);
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
const actor = makeActor({ user: user! });
|
||||
const pat = await runWithContext({ actor }, () =>
|
||||
env.server.services.auth.createAccessToken(actor, [
|
||||
[FULL_API_ACCESS],
|
||||
]),
|
||||
);
|
||||
|
||||
const outbound = await get('/share/shared-by-me', pat, {});
|
||||
expect(outbound.status).toBe(200);
|
||||
expect(await outbound.text()).toContain(email);
|
||||
|
||||
const listed = await get('/share/shares', pat, { uid: file.uid });
|
||||
expect(listed.status).toBe(200);
|
||||
expect(await listed.text()).toContain(email);
|
||||
});
|
||||
|
||||
it('answers an app that was handed the item to manage', async () => {
|
||||
const owner = env.users.user;
|
||||
const { file, email } = await invitedFile(owner);
|
||||
const app = await appHolding(
|
||||
owner,
|
||||
file.uid,
|
||||
(u) => `manage:fs:${u}`,
|
||||
);
|
||||
|
||||
const res = await get('/share/shares', app.token, {
|
||||
uid: file.uid,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const text = await res.text();
|
||||
// `manage` buys an app authority over the item, not sight of who
|
||||
// else the owner invited to it.
|
||||
expect(
|
||||
JSON.parse(text).items.some(
|
||||
(i: { pending?: boolean }) => i.pending,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(text).not.toContain(email);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -54,8 +54,14 @@ export async function toClientShare(
|
||||
...(share.type === undefined ? {} : { type: share.type }),
|
||||
...(thumbnail === undefined ? {} : { thumbnail }),
|
||||
...(share.owner === undefined ? {} : { owner: share.owner.username }),
|
||||
// Withheld from a listing whose caller it isn't for, hence the omission.
|
||||
...(share.pending
|
||||
? { pending: true, recipient_email: share.recipientEmail }
|
||||
? {
|
||||
pending: true,
|
||||
...(share.recipientEmail === undefined
|
||||
? {}
|
||||
: { recipient_email: share.recipientEmail }),
|
||||
}
|
||||
: {}),
|
||||
// A link share: no holder of any kind, this is what says so.
|
||||
...(share.anyone ? { anyone: true } : {}),
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { makeActor } from '../../core/actor.js';
|
||||
import type { IConfig } from '../../types';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
@@ -53,6 +54,47 @@ describe('team endpoints over HTTP', () => {
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
/** An app token acting for `owner`, minted the way the dashboard does. */
|
||||
const makeAppToken = async (owner: { username: string }) => {
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
const actor = makeActor({ user: user! });
|
||||
const app = await env.server.stores.app.create(
|
||||
{
|
||||
name: `team-http-app-${crypto.randomUUID()}`,
|
||||
title: 'Team app',
|
||||
index_url: `https://team-${crypto.randomUUID()}.test/`,
|
||||
},
|
||||
{ ownerUserId: actor.user.id! },
|
||||
);
|
||||
return env.server.services.auth.getUserAppToken(actor, app.uid);
|
||||
};
|
||||
|
||||
/** A scoped (non-full-access) access token for `owner`, carrying no app. */
|
||||
const makeScopedToken = async (owner: {
|
||||
username: string;
|
||||
token: string;
|
||||
}) => {
|
||||
const user = await env.server.stores.user.getByUsername(
|
||||
owner.username,
|
||||
);
|
||||
const anchor = `/${owner.username}/${crypto.randomUUID()}`;
|
||||
await env.server.services.fs.mkdir(user!.id, {
|
||||
path: anchor,
|
||||
createMissingParents: true,
|
||||
});
|
||||
const entry = await env.server.stores.fsEntry.getEntryByPath(anchor);
|
||||
const { actor } = await env.server.services.auth.authenticate(
|
||||
owner.token,
|
||||
);
|
||||
return env.server.services.auth.createAccessToken(
|
||||
actor!,
|
||||
[[`fs:${entry!.uid}:list`]],
|
||||
{ label: 'teams-scope' },
|
||||
);
|
||||
};
|
||||
|
||||
/** A team owned by `user`, with one provisioned member. */
|
||||
const makeTeam = async () => {
|
||||
const res = await call('POST', '/teams', env.users.user.token, {
|
||||
@@ -199,6 +241,15 @@ describe('team endpoints over HTTP', () => {
|
||||
expect(after.status).toBe(200);
|
||||
const body = (await after.json()) as { items: { username: string }[] };
|
||||
expect(body.items.some((m) => m.username === env.users.user.username)).toBe(true);
|
||||
|
||||
// A scoped token was minted for one thing; a roster is not it.
|
||||
const scoped = await makeScopedToken(env.users.user);
|
||||
const narrow = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/directory`,
|
||||
typeof scoped === 'string' ? scoped : scoped.token,
|
||||
);
|
||||
expect(narrow.status).toBe(403);
|
||||
});
|
||||
|
||||
it('refuses the directory to someone outside the team', async () => {
|
||||
@@ -215,6 +266,188 @@ describe('team endpoints over HTTP', () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
// -- app actors, gated by the same opt-in as the directory --------
|
||||
//
|
||||
// Built through the service, not the wire: these routes share a
|
||||
// rate-limit budget with the mutation tests above, and every HTTP
|
||||
// create/update here would eat into that same window.
|
||||
|
||||
/** A team owned by `user`, created and toggled through the service. */
|
||||
const makeServiceTeam = async () => {
|
||||
const owner = (await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
))!;
|
||||
const team = await env.server.services.team.createTeam(owner.id, {
|
||||
name: 'Acme',
|
||||
handle: randomHandle(),
|
||||
});
|
||||
return { team, ownerId: owner.id };
|
||||
};
|
||||
|
||||
it('omits a team from an app token until the directory opts in', async () => {
|
||||
const { team, ownerId } = await makeServiceTeam();
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
|
||||
const before = await call('GET', '/teams', appToken);
|
||||
expect(before.status).toBe(200);
|
||||
const beforeBody = (await before.json()) as { items: { uid: string }[] };
|
||||
expect(beforeBody.items.some((t) => t.uid === team.uid)).toBe(false);
|
||||
|
||||
await env.server.services.team.updateTeam(team.uid, ownerId, {
|
||||
directoryEnabled: true,
|
||||
});
|
||||
|
||||
const after = await call('GET', '/teams', appToken);
|
||||
expect(after.status).toBe(200);
|
||||
const afterBody = (await after.json()) as { items: { uid: string }[] };
|
||||
expect(afterBody.items.some((t) => t.uid === team.uid)).toBe(true);
|
||||
});
|
||||
|
||||
it('still lists a team to its owner session with the directory off', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const res = await call('GET', '/teams', env.users.user.token);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { items: { uid: string }[] };
|
||||
expect(body.items.some((t) => t.uid === team.uid)).toBe(true);
|
||||
});
|
||||
|
||||
it('lists a team to a full-access API token with the directory off', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const res = await call('GET', '/teams', env.users.user.apiToken);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { items: { uid: string }[] };
|
||||
expect(body.items.some((t) => t.uid === team.uid)).toBe(true);
|
||||
});
|
||||
|
||||
it('404s /members to an app token until the directory opts in, then gives usernames only', async () => {
|
||||
const { team, ownerId } = await makeServiceTeam();
|
||||
const username = `svc_${Math.random().toString(36).slice(2, 9)}`;
|
||||
await env.server.services.team.provisionAccount(team.uid, ownerId, {
|
||||
username,
|
||||
});
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
|
||||
const before = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
appToken,
|
||||
);
|
||||
expect(before.status).toBe(404);
|
||||
expect(await before.json()).toMatchObject({ code: 'team_not_found' });
|
||||
|
||||
await env.server.services.team.updateTeam(team.uid, ownerId, {
|
||||
directoryEnabled: true,
|
||||
});
|
||||
|
||||
const after = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
appToken,
|
||||
);
|
||||
expect(after.status).toBe(200);
|
||||
const body = (await after.json()) as {
|
||||
items: Record<string, unknown>[];
|
||||
};
|
||||
expect(body.items.length).toBeGreaterThan(0);
|
||||
// An app gets username only — no org_owned, created_at, or uuid.
|
||||
for (const m of body.items) expect(Object.keys(m)).toEqual(['username']);
|
||||
});
|
||||
|
||||
it('still refuses an app token on GET /teams/:uid', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
|
||||
const res = await call('GET', `/teams/${team.uid}`, appToken);
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({ code: 'forbidden' });
|
||||
});
|
||||
|
||||
it('excludes a never-activated or suspended seat from an app`s view of /members', async () => {
|
||||
const { team, ownerId } = await makeServiceTeam();
|
||||
const neverActivated = `svc_na_${Math.random().toString(36).slice(2, 9)}`;
|
||||
await env.server.services.team.provisionAccount(team.uid, ownerId, {
|
||||
username: neverActivated,
|
||||
});
|
||||
const suspendedUsername = `svc_su_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const suspended = await env.server.services.team.provisionAccount(
|
||||
team.uid,
|
||||
ownerId,
|
||||
{ username: suspendedUsername },
|
||||
);
|
||||
await env.server.services.team.disableMember(
|
||||
team.uid,
|
||||
ownerId,
|
||||
suspended.userId,
|
||||
);
|
||||
await env.server.services.team.updateTeam(team.uid, ownerId, {
|
||||
directoryEnabled: true,
|
||||
});
|
||||
|
||||
const appToken = await makeAppToken(env.users.user);
|
||||
const appView = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
appToken,
|
||||
);
|
||||
expect(appView.status).toBe(200);
|
||||
const appItems = (
|
||||
(await appView.json()) as { items: Record<string, unknown>[] }
|
||||
).items;
|
||||
for (const m of appItems) expect(Object.keys(m)).toEqual(['username']);
|
||||
const appUsernames = appItems.map((m) => m.username);
|
||||
expect(appUsernames).not.toContain(neverActivated);
|
||||
expect(appUsernames).not.toContain(suspendedUsername);
|
||||
// The owner's own seat is active, so it still shows.
|
||||
expect(appUsernames).toContain(env.users.user.username);
|
||||
|
||||
// The owner's own session sees every seat regardless of status.
|
||||
const ownerView = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(ownerView.status).toBe(200);
|
||||
const ownerUsernames = (
|
||||
(await ownerView.json()) as { items: { username: string }[] }
|
||||
).items.map((m) => m.username);
|
||||
expect(ownerUsernames).toContain(neverActivated);
|
||||
expect(ownerUsernames).toContain(suspendedUsername);
|
||||
});
|
||||
|
||||
it('gives a full-access API token the owner`s uuid on /members', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const res = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
env.users.user.apiToken,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
items: { username: string; uuid?: string }[];
|
||||
};
|
||||
const owner = body.items.find(
|
||||
(m) => m.username === env.users.user.username,
|
||||
);
|
||||
expect(owner?.uuid).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('refuses a scoped access token on GET /teams and /members', async () => {
|
||||
const { team } = await makeServiceTeam();
|
||||
const scoped = await makeScopedToken(env.users.user);
|
||||
|
||||
const teamsRes = await call('GET', '/teams', scoped);
|
||||
expect(teamsRes.status).toBe(403);
|
||||
expect(await teamsRes.json()).toMatchObject({ code: 'forbidden' });
|
||||
|
||||
const membersRes = await call(
|
||||
'GET',
|
||||
`/teams/${team.uid}/members`,
|
||||
scoped,
|
||||
);
|
||||
expect(membersRes.status).toBe(403);
|
||||
expect(await membersRes.json()).toMatchObject({ code: 'forbidden' });
|
||||
});
|
||||
|
||||
it('lists members with org_owned distinguishing the owner', async () => {
|
||||
const { team, memberUsername } = await makeTeam();
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
} from '../../core/http/decorators.js';
|
||||
import { isAccountContext } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { TeamRow } from '../../stores/team/TeamStore.js';
|
||||
import { PuterController } from '../types.js';
|
||||
@@ -70,7 +71,9 @@ const toClientTeam = (team: TeamRow, isOwner: boolean) => ({
|
||||
|
||||
@Controller('/teams')
|
||||
export class TeamController extends PuterController {
|
||||
// `requireUserActor` installs the auth gates; reads need it too.
|
||||
// Most routes gate with `requireUserActor`; `listTeams` and `listMembers`
|
||||
// use `requireAuth` so an app can read a team whose owner opted into the
|
||||
// directory (see `directory_enabled`).
|
||||
|
||||
/** Off means `/teams` 404s and no team route is registered at all. */
|
||||
isEnabled(): boolean {
|
||||
@@ -100,7 +103,7 @@ export class TeamController extends PuterController {
|
||||
|
||||
@Get('', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireAuth: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_READ_LIMIT,
|
||||
})
|
||||
@@ -108,8 +111,13 @@ export class TeamController extends PuterController {
|
||||
const userId = this.#requireUserId(req);
|
||||
await this.#requireTeamsAvailable(req, userId);
|
||||
const teams = await this.stores.team.listTeamsForUser(userId);
|
||||
// An app only sees a team whose owner opened the directory to apps;
|
||||
// a team that hasn't is indistinguishable from no team at all.
|
||||
const visible = isAccountContext(req.actor)
|
||||
? teams
|
||||
: teams.filter((t) => this.services.team.isDirectoryOpen(t));
|
||||
res.json({
|
||||
items: teams.map((t) =>
|
||||
items: visible.map((t) =>
|
||||
toClientTeam(t, t.owner_user_id === userId),
|
||||
),
|
||||
});
|
||||
@@ -178,7 +186,7 @@ export class TeamController extends PuterController {
|
||||
|
||||
@Get('/:uid/members', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
requireAuth: true,
|
||||
requireVerified: true,
|
||||
rateLimit: TEAM_READ_LIMIT,
|
||||
})
|
||||
@@ -188,9 +196,13 @@ export class TeamController extends PuterController {
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
);
|
||||
// Only the owner: the uuid is what billing keys a seat's plan on, and
|
||||
// one member has no business identifying another.
|
||||
const isOwner = team.owner_user_id === userId;
|
||||
const accountContext = isAccountContext(req.actor);
|
||||
// Same consent as the directory: an app only reads a team that opted in.
|
||||
if (!accountContext) this.services.team.assertDirectoryOpen(team);
|
||||
|
||||
// uuid only when the team owner calls with their own session or API
|
||||
// token; an app needing a stable id uses `listDirectory()` instead.
|
||||
const showUuid = accountContext && team.owner_user_id === userId;
|
||||
|
||||
const page = await this.stores.team.listMembers(
|
||||
this.#param(req, 'uid'),
|
||||
@@ -200,23 +212,30 @@ export class TeamController extends PuterController {
|
||||
typeof req.query.cursor === 'string'
|
||||
? req.query.cursor
|
||||
: undefined,
|
||||
// An app's view matches the directory's: active seats only.
|
||||
activeOnly: !accountContext,
|
||||
},
|
||||
);
|
||||
// An app only ever gets a username; org_owned/created_at are account-only.
|
||||
res.json({
|
||||
items: page.items.map((m) => ({
|
||||
username: m.username,
|
||||
org_owned: Number(m.org_owned) === 1,
|
||||
created_at: m.created_at,
|
||||
...(isOwner ? { uuid: m.uuid } : {}),
|
||||
})),
|
||||
items: page.items.map((m) =>
|
||||
accountContext
|
||||
? {
|
||||
username: m.username,
|
||||
org_owned: Number(m.org_owned) === 1,
|
||||
created_at: m.created_at,
|
||||
...(showUuid ? { uuid: m.uuid } : {}),
|
||||
}
|
||||
: { username: m.username },
|
||||
),
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The only team route that admits an app actor. It discloses nothing a
|
||||
* colleague cannot already read through `/members`, and the team has to
|
||||
* have opted in, so an app cannot enumerate a team by default.
|
||||
* Admits an app actor, like `listTeams` and `listMembers`, once the team
|
||||
* has opted in. Discloses nothing a colleague cannot already read through
|
||||
* `/members`.
|
||||
*/
|
||||
@Get('/:uid/directory', {
|
||||
subdomain: 'api',
|
||||
@@ -226,6 +245,15 @@ export class TeamController extends PuterController {
|
||||
async listDirectory(req: Request, res: Response): Promise<void> {
|
||||
// The membership is the person's, never the app's.
|
||||
const userId = this.#requireUserId(req);
|
||||
// A scoped token holds only what it was minted for; a roster is not that.
|
||||
const token = req.actor?.accessToken;
|
||||
if (token && token.fullAccess !== true) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'This endpoint is not available to scoped access tokens',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
const page = await this.services.team.listDirectory(
|
||||
this.#param(req, 'uid'),
|
||||
userId,
|
||||
|
||||
@@ -73,7 +73,8 @@ export const PuterController =
|
||||
public onServerShutdown() {
|
||||
return;
|
||||
}
|
||||
public getReportedCosts(): // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
public getReportedCosts():
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
| Promise<Record<string, any>[]>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
| Record<string, any>[] {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { Actor } from '../../actor';
|
||||
import { PERIOD_ESCAPE } from '../../../services/metering/consts.js';
|
||||
import type { MeteringService } from '../../../services/metering/MeteringService.js';
|
||||
import type { UsageByType } from '../../../services/metering/types';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../../testUtil.js';
|
||||
|
||||
@@ -31,12 +32,24 @@ import { setupPuterTestEnv, type PuterTestEnv } from '../../../testUtil.js';
|
||||
*/
|
||||
describe('egress metering over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
let originalCacheMs: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
// A sharded month caches getActorCurrentMonthUsageDetails's detail
|
||||
// read for USAGE_DETAIL_CACHE_MS — these tests read before and after
|
||||
// one request a moment apart, well inside that window, so the cache
|
||||
// would mask the very update they're checking for.
|
||||
const ctor = env.server.services.metering
|
||||
.constructor as typeof MeteringService;
|
||||
originalCacheMs = ctor.USAGE_DETAIL_CACHE_MS;
|
||||
ctor.USAGE_DETAIL_CACHE_MS = 0;
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
(
|
||||
env.server.services.metering.constructor as typeof MeteringService
|
||||
).USAGE_DETAIL_CACHE_MS = originalCacheMs;
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
|
||||
@@ -679,6 +679,27 @@ describe('resolveOwnedAppForHostedSite', () => {
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the private app over an older public bootstrap stub on an alternate host', async () => {
|
||||
const owner = await makeUser();
|
||||
await server.stores.app.createFromOrigin(
|
||||
`app-${uuidv4()}`,
|
||||
'http://beans.host.puter.localhost',
|
||||
{ ownerUserId: owner.id },
|
||||
);
|
||||
const app = await createPrivateApp(
|
||||
owner.id,
|
||||
'http://beans.app.puter.localhost/',
|
||||
);
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf('beans.app.puter.localhost'),
|
||||
site: { user_id: owner.id },
|
||||
db: server.clients.db,
|
||||
config: baseConfig(),
|
||||
});
|
||||
expect(out?.uid).toBe(app.uid);
|
||||
expect(Boolean(out?.is_private)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns null when the site has no owner', async () => {
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf('beans.site.puter.localhost'),
|
||||
|
||||
@@ -249,8 +249,10 @@ export async function resolveOwnedAppForHostedSite(opts: {
|
||||
? `AND \`is_private\` = ${opts.db.booleanLiteral(true)} `
|
||||
: '';
|
||||
const placeholders = uniqueCandidates.map(() => '?').join(', ');
|
||||
// Ambiguity fails closed: a private row wins; `id` keeps it deterministic.
|
||||
const orderClause = `ORDER BY CASE WHEN \`is_private\` = ${opts.db.booleanLiteral(true)} THEN 0 ELSE 1 END, \`id\``;
|
||||
const rows = await opts.db.read(
|
||||
`SELECT * FROM apps WHERE owner_user_id = ? ${privateFilter}AND index_url IN (${placeholders}) LIMIT 2`,
|
||||
`SELECT * FROM apps WHERE owner_user_id = ? ${privateFilter}AND index_url IN (${placeholders}) ${orderClause} LIMIT 2`,
|
||||
[opts.site.user_id, ...uniqueCandidates],
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
@@ -876,6 +876,63 @@ describe('createPuterSiteMiddleware — file serving', () => {
|
||||
// FSService so we exercise parsing, path resolution, and the loop-
|
||||
// prevention guard together.
|
||||
|
||||
describe('createPuterSiteMiddleware — site.access.check', () => {
|
||||
const serveUnder = async (listener?: (data: unknown) => void) => {
|
||||
const owner = await makeUserWithHome();
|
||||
const homePath = `/${owner.username}`;
|
||||
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
|
||||
const sub = `gate-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await server.stores.subdomain.create({
|
||||
userId: owner.id,
|
||||
subdomain: sub,
|
||||
rootDirId: homeEntry!.id,
|
||||
});
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/secret.txt`,
|
||||
Buffer.from('shh'),
|
||||
'text/plain',
|
||||
);
|
||||
const seen: unknown[] = [];
|
||||
server.clients.event.on('site.access.check', (_key, data) => {
|
||||
if (data.subdomain !== sub) return;
|
||||
seen.push(data);
|
||||
listener?.(data);
|
||||
});
|
||||
const { out } = await runMiddleware(
|
||||
buildMiddleware(),
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: '/secret.txt',
|
||||
}),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
return { out, seen, sub };
|
||||
};
|
||||
|
||||
it('asks listeners before streaming and serves when nobody objects', async () => {
|
||||
const { out, seen, sub } = await serveUnder();
|
||||
expect(out.statusCode).toBe(200);
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0]).toMatchObject({
|
||||
subdomain: sub,
|
||||
host: `${sub}.site.puter.localhost`,
|
||||
requestPath: '/secret.txt',
|
||||
entry: { name: 'secret.txt' },
|
||||
result: { allowed: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the plain file 404 when a listener withholds the entry', async () => {
|
||||
const { out } = await serveUnder((data) => {
|
||||
(data as { result: { allowed: boolean } }).result.allowed = false;
|
||||
});
|
||||
expect(out.statusCode).toBe(404);
|
||||
expect(String(out.body)).toContain('Not Found');
|
||||
expect(out.headers['Content-Type']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPuterSiteMiddleware — .puter_site_config', () => {
|
||||
const setupSiteWithConfig = async (config: unknown) => {
|
||||
const owner = await makeUserWithHome();
|
||||
|
||||
@@ -631,6 +631,33 @@ export const createPuterSiteMiddleware = (
|
||||
return;
|
||||
}
|
||||
|
||||
// A listener owning the site may withhold this entry (a system site
|
||||
// that publishes per-user files conditionally). Denied looks exactly
|
||||
// like missing, and a listener failure denies rather than serves.
|
||||
const accessCheck = {
|
||||
subdomain,
|
||||
host,
|
||||
requestPath: req.path,
|
||||
entry,
|
||||
result: { allowed: true },
|
||||
};
|
||||
try {
|
||||
await layers.clients.event.emitAndWait(
|
||||
'site.access.check',
|
||||
accessCheck,
|
||||
{},
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('[puter-site] site.access.check threw', e);
|
||||
accessCheck.result.allowed = false;
|
||||
}
|
||||
if (!accessCheck.result.allowed) {
|
||||
res.status(404)
|
||||
.type('text/html; charset=UTF-8')
|
||||
.send('<h1>404</h1><p>Not Found</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
// Stream the file. `fsEntry.readContent` honours Range + emits
|
||||
// ETag/Last-Modified when the S3 layer returns them. Range
|
||||
// requests are suppressed when serving a custom error page so
|
||||
|
||||
@@ -27,12 +27,7 @@ import type { Actor } from '../actor';
|
||||
* it instead of accepting any token that authenticates.
|
||||
*/
|
||||
export type TokenSource =
|
||||
| 'body'
|
||||
| 'header'
|
||||
| 'x-api-key'
|
||||
| 'cookie'
|
||||
| 'query'
|
||||
| 'handshake';
|
||||
'body' | 'header' | 'x-api-key' | 'cookie' | 'query' | 'handshake';
|
||||
|
||||
/** Express router methods plus the WebDAV verbs some endpoints use. */
|
||||
export type RouteMethod =
|
||||
@@ -340,9 +335,7 @@ export type AuthRequired<O extends RouteOptions> = O extends {
|
||||
? true
|
||||
: O extends {
|
||||
requireSubscription:
|
||||
| true
|
||||
| readonly string[]
|
||||
| string[];
|
||||
true | readonly string[] | string[];
|
||||
}
|
||||
? true
|
||||
: O extends { requireReputation: string }
|
||||
|
||||
@@ -257,6 +257,126 @@ describe('ChatCompletionDriver provider registration', () => {
|
||||
// -- Failure classification ------------------------------------------
|
||||
|
||||
describe('ChatCompletionDriver exhausted-chain classification', () => {
|
||||
const attemptsFrom = (err: HttpError) =>
|
||||
(
|
||||
err as unknown as {
|
||||
fields: {
|
||||
attempts: Array<{
|
||||
error: string;
|
||||
status?: number;
|
||||
code?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
).fields.attempts;
|
||||
|
||||
it('maps an upstream 402 to alerted credit exhaustion and redacts its URL', async () => {
|
||||
const warn = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
const err = await errorFor(
|
||||
Object.assign(
|
||||
new Error(
|
||||
'Insufficient credits. Add more using https://openrouter.ai/settings/credits',
|
||||
),
|
||||
{ status: 402 },
|
||||
),
|
||||
);
|
||||
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 503,
|
||||
legacyCode: 'upstream_credits_exhausted',
|
||||
message: 'AI provider out of credits',
|
||||
});
|
||||
expect(err.noAlarm).toBeFalsy();
|
||||
expect(attemptsFrom(err)[0]!.error).toBe(
|
||||
'Insufficient credits. Add more using',
|
||||
);
|
||||
expect(JSON.stringify(attemptsFrom(err))).not.toContain('http');
|
||||
const failureLog = warn.mock.calls.find((call) =>
|
||||
String(call[0]).startsWith('[ai-chat] all routes failed'),
|
||||
);
|
||||
expect(failureLog).toBeDefined();
|
||||
expect(String(failureLog![0])).not.toContain('http');
|
||||
});
|
||||
|
||||
it('classifies a 403 used-up credit message before authentication failures', async () => {
|
||||
const err = await errorFor(
|
||||
Object.assign(
|
||||
new Error(
|
||||
'Your current credits have been used up and we are unable to process further requests. Please visit https://openrouter.ai/settings/credits to add credits.',
|
||||
),
|
||||
{ status: 403 },
|
||||
),
|
||||
);
|
||||
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 503,
|
||||
legacyCode: 'upstream_credits_exhausted',
|
||||
});
|
||||
expect(err.legacyCode).not.toBe('upstream_auth_failed');
|
||||
});
|
||||
|
||||
it('alerts on a free model whose required team balance is missing', async () => {
|
||||
const warn = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
const err = await errorFor(
|
||||
Object.assign(
|
||||
new Error(
|
||||
'Free model requires Team balance greater than $4.999999. (request id: 20260921210512505339070jYB)',
|
||||
),
|
||||
{ status: 429 },
|
||||
),
|
||||
);
|
||||
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 503,
|
||||
legacyCode: 'upstream_credits_exhausted',
|
||||
});
|
||||
expect(err.noAlarm).toBeFalsy();
|
||||
expect(attemptsFrom(err)[0]!.error).toBe(
|
||||
'Free model requires Team balance greater than $4.999999.',
|
||||
);
|
||||
expect(JSON.stringify(attemptsFrom(err))).not.toMatch(/request id/i);
|
||||
expect(
|
||||
warn.mock.calls.some((call) =>
|
||||
String(call[0]).startsWith('[ai-chat] all routes failed'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies the OpenAI insufficient_quota code before rate limits', async () => {
|
||||
const err = await errorFor({
|
||||
status: 429,
|
||||
message: 'quota exhausted',
|
||||
error: { code: 'insufficient_quota' },
|
||||
});
|
||||
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 503,
|
||||
legacyCode: 'upstream_credits_exhausted',
|
||||
});
|
||||
expect(err.legacyCode).not.toBe('upstream_rate_limited');
|
||||
});
|
||||
|
||||
it('strips HTML, URLs and request ids from serialized attempt details', async () => {
|
||||
const err = await errorFor(
|
||||
Object.assign(
|
||||
new Error(
|
||||
'<html><body>Insufficient credits. Visit https://vendor.test/top-up (request id: req-secret)</body></html>',
|
||||
),
|
||||
{ status: 402 },
|
||||
),
|
||||
);
|
||||
const serialized = JSON.stringify(attemptsFrom(err));
|
||||
|
||||
expect(err.legacyCode).toBe('upstream_credits_exhausted');
|
||||
expect(serialized).not.toContain('<');
|
||||
expect(serialized).not.toContain('http');
|
||||
expect(serialized).not.toMatch(/request id/i);
|
||||
});
|
||||
|
||||
it('maps an upstream 429 to 429 upstream_rate_limited', async () => {
|
||||
const err = await errorFor(
|
||||
Object.assign(new Error('slow down'), { status: 429 }),
|
||||
@@ -307,6 +427,16 @@ describe('ChatCompletionDriver exhausted-chain classification', () => {
|
||||
expect(err).toMatchObject({ legacyCode: 'upstream_rate_limited' });
|
||||
});
|
||||
|
||||
it('keeps a plain 429 in the rate-limit bucket', async () => {
|
||||
const err = await errorFor(
|
||||
Object.assign(new Error('Rate limit exceeded'), { status: 429 }),
|
||||
);
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 429,
|
||||
legacyCode: 'upstream_rate_limited',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps an upstream 401 to a 500 upstream_auth_failed — our misconfiguration, not the callerdispute', async () => {
|
||||
const err = await errorFor(
|
||||
Object.assign(new Error('invalid api key'), { statusCode: 401 }),
|
||||
@@ -488,7 +618,9 @@ describe('ChatCompletionDriver streaming failure handling', () => {
|
||||
vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValue({
|
||||
stream: true,
|
||||
init_chat_stream: async () => {
|
||||
throw new Error('populator exploded');
|
||||
throw new Error(
|
||||
'populator exploded; see https://vendor.test/request/secret',
|
||||
);
|
||||
},
|
||||
finally_fn: cleanup,
|
||||
} as never);
|
||||
@@ -503,7 +635,7 @@ describe('ChatCompletionDriver streaming failure handling', () => {
|
||||
|
||||
const events = await collect(result.stream);
|
||||
expect(events).toEqual([
|
||||
{ type: 'error', message: 'populator exploded' },
|
||||
{ type: 'error', message: 'populator exploded; see' },
|
||||
]);
|
||||
// The provider's cleanup hook still runs on the failure path.
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -202,8 +202,11 @@ afterAll(async () => {
|
||||
* reject is what makes the whole chain observable: the driver records every
|
||||
* attempt on the thrown error, and `attempts[0]` is who it chose first.
|
||||
*/
|
||||
const attemptsFor = async (model: string) => {
|
||||
createMock.mockRejectedValue(new Error('upstream down'));
|
||||
const attemptsFor = async (
|
||||
model: string,
|
||||
thrown: unknown = new Error('upstream down'),
|
||||
) => {
|
||||
createMock.mockRejectedValue(thrown);
|
||||
let caught: HttpError | undefined;
|
||||
try {
|
||||
await withTestActor(() =>
|
||||
@@ -405,6 +408,74 @@ describe('ChatCompletionDriver unhealthy-route skipping', () => {
|
||||
expect(next[0].provider).not.toBe('deepseek');
|
||||
});
|
||||
|
||||
it('marks a credit-exhausted 402 route unhealthy for the next caller', async () => {
|
||||
const first = await attemptsFor(
|
||||
'deepseek-v4-pro',
|
||||
Object.assign(
|
||||
new Error(
|
||||
'Insufficient credits. Add more using https://openrouter.ai/settings/credits',
|
||||
),
|
||||
{ status: 402 },
|
||||
),
|
||||
);
|
||||
const next = await attemptsFor('deepseek-v4-pro');
|
||||
|
||||
expect(next[0]).not.toMatchObject({
|
||||
provider: first[0]!.provider,
|
||||
model: first[0]!.model,
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies differing credit-exhaustion statuses as one exhausted chain', async () => {
|
||||
createMock
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(
|
||||
new Error(
|
||||
'Insufficient credits. Add more using https://openrouter.ai/settings/credits',
|
||||
),
|
||||
{ status: 402 },
|
||||
),
|
||||
)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(
|
||||
new Error(
|
||||
'Your current credits have been used up and we are unable to process further requests. Please visit https://openrouter.ai/settings/credits to add credits.',
|
||||
),
|
||||
{ status: 403 },
|
||||
),
|
||||
)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(
|
||||
new Error(
|
||||
'Free model requires Team balance greater than $4.999999. (request id: 20260921210512505339070jYB)',
|
||||
),
|
||||
{ status: 429 },
|
||||
),
|
||||
);
|
||||
|
||||
const err = await withTestActor(() =>
|
||||
driver
|
||||
.complete({
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
})
|
||||
.catch((e: unknown) => e as HttpError),
|
||||
);
|
||||
|
||||
expect(err).toMatchObject({
|
||||
statusCode: 503,
|
||||
legacyCode: 'upstream_credits_exhausted',
|
||||
});
|
||||
const attempts = (
|
||||
err as unknown as {
|
||||
fields: { attempts: Array<{ status?: number }> };
|
||||
}
|
||||
).fields.attempts;
|
||||
expect(attempts.map((attempt) => attempt.status)).toEqual([
|
||||
402, 403, 429,
|
||||
]);
|
||||
});
|
||||
|
||||
it('still serves a marked route when it is the only one left', async () => {
|
||||
// gemini-2.5-flash-image-preview has a single route; marking it must
|
||||
// degrade to trying it anyway rather than failing with no attempt.
|
||||
|
||||
@@ -191,16 +191,33 @@ describe('ChatCompletionDriver.complete auth and model resolution', () => {
|
||||
).rejects.toMatchObject({ statusCode: 401 });
|
||||
});
|
||||
|
||||
it('throws 400 when the requested model is unknown', async () => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
driver.complete({
|
||||
model: 'totally-not-a-model',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
it.each(['totally-not-a-model', '__proto__', 'constructor'])(
|
||||
'throws 400 when the requested model is unknown: %s',
|
||||
async (model) => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
driver.complete({
|
||||
model,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['__proto__', 'constructor'])(
|
||||
'rejects inherited object keys as unknown providers: %s',
|
||||
async (provider) => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
driver.complete({
|
||||
provider,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
} as ICompleteArguments),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
},
|
||||
);
|
||||
|
||||
it('falls back to the provider default model when neither model nor provider is given (azure-openai is the hard-coded default provider)', async () => {
|
||||
// Without `azure-openai` in providers config, the driver tries
|
||||
@@ -446,6 +463,53 @@ describe('ChatCompletionDriver.complete events and cost emission', () => {
|
||||
expect(data.service_used).toBe('fake-chat');
|
||||
});
|
||||
|
||||
it('charges cached prompt tokens only at their cache rate', async () => {
|
||||
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
|
||||
{
|
||||
id: 'cached-pricing',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
promptTokensIncludeCached: true,
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 200,
|
||||
cached_tokens: 10,
|
||||
},
|
||||
max_tokens: 8192,
|
||||
},
|
||||
]);
|
||||
const driver = await makeDriver();
|
||||
const costEvents = captureEvent('ai.prompt.cost-calculated');
|
||||
vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce({
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
},
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 1,
|
||||
cached_tokens: 40,
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
} as never);
|
||||
|
||||
await withTestActor(() =>
|
||||
driver.complete({
|
||||
model: 'cached-pricing',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const data = costEvents[0]![1] as {
|
||||
input_ucents: number;
|
||||
output_ucents: number;
|
||||
};
|
||||
expect(data.input_ucents).toBe(60 * 100 + 40 * 10);
|
||||
expect(data.output_ucents).toBe(200);
|
||||
});
|
||||
|
||||
it('injects `usd_cents` on the usage object derived from the cost map', async () => {
|
||||
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
|
||||
{
|
||||
@@ -478,6 +542,43 @@ describe('ChatCompletionDriver.complete events and cost emission', () => {
|
||||
expect(res.usage.usd_cents).toBe(expectedMicroCents / 1_000_000);
|
||||
});
|
||||
|
||||
it('prices `usd_cents` at long-context rates once input passes the threshold', async () => {
|
||||
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
|
||||
{
|
||||
id: 'priced',
|
||||
aliases: [],
|
||||
costs_currency: 'usd-cents',
|
||||
costs: { input_tokens: 1000, output_tokens: 2000 },
|
||||
long_context_pricing: {
|
||||
threshold: 5,
|
||||
input_multiplier: 2,
|
||||
output_multiplier: 1.5,
|
||||
},
|
||||
max_tokens: 8192,
|
||||
},
|
||||
]);
|
||||
const d = await makeDriver();
|
||||
|
||||
vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce({
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
},
|
||||
usage: { input_tokens: 10, output_tokens: 7 },
|
||||
finish_reason: 'stop',
|
||||
} as never);
|
||||
|
||||
const res = (await withTestActor(() =>
|
||||
d.complete({
|
||||
model: 'priced',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
)) as { usage: Record<string, number> };
|
||||
|
||||
const expectedMicroCents = 10 * 1000 * 2 + 7 * 2000 * 1.5;
|
||||
expect(res.usage.usd_cents).toBe(expectedMicroCents / 1_000_000);
|
||||
});
|
||||
|
||||
it('does not override `usd_cents` when the provider already returned one (e.g. OpenRouter)', async () => {
|
||||
vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce({
|
||||
message: {
|
||||
@@ -647,6 +748,54 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
|
||||
expect(passed.max_tokens!).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('caps `max_tokens` at the long-context output rate for a prompt past the threshold', async () => {
|
||||
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
|
||||
{
|
||||
id: 'capme',
|
||||
aliases: [],
|
||||
costs_currency: 'usd-cents',
|
||||
costs: { input_tokens: 1000, output_tokens: 2000 },
|
||||
long_context_pricing: {
|
||||
threshold: 10,
|
||||
input_multiplier: 2,
|
||||
output_multiplier: 1.5,
|
||||
},
|
||||
max_tokens: 8192,
|
||||
},
|
||||
]);
|
||||
const d = await makeDriver();
|
||||
|
||||
// A ~100-token prompt is past the threshold. 1_000_000 microcents at
|
||||
// 2000 * 1.5 per output token leaves at most 333 output tokens before
|
||||
// the prompt is paid for; the standard rate would allow ~450 after it.
|
||||
vi.spyOn(server.services.metering, 'getRemainingUsage').mockResolvedValue(
|
||||
1_000_000,
|
||||
);
|
||||
|
||||
const completeSpy = vi
|
||||
.spyOn(FakeChatProvider.prototype, 'complete')
|
||||
.mockResolvedValueOnce({
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
},
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
finish_reason: 'stop',
|
||||
} as never);
|
||||
|
||||
await withTestActor(() =>
|
||||
d.complete({
|
||||
model: 'capme',
|
||||
messages: [{ role: 'user', content: 'x'.repeat(400) }],
|
||||
max_tokens: 10_000,
|
||||
}),
|
||||
);
|
||||
|
||||
const passed = completeSpy.mock.calls[0]![0] as ICompleteArguments;
|
||||
expect(passed.max_tokens!).toBeLessThanOrEqual(333);
|
||||
expect(passed.max_tokens!).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('throws 402 instead of leaving `max_tokens` unset when credits cannot afford one output token', async () => {
|
||||
// Regression: previously a sub-1 cap set max_tokens to `undefined`,
|
||||
// which let the provider run to the model's full output limit and
|
||||
|
||||
@@ -30,13 +30,18 @@ import type { MeteringService } from '../../services/metering/MeteringService.js
|
||||
import type { DriverStreamResult } from '../meta.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
|
||||
import { isUpstreamTimeoutError } from '../util/upstreamErrors.js';
|
||||
import {
|
||||
isCreditExhaustion as isUpstreamCreditExhaustion,
|
||||
isUpstreamTimeoutError,
|
||||
sanitizeUpstreamMessage,
|
||||
} from '../util/upstreamErrors.js';
|
||||
import { AlibabaProvider } from './providers/alibaba/AlibabaProvider.js';
|
||||
import { AzureChatProvider } from './providers/azure/AzureChatProvider.js';
|
||||
import { AzureResponsesProvider } from './providers/azure/AzureResponsesProvider.js';
|
||||
import { BytePlusProvider } from './providers/byteplus/BytePlusProvider.js';
|
||||
import { ClaudeProvider } from './providers/claude/ClaudeProvider.js';
|
||||
import { DeepSeekProvider } from './providers/deepseek/DeepSeekProvider.js';
|
||||
import { deepSeekPricing } from './providers/deepseek/pricing.js';
|
||||
import { FakeChatProvider } from './providers/FakeChatProvider.js';
|
||||
import { GeminiChatProvider } from './providers/gemini/GeminiChatProvider.js';
|
||||
import { GroqAIProvider } from './providers/groq/GroqAIProvider.js';
|
||||
@@ -82,7 +87,13 @@ import {
|
||||
normalizeResultToOpenAI,
|
||||
shouldPresentAsOpenAI,
|
||||
} from './utils/normalizeToOpenAI.js';
|
||||
import { costKeys, isFreeModel } from './utils/pricing.js';
|
||||
import {
|
||||
costKeys,
|
||||
isFreeModel,
|
||||
isOutputCostKey,
|
||||
longContextMultipliers,
|
||||
trackedInputTokens,
|
||||
} from './utils/pricing.js';
|
||||
import {
|
||||
isRouteUnhealthy,
|
||||
markRouteUnhealthy,
|
||||
@@ -153,11 +164,14 @@ const toAttempt = (
|
||||
provider: providerId,
|
||||
status,
|
||||
code: e?.error?.code ?? e?.code,
|
||||
error: message,
|
||||
error: sanitizeUpstreamMessage(message),
|
||||
...(isUpstreamTimeoutError(err) ? { timedOut: true } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const isCreditExhaustion = (a: ProviderAttempt) =>
|
||||
isUpstreamCreditExhaustion(a.status, a.code, a.error);
|
||||
|
||||
const isRateLimit = (a: ProviderAttempt) =>
|
||||
a.status === 429 ||
|
||||
/rate[\s_-]?limit|too many requests|quota/i.test(a.error);
|
||||
@@ -184,6 +198,7 @@ const isUpstream5xx = (a: ProviderAttempt) =>
|
||||
*/
|
||||
const isRouteLevelFailure = (a: ProviderAttempt) =>
|
||||
a.status === undefined ||
|
||||
isCreditExhaustion(a) ||
|
||||
isRateLimit(a) ||
|
||||
isAuthFailure(a) ||
|
||||
isUpstream5xx(a);
|
||||
@@ -197,6 +212,7 @@ const routeId = (provider: string, modelId: string) => `${provider}:${modelId}`;
|
||||
*
|
||||
* Per-class rules (see also alarm gate in server.ts):
|
||||
*
|
||||
* - All credit-exhausted → 503 `upstream_credits_exhausted` (alerted)
|
||||
* - All rate-limited → 429 `upstream_rate_limited` (alerted, unless every attempt
|
||||
* was on a free model — see `allModelsFree`)
|
||||
* - All auth failures → 500 `upstream_auth_failed` (paged: our config)
|
||||
@@ -217,6 +233,12 @@ const classifyAttempts = (
|
||||
});
|
||||
}
|
||||
|
||||
if (attempts.every(isCreditExhaustion)) {
|
||||
return new HttpError(503, 'AI provider out of credits', {
|
||||
legacyCode: 'upstream_credits_exhausted',
|
||||
fields,
|
||||
});
|
||||
}
|
||||
if (attempts.every(isRateLimit)) {
|
||||
return new HttpError(429, 'AI provider rate limit exceeded', {
|
||||
legacyCode: 'upstream_rate_limited',
|
||||
@@ -301,8 +323,8 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
readonly rateLimit = AI_RATE_LIMIT;
|
||||
readonly concurrent = AI_CONCURRENT;
|
||||
|
||||
#providers: Record<string, IChatProvider> = {};
|
||||
#modelIdMap: Record<string, IChatModel[]> = {};
|
||||
#providers: Record<string, IChatProvider> = Object.create(null);
|
||||
#modelIdMap: Record<string, IChatModel[]> = Object.create(null);
|
||||
|
||||
/** Metering scoped to this driver. Lazy: services wire up after drivers. */
|
||||
get #aiMetering(): MeteringService {
|
||||
@@ -659,7 +681,9 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
passthrough.write(
|
||||
`${JSON.stringify({
|
||||
type: 'error',
|
||||
message: (e as Error).message,
|
||||
message: sanitizeUpstreamMessage(
|
||||
e instanceof Error ? e.message : String(e),
|
||||
),
|
||||
})}\n`,
|
||||
);
|
||||
passthrough.end();
|
||||
@@ -796,11 +820,14 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
? outputRateRaw
|
||||
: undefined;
|
||||
|
||||
const isOutputKey = (key: string) =>
|
||||
key === outputKey ||
|
||||
key === 'output_tokens' ||
|
||||
key === 'completion_tokens' ||
|
||||
key === 'thinking_tokens';
|
||||
const isOutputKey = (key: string) => isOutputCostKey(key, outputKey);
|
||||
const multipliers = longContextMultipliers(
|
||||
model,
|
||||
trackedInputTokens(usage, model),
|
||||
);
|
||||
const cachedTokens = Number.isFinite(usage.cached_tokens)
|
||||
? Math.max(0, usage.cached_tokens)
|
||||
: 0;
|
||||
|
||||
let inputMicroCents = 0;
|
||||
let outputMicroCents = 0;
|
||||
@@ -814,6 +841,11 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
if (key === 'usd_cents') continue;
|
||||
if (key === 'tokens') continue;
|
||||
|
||||
const amount =
|
||||
model.promptTokensIncludeCached && key === inputKey
|
||||
? Math.max(0, rawAmount - cachedTokens)
|
||||
: rawAmount;
|
||||
|
||||
// thinking_tokens → output rate fallback
|
||||
let rate = costs[key];
|
||||
if (typeof rate !== 'number' || !Number.isFinite(rate)) {
|
||||
@@ -834,12 +866,11 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
}
|
||||
}
|
||||
|
||||
const sub = rawAmount * rate;
|
||||
sawAnyRate = true;
|
||||
if (isOutputKey(key)) {
|
||||
outputMicroCents += sub;
|
||||
outputMicroCents += amount * rate * multipliers.output;
|
||||
} else {
|
||||
inputMicroCents += sub;
|
||||
inputMicroCents += amount * rate * multipliers.input;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -906,9 +937,14 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
const metering = this.services.metering;
|
||||
const { promptTokenEstimate, requestedMaxTokens } = estimates;
|
||||
const { inputKey, outputKey } = costKeys(model);
|
||||
// A prompt estimated past a long-context threshold pays the raised
|
||||
// rates on input and output alike.
|
||||
const multipliers = longContextMultipliers(model, promptTokenEstimate);
|
||||
// `|| 0` also catches NaN from a malformed cost table.
|
||||
const inputTokenCost = Number(model.costs?.[inputKey] ?? 0) || 0;
|
||||
const outputTokenCost = Number(model.costs?.[outputKey] ?? 0) || 0;
|
||||
const inputTokenCost =
|
||||
(Number(model.costs?.[inputKey] ?? 0) || 0) * multipliers.input;
|
||||
const outputTokenCost =
|
||||
(Number(model.costs?.[outputKey] ?? 0) || 0) * multipliers.output;
|
||||
const approximateInputCost = promptTokenEstimate * inputTokenCost;
|
||||
const minimumCredits = Number(model.minimumCredits || 1);
|
||||
|
||||
@@ -1249,6 +1285,15 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
|
||||
const deepseekKey = readKey(providers['deepseek']);
|
||||
if (deepseekKey) {
|
||||
deepSeekPricing.onRefreshError = () => {
|
||||
this.clients.alarm.create(
|
||||
'deepseek_pricing_refresh_failed',
|
||||
'DeepSeek pricing refresh failed; using last verified rates',
|
||||
{},
|
||||
'warning',
|
||||
{ dedup: true },
|
||||
);
|
||||
};
|
||||
this.#providers['deepseek'] = new DeepSeekProvider(
|
||||
{ apiKey: deepseekKey },
|
||||
metering,
|
||||
|
||||
@@ -46,11 +46,16 @@ import {
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
|
||||
import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import {
|
||||
expectedIdentifierFields,
|
||||
makeActorMatrix,
|
||||
sentIdentifierFields,
|
||||
withTestActor,
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { AzureChatProvider } from './AzureChatProvider.js';
|
||||
import { AZURE_MODELS } from './models.js';
|
||||
@@ -389,31 +394,82 @@ describe('AzureChatProvider.complete request shape', () => {
|
||||
const provider = makeProvider();
|
||||
createMock.mockResolvedValueOnce(okCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-4o',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
await withTestActor(
|
||||
() =>
|
||||
provider.complete({
|
||||
model: 'gpt-4o',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
makeActor({ user: { id: 42, uuid: 'u42', username: 'alice' } }),
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.user).toBe('puter-u42');
|
||||
expect('safety_identifier' in args).toBe(true);
|
||||
expect(args.safety_identifier).toBe(args.user);
|
||||
});
|
||||
|
||||
it('strips safety_identifier for Grok deployments, which 400 on unknown args', async () => {
|
||||
it('sends the actor uuid and effective app uid as user/safety_identifier', async () => {
|
||||
const provider = makeProvider();
|
||||
createMock.mockResolvedValue(okCompletion);
|
||||
|
||||
for (const actor of makeActorMatrix()) {
|
||||
await withTestActor(
|
||||
() =>
|
||||
provider.complete({
|
||||
model: 'gpt-4o',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}),
|
||||
actor,
|
||||
);
|
||||
}
|
||||
|
||||
const fields = ['user', 'safety_identifier', 'prompt_cache_key'];
|
||||
expect(sentIdentifierFields(createMock.mock.calls, fields)).toEqual(
|
||||
expectedIdentifierFields(fields),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards a caller-supplied prompt_cache_key instead of the derived identifier', async () => {
|
||||
const provider = makeProvider();
|
||||
createMock.mockResolvedValueOnce(okCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'grok-4-20-non-reasoning',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
await withTestActor(
|
||||
() =>
|
||||
provider.complete({
|
||||
model: 'gpt-4o',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
prompt_cache_key: 'caller-key',
|
||||
}),
|
||||
makeActor({ user: { id: 42, uuid: 'u42', username: 'alice' } }),
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.prompt_cache_key).toBe('caller-key');
|
||||
expect(args.safety_identifier).toBe('puter-u42');
|
||||
});
|
||||
|
||||
it('strips safety_identifier/prompt_cache_key for Grok deployments, which 400 on unknown args', async () => {
|
||||
const provider = makeProvider();
|
||||
createMock.mockResolvedValueOnce(okCompletion);
|
||||
|
||||
// Runs under a real user actor so `user` would be present; only the
|
||||
// Grok branch may drop `safety_identifier`/`prompt_cache_key`.
|
||||
await withTestActor(
|
||||
() =>
|
||||
provider.complete({
|
||||
model: 'grok-4-20-non-reasoning',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
makeActor({
|
||||
user: { id: 42, uuid: 'u42', username: 'alice' },
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.user).toBe('puter-u42');
|
||||
expect('safety_identifier' in args).toBe(false);
|
||||
expect('prompt_cache_key' in args).toBe(false);
|
||||
expect(args.model).toBe('grok-4-20-non-reasoning');
|
||||
});
|
||||
|
||||
|
||||
@@ -125,6 +125,7 @@ export class AzureChatProvider implements IChatProvider {
|
||||
reasoning_effort,
|
||||
temperature,
|
||||
text,
|
||||
prompt_cache_key,
|
||||
} = params;
|
||||
let { messages, model } = params;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -175,6 +176,8 @@ export class AzureChatProvider implements IChatProvider {
|
||||
// })
|
||||
|
||||
const userIdentifier = upstreamUserIdentifier(actor);
|
||||
// Cache key defaults to the actor identifier; see upstreamUserIdentifier.
|
||||
const cacheKey = prompt_cache_key ?? userIdentifier;
|
||||
|
||||
// Resolve any `puter_path` content parts into inline base64 data URLs.
|
||||
// Chat Completions doesn't support file uploads, so this is the only
|
||||
@@ -203,13 +206,20 @@ export class AzureChatProvider implements IChatProvider {
|
||||
const supportsReasoningControls =
|
||||
typeof model === 'string' && model.startsWith('gpt-5');
|
||||
|
||||
// `safety_identifier` is an OpenAI-specific param. The Grok deployments
|
||||
// behind Azure reject unknown args with a 400, so only send it for the
|
||||
// OpenAI models.
|
||||
// `safety_identifier`/`prompt_cache_key` are OpenAI-specific params.
|
||||
// The Grok deployments behind Azure reject unknown args with a 400,
|
||||
// so only send them for the OpenAI models.
|
||||
|
||||
const completionParams: ChatCompletionCreateParams = {
|
||||
user: userIdentifier,
|
||||
...(isGrok ? {} : { safety_identifier: userIdentifier }),
|
||||
...(isGrok
|
||||
? {}
|
||||
: {
|
||||
safety_identifier: userIdentifier,
|
||||
...(cacheKey !== undefined
|
||||
? { prompt_cache_key: cacheKey }
|
||||
: {}),
|
||||
}),
|
||||
messages: messages,
|
||||
model: modelUsed.id,
|
||||
...(tools ? { tools } : {}),
|
||||
|
||||
@@ -40,11 +40,16 @@ import {
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
|
||||
import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import {
|
||||
expectedIdentifierFields,
|
||||
makeActorMatrix,
|
||||
sentIdentifierFields,
|
||||
withTestActor,
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { AzureResponsesProvider } from './AzureResponsesProvider.js';
|
||||
import { AZURE_MODELS } from './models.js';
|
||||
@@ -206,17 +211,19 @@ describe('AzureResponsesProvider.complete argument validation', () => {
|
||||
// -- Request shape ---------------------------------------------------
|
||||
|
||||
describe('AzureResponsesProvider.complete request shape', () => {
|
||||
it('sends messages as `input`, renames max_tokens, and always sets safety_identifier', async () => {
|
||||
it('sends messages as `input`, renames max_tokens, and sets safety_identifier from the actor', async () => {
|
||||
const provider = makeProvider();
|
||||
responsesCreateMock.mockResolvedValueOnce(okResponse);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-5.3-codex',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
max_tokens: 256,
|
||||
temperature: 0.3,
|
||||
}),
|
||||
await withTestActor(
|
||||
() =>
|
||||
provider.complete({
|
||||
model: 'gpt-5.3-codex',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
max_tokens: 256,
|
||||
temperature: 0.3,
|
||||
}),
|
||||
makeActor({ user: { id: 42, uuid: 'u42', username: 'alice' } }),
|
||||
);
|
||||
|
||||
const [args] = responsesCreateMock.mock.calls[0]!;
|
||||
@@ -224,9 +231,31 @@ describe('AzureResponsesProvider.complete request shape', () => {
|
||||
expect(args.input).toEqual([{ role: 'user', content: 'hello' }]);
|
||||
expect(args.max_output_tokens).toBe(256);
|
||||
expect(args.temperature).toBe(0.3);
|
||||
expect(args.user).toBe('puter-u42');
|
||||
expect(args.safety_identifier).toBe(args.user);
|
||||
});
|
||||
|
||||
it('sends the actor uuid and effective app uid as user/safety_identifier', async () => {
|
||||
const provider = makeProvider();
|
||||
responsesCreateMock.mockResolvedValue(okResponse);
|
||||
|
||||
for (const actor of makeActorMatrix()) {
|
||||
await withTestActor(
|
||||
() =>
|
||||
provider.complete({
|
||||
model: 'gpt-5.3-codex',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}),
|
||||
actor,
|
||||
);
|
||||
}
|
||||
|
||||
const fields = ['user', 'safety_identifier', 'prompt_cache_key'];
|
||||
expect(
|
||||
sentIdentifierFields(responsesCreateMock.mock.calls, fields),
|
||||
).toEqual(expectedIdentifierFields(fields));
|
||||
});
|
||||
|
||||
it('resolves an alias against the unrestricted catalog', async () => {
|
||||
const provider = makeProvider();
|
||||
responsesCreateMock.mockResolvedValueOnce(okResponse);
|
||||
|
||||
@@ -143,6 +143,8 @@ export class AzureResponsesProvider implements IChatProvider {
|
||||
)!;
|
||||
|
||||
const userIdentifier = upstreamUserIdentifier(actor);
|
||||
// Cache key defaults to the actor identifier; see upstreamUserIdentifier.
|
||||
const cacheKey = prompt_cache_key ?? userIdentifier;
|
||||
|
||||
// Resolve any `puter_path` content parts into inline base64 data URLs
|
||||
// before the Responses API sees them.
|
||||
@@ -206,7 +208,7 @@ export class AzureResponsesProvider implements IChatProvider {
|
||||
...(instructions !== undefined ? { instructions } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
...(prompt !== undefined ? { prompt } : {}),
|
||||
...(prompt_cache_key !== undefined ? { prompt_cache_key } : {}),
|
||||
...(cacheKey !== undefined ? { prompt_cache_key: cacheKey } : {}),
|
||||
...(prompt_cache_retention !== undefined
|
||||
? { prompt_cache_retention }
|
||||
: {}),
|
||||
|
||||
@@ -790,42 +790,69 @@ describe('ClaudeProvider.complete request shape', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('omits temperature for fable 5.1 (rejects non-default sampling)', async () => {
|
||||
it.each(['claude-fable-5-1', 'claude-opus-5-5', 'claude-sonnet-5'])(
|
||||
'omits temperature for %s',
|
||||
async (model) => {
|
||||
const { provider } = makeProvider();
|
||||
messagesCreateMock.mockResolvedValueOnce(baseResponse);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
temperature: 0.5,
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = messagesCreateMock.mock.calls[0]!;
|
||||
expect('temperature' in args).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['claude-fable-5-1', 'claude-opus-5-5', 'claude-sonnet-5'])(
|
||||
'uses adaptive thinking and output effort on %s',
|
||||
async (model) => {
|
||||
const { provider } = makeProvider();
|
||||
messagesCreateMock.mockResolvedValueOnce(baseResponse);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
reasoning_effort: 'high',
|
||||
} as never),
|
||||
);
|
||||
|
||||
const [args] = messagesCreateMock.mock.calls[0]!;
|
||||
expect(args.thinking).toEqual({
|
||||
type: 'adaptive',
|
||||
display: 'summarized',
|
||||
});
|
||||
expect(args.output_config).toEqual({ effort: 'high' });
|
||||
expect('temperature' in args).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('forwards reasoning_effort as adaptive thinking + output_config effort on opus 5.5', async () => {
|
||||
const { provider } = makeProvider();
|
||||
messagesCreateMock.mockResolvedValueOnce(baseResponse);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'claude-fable-5-1',
|
||||
model: 'claude-opus-5-5',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
reasoning_effort: 'low',
|
||||
temperature: 0.5,
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = messagesCreateMock.mock.calls[0]!;
|
||||
expect('temperature' in args).toBe(false);
|
||||
});
|
||||
|
||||
it('forwards reasoning_effort as adaptive thinking + output_config effort on fable 5.1', async () => {
|
||||
const { provider } = makeProvider();
|
||||
messagesCreateMock.mockResolvedValueOnce(baseResponse);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'claude-fable-5-1',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
reasoning_effort: 'high',
|
||||
} as never),
|
||||
);
|
||||
|
||||
const [args] = messagesCreateMock.mock.calls[0]!;
|
||||
// Fable 5.1 rejects `budget_tokens` and thinking is always on, so the
|
||||
// only accepted config is adaptive; effort rides in output_config.
|
||||
// Opus 5.5 rejects disabled thinking, `budget_tokens`, and sampling params.
|
||||
expect(args.thinking).toEqual({
|
||||
type: 'adaptive',
|
||||
display: 'summarized',
|
||||
});
|
||||
expect(args.output_config).toEqual({ effort: 'high' });
|
||||
expect(args.output_config).toEqual({ effort: 'low' });
|
||||
expect('temperature' in args).toBe(false);
|
||||
});
|
||||
|
||||
@@ -898,6 +925,22 @@ describe('ClaudeProvider model resolution', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('routes the bare claude-opus alias to opus 5.5 rather than opus 5', async () => {
|
||||
const { provider } = makeProvider();
|
||||
messagesCreateMock.mockResolvedValueOnce(baseResponse);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'claude-opus',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(messagesCreateMock.mock.calls[0]![0].model).toBe(
|
||||
'claude-opus-5-5',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the default model when given an unknown id', async () => {
|
||||
const { provider } = makeProvider();
|
||||
messagesCreateMock.mockResolvedValueOnce(baseResponse);
|
||||
@@ -918,6 +961,60 @@ describe('ClaudeProvider model resolution', () => {
|
||||
// ── Non-stream completion ───────────────────────────────────────────
|
||||
|
||||
describe('ClaudeProvider.complete non-stream output', () => {
|
||||
it.each([
|
||||
['claude-opus-5-5', 'claude-opus-5-5', 400, 500, 800, 20, 2000],
|
||||
[
|
||||
'anthropic/claude-opus-5-5',
|
||||
'claude-opus-5-5',
|
||||
400,
|
||||
500,
|
||||
800,
|
||||
20,
|
||||
2000,
|
||||
],
|
||||
['claude-opus', 'claude-opus-5-5', 400, 500, 800, 20, 2000],
|
||||
['claude-opus-latest', 'claude-opus-5-5', 400, 500, 800, 20, 2000],
|
||||
['claude-opus-5-latest', 'claude-opus-5', 500, 625, 1000, 50, 2500],
|
||||
['claude-sonnet-5', 'claude-sonnet-5', 200, 250, 400, 20, 1000],
|
||||
])(
|
||||
'resolves and meters %s at its current rates',
|
||||
async (model, canonicalId, input, write5m, write1h, cached, output) => {
|
||||
const { provider } = makeProvider();
|
||||
messagesCreateMock.mockResolvedValueOnce({
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_creation_input_tokens: 30,
|
||||
cache_creation: {
|
||||
ephemeral_5m_input_tokens: 10,
|
||||
ephemeral_1h_input_tokens: 20,
|
||||
},
|
||||
cache_read_input_tokens: 1000,
|
||||
},
|
||||
});
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
expect(await provider.list()).toContain(model);
|
||||
const [args] = messagesCreateMock.mock.calls[0]!;
|
||||
expect(args.model).toBe(canonicalId);
|
||||
expect(args.max_tokens).toBe(128_000);
|
||||
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe(`claude:${canonicalId}`);
|
||||
expect(overrides).toMatchObject({
|
||||
input_tokens: 100 * Number(input),
|
||||
output_tokens: 50 * Number(output),
|
||||
ephemeral_5m_input_tokens: 10 * Number(write5m),
|
||||
ephemeral_1h_input_tokens: 20 * Number(write1h),
|
||||
cache_read_input_tokens: 1000 * Number(cached),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('returns the message verbatim and meters input/output/cache token costs', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const msg = {
|
||||
|
||||
@@ -367,6 +367,7 @@ export class ClaudeProvider implements IChatProvider {
|
||||
'claude-opus-4-7',
|
||||
'claude-opus-4-8',
|
||||
'claude-opus-5',
|
||||
'claude-opus-5-5',
|
||||
].includes(modelUsed.id);
|
||||
const resolvedTemperature = omitsTemperature
|
||||
? undefined
|
||||
@@ -377,6 +378,7 @@ export class ClaudeProvider implements IChatProvider {
|
||||
'claude-fable-5-1',
|
||||
'claude-fable-5',
|
||||
'claude-sonnet-5',
|
||||
'claude-opus-5-5',
|
||||
'claude-opus-5',
|
||||
'claude-opus-4-8',
|
||||
'claude-opus-4-7',
|
||||
@@ -804,14 +806,13 @@ export class ClaudeProvider implements IChatProvider {
|
||||
}) {
|
||||
if (!reasoningEffort) return undefined;
|
||||
|
||||
// Fable 5/5.1, Opus 4.7+, 4.6, and Sonnet 4.6 use adaptive thinking
|
||||
// (`budget_tokens` is deprecated on 4.6/Sonnet 4.6, removed on
|
||||
// Fable 5+ and Opus 4.7+). Fable 5/5.1 and Opus 4.7+ omit thinking
|
||||
// content by default; `display: 'summarized'` restores visible
|
||||
// reasoning in the stream.
|
||||
// These models reject manual thinking budgets; summarized display
|
||||
// keeps reasoning visible in the stream.
|
||||
if (
|
||||
modelId === 'claude-fable-5-1' ||
|
||||
modelId === 'claude-fable-5' ||
|
||||
modelId === 'claude-sonnet-5' ||
|
||||
modelId === 'claude-opus-5-5' ||
|
||||
modelId === 'claude-opus-5' ||
|
||||
modelId === 'claude-opus-4-8' ||
|
||||
modelId === 'claude-opus-4-7'
|
||||
|
||||
@@ -44,7 +44,7 @@ export const CLAUDE_MODELS: IChatModel[] = [
|
||||
input_tokens: 1000,
|
||||
ephemeral_5m_input_tokens: 1000 * 1.25,
|
||||
ephemeral_1h_input_tokens: 1000 * 2,
|
||||
// Fable 5.1 bills cache reads at 0.025x input; every other Claude model is 0.1x.
|
||||
// 0.025x input, not the usual 0.1x.
|
||||
cache_read_input_tokens: 1000 * 0.025,
|
||||
output_tokens: 5000,
|
||||
},
|
||||
@@ -80,6 +80,7 @@ export const CLAUDE_MODELS: IChatModel[] = [
|
||||
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2026-01',
|
||||
release_date: '2026-06-30',
|
||||
aliases: [
|
||||
'claude-sonnet',
|
||||
@@ -93,14 +94,45 @@ export const CLAUDE_MODELS: IChatModel[] = [
|
||||
output_cost_key: 'output_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
input_tokens: 300,
|
||||
ephemeral_5m_input_tokens: 300 * 1.25,
|
||||
ephemeral_1h_input_tokens: 300 * 2,
|
||||
cache_read_input_tokens: 300 * 0.1,
|
||||
output_tokens: 1500,
|
||||
input_tokens: 200,
|
||||
ephemeral_5m_input_tokens: 200 * 1.25,
|
||||
ephemeral_1h_input_tokens: 200 * 2,
|
||||
cache_read_input_tokens: 200 * 0.1,
|
||||
output_tokens: 1000,
|
||||
},
|
||||
context: 1000000,
|
||||
max_tokens: 64000,
|
||||
max_tokens: 128000,
|
||||
},
|
||||
{
|
||||
puterId: 'anthropic:anthropic/claude-opus-5-5',
|
||||
id: 'claude-opus-5-5',
|
||||
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2026-06',
|
||||
release_date: '2026-09-22',
|
||||
aliases: [
|
||||
'claude-opus',
|
||||
'claude-opus-latest',
|
||||
'claude-opus-5-5-latest',
|
||||
'claude-opus-5.5',
|
||||
'anthropic/claude-opus-5-5',
|
||||
],
|
||||
name: 'Claude Opus 5.5',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'input_tokens',
|
||||
output_cost_key: 'output_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
input_tokens: 400,
|
||||
ephemeral_5m_input_tokens: 400 * 1.25,
|
||||
ephemeral_1h_input_tokens: 400 * 2,
|
||||
// 0.05x input, not the usual 0.1x.
|
||||
cache_read_input_tokens: 400 * 0.05,
|
||||
output_tokens: 2000,
|
||||
},
|
||||
context: 1000000,
|
||||
max_tokens: 128000,
|
||||
},
|
||||
{
|
||||
puterId: 'anthropic:anthropic/claude-opus-5',
|
||||
@@ -110,12 +142,7 @@ export const CLAUDE_MODELS: IChatModel[] = [
|
||||
tool_call: true,
|
||||
knowledge: '2026-05',
|
||||
release_date: '2026-07-24',
|
||||
aliases: [
|
||||
'claude-opus',
|
||||
'claude-opus-latest',
|
||||
'claude-opus-5-latest',
|
||||
'anthropic/claude-opus-5',
|
||||
],
|
||||
aliases: ['claude-opus-5-latest', 'anthropic/claude-opus-5'],
|
||||
name: 'Claude Opus 5',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'input_tokens',
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
/**
|
||||
* Integration test for the DeepSeek provider.
|
||||
*
|
||||
* Uses `deepseek-chat` (the cheap V3 chat model, provider default).
|
||||
* Uses `deepseek-chat` (the Flash legacy alias).
|
||||
* Skipped when `PUTER_TEST_AI_DEEPSEEK_API_KEY` is unset.
|
||||
*/
|
||||
|
||||
|
||||
@@ -17,17 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Offline unit tests for DeepSeekProvider.
|
||||
*
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
* redis) and constructs DeepSeekProvider directly against the live
|
||||
* wired `MeteringService` so the recording side is exercised end-to-
|
||||
* end. DeepSeek is OpenAI-compatible so the OpenAI SDK is mocked at
|
||||
* the module boundary; that's the real network egress point. The
|
||||
* companion integration test (DeepSeekProvider.integration.test.ts)
|
||||
* exercises the real DeepSeek endpoint.
|
||||
*/
|
||||
/** Offline provider tests with an in-memory server and mocked OpenAI network boundary. */
|
||||
|
||||
import { Writable } from 'node:stream';
|
||||
import {
|
||||
@@ -145,9 +135,9 @@ describe('DeepSeekProvider construction', () => {
|
||||
// ── Model catalog ───────────────────────────────────────────────────
|
||||
|
||||
describe('DeepSeekProvider model catalog', () => {
|
||||
it('returns deepseek-v4-flash as the default', () => {
|
||||
it('returns deepseek-flash as the default', () => {
|
||||
const { provider } = makeProvider();
|
||||
expect(provider.getDefaultModel()).toBe('deepseek-v4-flash');
|
||||
expect(provider.getDefaultModel()).toBe('deepseek-flash');
|
||||
});
|
||||
|
||||
it('exposes the static DEEPSEEK_MODELS list verbatim from models()', () => {
|
||||
@@ -164,8 +154,10 @@ describe('DeepSeekProvider model catalog', () => {
|
||||
expect(ids).toContain(a);
|
||||
}
|
||||
}
|
||||
expect(ids).toContain('deepseek-v4-flash');
|
||||
expect(ids).toContain('deepseek-v4-pro');
|
||||
expect(ids).toContain('deepseek-flash');
|
||||
expect(ids).toContain('deepseek-v4-flash');
|
||||
expect(ids).toContain('deepseek-v4-flash-vision-exp');
|
||||
expect(ids).toContain('deepseek-chat');
|
||||
expect(ids).toContain('deepseek/deepseek-v4-pro');
|
||||
});
|
||||
@@ -190,13 +182,13 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-v4-flash',
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.model).toBe('deepseek-v4-flash');
|
||||
expect(args.model).toBe('deepseek-v4-pro');
|
||||
expect(args.messages).toEqual([{ role: 'user', content: 'hello' }]);
|
||||
expect(args.max_tokens).toBe(1000);
|
||||
});
|
||||
@@ -207,7 +199,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-v4-flash',
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
max_tokens: 256,
|
||||
}),
|
||||
@@ -222,7 +214,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-v4-flash',
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
max_tokens: 0,
|
||||
}),
|
||||
@@ -237,7 +229,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-v4-flash',
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
@@ -261,7 +253,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
];
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-v4-flash',
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
tools,
|
||||
}),
|
||||
@@ -275,7 +267,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-v4-flash',
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: false,
|
||||
}),
|
||||
@@ -287,7 +279,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
createMock.mockReturnValueOnce(asAsyncIterable([]));
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-v4-flash',
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: true,
|
||||
}),
|
||||
@@ -303,7 +295,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-v4-flash',
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
@@ -344,7 +336,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-v4-flash',
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
@@ -381,7 +373,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-v4-flash',
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [
|
||||
{ role: 'user', content: 'do tool call' },
|
||||
{
|
||||
@@ -429,16 +421,16 @@ describe('DeepSeekProvider model resolution', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-v4-flash',
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-pro');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
'deepseek:deepseek-v4-flash',
|
||||
'deepseek:deepseek-v4-pro',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -449,17 +441,17 @@ describe('DeepSeekProvider model resolution', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
// `deepseek/deepseek-v4-flash` is an alias of `deepseek-v4-flash`.
|
||||
model: 'deepseek/deepseek-v4-flash',
|
||||
// `deepseek/deepseek-v4-pro` is an alias of `deepseek-v4-pro`.
|
||||
model: 'deepseek/deepseek-v4-pro',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-pro');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
'deepseek:deepseek-v4-flash',
|
||||
'deepseek:deepseek-v4-pro',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -475,26 +467,34 @@ describe('DeepSeekProvider model resolution', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-flash');
|
||||
const flash = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-flash')!;
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
{ prompt_tokens: 1, completion_tokens: 1, cached_tokens: 0 },
|
||||
expect.anything(),
|
||||
'deepseek:deepseek-v4-flash',
|
||||
expect.any(Object),
|
||||
'deepseek:deepseek-flash',
|
||||
{
|
||||
prompt_tokens: Number(flash.costs.prompt_tokens),
|
||||
completion_tokens: Number(flash.costs.completion_tokens),
|
||||
cached_tokens: 0,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// The legacy DeepSeek chat/reasoner ids (and their `deepseek/…` and
|
||||
// `deepseek:deepseek/…` variants) are aliased onto deepseek-v4-flash
|
||||
// so callers using the old names get transparently upgraded — and
|
||||
// metered against the v4-flash canonical prefix.
|
||||
it.each([
|
||||
'deepseek-v4-flash',
|
||||
'deepseek/deepseek-v4-flash',
|
||||
'deepseek:deepseek/deepseek-v4-flash',
|
||||
'deepseek-v4-flash-vision-exp',
|
||||
'deepseek/deepseek-v4-flash-vision-exp',
|
||||
'deepseek:deepseek/deepseek-v4-flash-vision-exp',
|
||||
'deepseek-chat',
|
||||
'deepseek/deepseek-chat',
|
||||
'deepseek:deepseek/deepseek-chat',
|
||||
'deepseek-reasoner',
|
||||
'deepseek/deepseek-reasoner',
|
||||
'deepseek:deepseek/deepseek-reasoner',
|
||||
])('maps legacy alias %s onto deepseek-v4-flash', async (alias) => {
|
||||
])('maps Flash and legacy alias %s onto deepseek-flash', async (alias) => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
@@ -505,11 +505,11 @@ describe('DeepSeekProvider model resolution', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-flash');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
'deepseek:deepseek-v4-flash',
|
||||
'deepseek:deepseek-flash',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -536,7 +536,7 @@ describe('DeepSeekProvider.complete non-stream output', () => {
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-v4-flash',
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
@@ -551,24 +551,91 @@ describe('DeepSeekProvider.complete non-stream output', () => {
|
||||
cached_tokens: 10,
|
||||
});
|
||||
|
||||
// deepseek-v4-flash costs: prompt=14, completion=28, cached=0.28
|
||||
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-v4-flash')!;
|
||||
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-v4-pro')!;
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage).toEqual({
|
||||
prompt_tokens: 100,
|
||||
prompt_tokens: 90,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 10,
|
||||
});
|
||||
expect(actor).toBe(SYSTEM_ACTOR);
|
||||
expect(prefix).toBe('deepseek:deepseek-v4-flash');
|
||||
expect(prefix).toBe('deepseek:deepseek-v4-pro');
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 100 * Number(chat.costs.prompt_tokens),
|
||||
prompt_tokens: 90 * Number(chat.costs.prompt_tokens),
|
||||
completion_tokens: 50 * Number(chat.costs.completion_tokens),
|
||||
cached_tokens: 10 * Number(chat.costs.cached_tokens ?? 0),
|
||||
});
|
||||
});
|
||||
|
||||
it('charges a fully cached prompt only at the cache rate', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'ok', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 0,
|
||||
prompt_tokens_details: { cached_tokens: 100 },
|
||||
},
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const flash = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-flash')!;
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
{ prompt_tokens: 0, completion_tokens: 0, cached_tokens: 100 },
|
||||
expect.anything(),
|
||||
'deepseek:deepseek-flash',
|
||||
{
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
cached_tokens: 100 * Number(flash.costs.cached_tokens),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('uses DeepSeek cache hit and miss counts when provided', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'ok', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 1,
|
||||
prompt_cache_hit_tokens: 40,
|
||||
prompt_cache_miss_tokens: 60,
|
||||
},
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
{ prompt_tokens: 60, completion_tokens: 1, cached_tokens: 40 },
|
||||
expect.anything(),
|
||||
'deepseek:deepseek-v4-pro',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves OpenAI-shaped tool_calls on the assistant response', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
@@ -596,7 +663,7 @@ describe('DeepSeekProvider.complete non-stream output', () => {
|
||||
|
||||
const result = (await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-v4-flash',
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [{ role: 'user', content: 'do a tool call' }],
|
||||
tools: [
|
||||
{
|
||||
@@ -631,7 +698,7 @@ describe('DeepSeekProvider.complete non-stream output', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-v4-flash',
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
@@ -664,7 +731,7 @@ describe('DeepSeekProvider.complete streaming', () => {
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-v4-flash',
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [{ role: 'user', content: 'say hi' }],
|
||||
stream: true,
|
||||
}),
|
||||
@@ -689,12 +756,12 @@ describe('DeepSeekProvider.complete streaming', () => {
|
||||
cached_tokens: 1,
|
||||
});
|
||||
|
||||
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-v4-flash')!;
|
||||
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-v4-pro')!;
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('deepseek:deepseek-v4-flash');
|
||||
expect(prefix).toBe('deepseek:deepseek-v4-pro');
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 4 * Number(chat.costs.prompt_tokens),
|
||||
prompt_tokens: 3 * Number(chat.costs.prompt_tokens),
|
||||
completion_tokens: 2 * Number(chat.costs.completion_tokens),
|
||||
cached_tokens: 1 * Number(chat.costs.cached_tokens ?? 0),
|
||||
});
|
||||
@@ -745,7 +812,7 @@ describe('DeepSeekProvider.complete streaming', () => {
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-v4-flash',
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [{ role: 'user', content: 'do tool call' }],
|
||||
tools: [
|
||||
{
|
||||
@@ -784,7 +851,7 @@ describe('DeepSeekProvider.complete error mapping', () => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-v4-flash',
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [{ role: 'user', content: 'boom' }],
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { IChatProvider, ICompleteArguments } from '../../types.js';
|
||||
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
|
||||
import { DEEPSEEK_MODELS } from './models.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { deepSeekPricing } from './pricing.js';
|
||||
|
||||
export class DeepSeekProvider implements IChatProvider {
|
||||
#openai: OpenAI;
|
||||
@@ -38,10 +39,11 @@ export class DeepSeekProvider implements IChatProvider {
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
});
|
||||
this.#meteringService = meteringService;
|
||||
deepSeekPricing.start();
|
||||
}
|
||||
|
||||
getDefaultModel() {
|
||||
return 'deepseek-v4-flash';
|
||||
return 'deepseek-flash';
|
||||
}
|
||||
|
||||
models() {
|
||||
@@ -60,6 +62,7 @@ export class DeepSeekProvider implements IChatProvider {
|
||||
max_tokens,
|
||||
temperature,
|
||||
}: ICompleteArguments): ReturnType<IChatProvider['complete']> {
|
||||
await deepSeekPricing.refreshIfStale();
|
||||
const actor = Context.get('actor');
|
||||
const availableModels = this.models();
|
||||
const modelUsed =
|
||||
@@ -122,14 +125,29 @@ export class DeepSeekProvider implements IChatProvider {
|
||||
|
||||
return OpenAIUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage }) => {
|
||||
const deepSeekUsage = usage as typeof usage & {
|
||||
prompt_cache_hit_tokens?: number;
|
||||
prompt_cache_miss_tokens?: number;
|
||||
};
|
||||
const cachedTokens =
|
||||
deepSeekUsage.prompt_cache_hit_tokens ??
|
||||
usage.prompt_tokens_details?.cached_tokens ??
|
||||
0;
|
||||
const trackedUsage = OpenAIUtil.extractMeteredUsage(usage);
|
||||
const meteredUsage = {
|
||||
prompt_tokens:
|
||||
deepSeekUsage.prompt_cache_miss_tokens ??
|
||||
Math.max(0, (usage.prompt_tokens ?? 0) - cachedTokens),
|
||||
completion_tokens: usage.completion_tokens ?? 0,
|
||||
cached_tokens: cachedTokens,
|
||||
};
|
||||
const costsOverrideFromModel = Object.fromEntries(
|
||||
Object.entries(trackedUsage).map(([k, v]) => {
|
||||
Object.entries(meteredUsage).map(([k, v]) => {
|
||||
return [k, v * modelUsed.costs[k]];
|
||||
}),
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
meteredUsage,
|
||||
actor!,
|
||||
`deepseek:${modelUsed.id}`,
|
||||
costsOverrideFromModel,
|
||||
|
||||
@@ -18,61 +18,50 @@
|
||||
*/
|
||||
|
||||
import type { IChatModel } from '../../types.js';
|
||||
import { deepSeekPricing, type DeepSeekModelId } from './pricing.js';
|
||||
|
||||
const dynamicCosts = (modelId: DeepSeekModelId) => ({
|
||||
tokens: 1_000_000,
|
||||
get prompt_tokens() {
|
||||
return deepSeekPricing.costs(modelId).prompt;
|
||||
},
|
||||
get completion_tokens() {
|
||||
return deepSeekPricing.costs(modelId).completion;
|
||||
},
|
||||
get cached_tokens() {
|
||||
return deepSeekPricing.costs(modelId).cached;
|
||||
},
|
||||
});
|
||||
|
||||
// Hardcoded from https://models.dev/api.json
|
||||
export const DEEPSEEK_MODELS: IChatModel[] = [
|
||||
{
|
||||
puterId: 'deepseek:deepseek/deepseek-v4-flash',
|
||||
id: 'deepseek-v4-flash',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
puterId: 'deepseek:deepseek/deepseek-flash',
|
||||
id: 'deepseek-flash',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2026-04',
|
||||
release_date: '2026-04-24',
|
||||
name: 'DeepSeek Chat',
|
||||
name: 'DeepSeek Flash',
|
||||
aliases: [
|
||||
'deepseek/deepseek-flash',
|
||||
'deepseek-v4-flash',
|
||||
'deepseek/deepseek-v4-flash',
|
||||
'deepseek:deepseek/deepseek-v4-flash',
|
||||
'deepseek-v4-flash-vision-exp',
|
||||
'deepseek/deepseek-v4-flash-vision-exp',
|
||||
'deepseek:deepseek/deepseek-v4-flash-vision-exp',
|
||||
'deepseek-chat',
|
||||
'deepseek/deepseek-chat',
|
||||
'deepseek:deepseek/deepseek-chat',
|
||||
'deepseek-reasoner',
|
||||
'deepseek/deepseek-reasoner',
|
||||
'deepseek:deepseek/deepseek-reasoner',
|
||||
'deepseek:deepseek/deepseek-chat',
|
||||
],
|
||||
context: 1_000_000,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 14,
|
||||
completion_tokens: 28,
|
||||
cached_tokens: 0.28,
|
||||
},
|
||||
max_tokens: 384_000,
|
||||
},
|
||||
{
|
||||
// Priced identically to deepseek-v4-flash per DeepSeek's launch note
|
||||
// (https://api-docs.deepseek.com/news/news260821/) and the pricing
|
||||
// page, which lists the same rates for both models; images are
|
||||
// tokenized (up to 384 tokens each) and billed as input tokens.
|
||||
puterId: 'deepseek:deepseek/deepseek-v4-flash-vision-exp',
|
||||
id: 'deepseek-v4-flash-vision-exp',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
release_date: '2026-08-21',
|
||||
name: 'DeepSeek V4 Flash Vision (Experimental)',
|
||||
aliases: ['deepseek/deepseek-v4-flash-vision-exp'],
|
||||
context: 1_000_000,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 14,
|
||||
completion_tokens: 28,
|
||||
cached_tokens: 0.28,
|
||||
},
|
||||
promptTokensIncludeCached: true,
|
||||
costs: dynamicCosts('deepseek-flash'),
|
||||
max_tokens: 384_000,
|
||||
},
|
||||
{
|
||||
@@ -89,12 +78,8 @@ export const DEEPSEEK_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 174,
|
||||
completion_tokens: 348,
|
||||
cached_tokens: 1.45,
|
||||
},
|
||||
promptTokensIncludeCached: true,
|
||||
costs: dynamicCosts('deepseek-v4-pro'),
|
||||
max_tokens: 384_000,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DeepSeekPricing } from './pricing.js';
|
||||
|
||||
describe.skipIf(!process.env.PUTER_TEST_AI_DEEPSEEK_PRICING)(
|
||||
'DeepSeek pricing page (integration)',
|
||||
() => {
|
||||
it('fetches and parses current official rates', async () => {
|
||||
const pricing = new DeepSeekPricing();
|
||||
await pricing.refresh();
|
||||
expect(pricing.costs('deepseek-flash').prompt).toBeGreaterThan(0);
|
||||
expect(pricing.costs('deepseek-v4-pro').cached).toBeGreaterThan(0);
|
||||
}, 15_000);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DEEPSEEK_MODELS } from './models.js';
|
||||
import {
|
||||
DeepSeekPricing,
|
||||
isDeepSeekPeak,
|
||||
parseDeepSeekPrices,
|
||||
} from './pricing.js';
|
||||
|
||||
const pricePage = (flashInput = '$0.15') => `
|
||||
<table><tr><td>MODEL</td><td>deepseek-flash<sup>(1)</sup></td><td>deepseek-v4-pro</td></tr>
|
||||
<tr><td>PRICING</td><td>1M INPUT TOKENS<br>(CACHE HIT)</td><td>OFF-PEAK</td><td>$0.003</td><td>$0.022</td></tr>
|
||||
<tr><td>PEAK</td><td>$0.006</td><td>$0.044</td></tr>
|
||||
<tr><td>1M INPUT TOKENS<br>(CACHE MISS)</td><td>OFF-PEAK</td><td>${flashInput}</td><td>$0.66</td></tr>
|
||||
<tr><td>PEAK</td><td>$0.30</td><td>$1.32</td></tr>
|
||||
<tr><td>1M OUTPUT TOKENS</td><td>OFF-PEAK</td><td>$0.60</td><td>$1.98</td></tr>
|
||||
<tr><td>PEAK</td><td>$1.20</td><td>$3.96</td></tr></table>
|
||||
<p>Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday, excluding Chinese public holidays.</p>`;
|
||||
|
||||
describe('DeepSeek pricing', () => {
|
||||
it('parses both models, periods, and cache rates from the official table shape', () => {
|
||||
const prices = parseDeepSeekPrices(pricePage());
|
||||
expect(prices['deepseek-flash'].offPeak).toEqual({
|
||||
prompt: 0.15,
|
||||
completion: 0.6,
|
||||
cached: 0.003,
|
||||
});
|
||||
expect(prices['deepseek-v4-pro'].peak).toEqual({
|
||||
prompt: 1.32,
|
||||
completion: 3.96,
|
||||
cached: 0.044,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects incomplete tables and changed peak rules', () => {
|
||||
expect(() =>
|
||||
parseDeepSeekPrices(
|
||||
pricePage().replace('excluding Chinese public holidays', ''),
|
||||
),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
parseDeepSeekPrices(pricePage().replace('<td>$3.96</td>', '')),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['2026-09-28T00:59:00Z', false],
|
||||
['2026-09-28T01:00:00Z', true],
|
||||
['2026-09-28T04:00:00Z', false],
|
||||
['2026-09-28T06:00:00Z', true],
|
||||
['2026-09-28T10:00:00Z', false],
|
||||
['2026-09-27T07:00:00Z', false],
|
||||
['2026-10-01T07:00:00Z', false],
|
||||
['2026-05-04T07:00:00Z', false],
|
||||
['2027-09-28T07:00:00Z', false],
|
||||
])('classifies %s peak=%s', (iso, expected) => {
|
||||
expect(isDeepSeekPeak(new Date(iso))).toBe(expected);
|
||||
});
|
||||
|
||||
it('updates catalog rates at UTC peak boundaries even after model routing copies an entry', () => {
|
||||
const flash = {
|
||||
...DEEPSEEK_MODELS.find((model) => model.id === 'deepseek-flash')!,
|
||||
};
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date('2026-09-28T05:59:59Z'));
|
||||
expect(flash.costs.prompt_tokens).toBe(15);
|
||||
vi.setSystemTime(new Date('2026-09-28T06:00:00Z'));
|
||||
expect(flash.costs.prompt_tokens).toBe(30);
|
||||
expect(flash.costs.cached_tokens).toBe(0.6);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('refreshes rates and keeps the last good schedule after a bad response', async () => {
|
||||
const fetchPage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(pricePage('$0.18')))
|
||||
.mockResolvedValueOnce(new Response('<html>bad prices</html>'));
|
||||
const pricing = new DeepSeekPricing(fetchPage);
|
||||
const offPeak = new Date('2026-09-28T05:00:00Z');
|
||||
|
||||
await pricing.refresh();
|
||||
expect(pricing.costs('deepseek-flash', offPeak).prompt).toBe(18);
|
||||
await expect(pricing.refresh()).rejects.toThrow();
|
||||
expect(pricing.costs('deepseek-flash', offPeak).prompt).toBe(18);
|
||||
expect(fetchPage).toHaveBeenCalledWith(
|
||||
'https://api-docs.deepseek.com/quick_start/pricing/',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a failed automatic refresh without replacing rates', async () => {
|
||||
const reportError = vi.fn();
|
||||
const pricing = new DeepSeekPricing(
|
||||
vi.fn().mockRejectedValue(new Error('offline')),
|
||||
reportError,
|
||||
);
|
||||
await expect(pricing.refreshIfStale()).resolves.toBeUndefined();
|
||||
expect(reportError).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
pricing.costs('deepseek-flash', new Date('2026-09-28T05:00:00Z'))
|
||||
.prompt,
|
||||
).toBe(15);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export type DeepSeekModelId = 'deepseek-flash' | 'deepseek-v4-pro';
|
||||
type Period = 'offPeak' | 'peak';
|
||||
type TokenRates = { prompt: number; completion: number; cached: number };
|
||||
type PriceSchedule = Record<DeepSeekModelId, Record<Period, TokenRates>>;
|
||||
|
||||
const PRICING_URL = 'https://api-docs.deepseek.com/quick_start/pricing/';
|
||||
const REFRESH_MS = 60 * 60 * 1000;
|
||||
|
||||
// Last verified official rates, in USD per million tokens. Used until a valid
|
||||
// refresh succeeds; the page remains the source of subsequent changes.
|
||||
const FALLBACK_RATES: PriceSchedule = {
|
||||
'deepseek-flash': {
|
||||
offPeak: { prompt: 0.15, completion: 0.6, cached: 0.003 },
|
||||
peak: { prompt: 0.3, completion: 1.2, cached: 0.006 },
|
||||
},
|
||||
'deepseek-v4-pro': {
|
||||
offPeak: { prompt: 0.66, completion: 1.98, cached: 0.022 },
|
||||
peak: { prompt: 1.32, completion: 3.96, cached: 0.044 },
|
||||
},
|
||||
};
|
||||
|
||||
// State Council's 2026 public holiday arrangement:
|
||||
// https://en.bjhd.gov.cn/workinginhaidian/supportingservices/publicholidays/202512/t20251211_4797062.shtml
|
||||
const HOLIDAYS_2026 = [
|
||||
['2026-01-01', '2026-01-03'],
|
||||
['2026-02-15', '2026-02-23'],
|
||||
['2026-04-04', '2026-04-06'],
|
||||
['2026-05-01', '2026-05-05'],
|
||||
['2026-06-19', '2026-06-21'],
|
||||
['2026-09-25', '2026-09-27'],
|
||||
['2026-10-01', '2026-10-07'],
|
||||
];
|
||||
|
||||
const isChinesePublicHoliday = (date: Date): boolean => {
|
||||
const day = date.toISOString().slice(0, 10);
|
||||
return HOLIDAYS_2026.some(([start, end]) => day >= start && day <= end);
|
||||
};
|
||||
|
||||
export const isDeepSeekPeak = (date: Date): boolean => {
|
||||
// An unpublished year's holiday calendar must not cause peak overbilling.
|
||||
if (date.getUTCFullYear() !== 2026) return false;
|
||||
const weekday = date.getUTCDay();
|
||||
const hour = date.getUTCHours();
|
||||
return (
|
||||
weekday >= 1 &&
|
||||
weekday <= 5 &&
|
||||
!isChinesePublicHoliday(date) &&
|
||||
((hour >= 1 && hour < 4) || (hour >= 6 && hour < 10))
|
||||
);
|
||||
};
|
||||
|
||||
const plainText = (html: string): string =>
|
||||
html
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const readDollars = (cell: string): number => {
|
||||
if (!/^\$\d+(?:\.\d+)?$/.test(cell))
|
||||
throw new Error('Invalid DeepSeek price');
|
||||
const value = Number(cell.slice(1));
|
||||
if (!Number.isFinite(value) || value <= 0 || value > 100) {
|
||||
throw new Error('DeepSeek price outside expected range');
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const parseDeepSeekPrices = (html: string): PriceSchedule => {
|
||||
if (
|
||||
!plainText(html).includes(
|
||||
'Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday, excluding Chinese public holidays.',
|
||||
)
|
||||
) {
|
||||
throw new Error('DeepSeek peak schedule changed');
|
||||
}
|
||||
const table = [...html.matchAll(/<table\b[^>]*>[\s\S]*?<\/table>/gi)]
|
||||
.map(([match]) => match)
|
||||
.find(
|
||||
(value) =>
|
||||
value.includes('deepseek-flash') && value.includes('PRICING'),
|
||||
);
|
||||
if (!table) throw new Error('DeepSeek pricing table missing');
|
||||
|
||||
const rows = [...table.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)].map(
|
||||
([, row]) =>
|
||||
[...row.matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi)].map(([, cell]) =>
|
||||
plainText(cell),
|
||||
),
|
||||
);
|
||||
if (
|
||||
rows[0]?.at(-2) !== 'deepseek-flash (1)' ||
|
||||
rows[0]?.at(-1) !== 'deepseek-v4-pro'
|
||||
) {
|
||||
throw new Error('DeepSeek model columns changed');
|
||||
}
|
||||
|
||||
const rates = structuredClone(FALLBACK_RATES);
|
||||
const seen = new Set<string>();
|
||||
let category: keyof TokenRates | undefined;
|
||||
let pricingStarted = false;
|
||||
for (const cells of rows) {
|
||||
if (cells.some((cell) => cell.startsWith('PRICING')))
|
||||
pricingStarted = true;
|
||||
if (!pricingStarted) continue;
|
||||
const label = cells.join(' ');
|
||||
if (label.includes('CACHE HIT')) category = 'cached';
|
||||
else if (label.includes('CACHE MISS')) category = 'prompt';
|
||||
else if (label.includes('OUTPUT TOKENS')) category = 'completion';
|
||||
const periodCell = cells.at(-3);
|
||||
if (periodCell !== 'PEAK' && periodCell !== 'OFF-PEAK') continue;
|
||||
if (!category) throw new Error('DeepSeek price category missing');
|
||||
const period = periodCell === 'PEAK' ? 'peak' : 'offPeak';
|
||||
const key = `${period}:${category}`;
|
||||
if (seen.has(key)) throw new Error('Duplicate DeepSeek price');
|
||||
rates['deepseek-flash'][period][category] = readDollars(cells.at(-2)!);
|
||||
rates['deepseek-v4-pro'][period][category] = readDollars(cells.at(-1)!);
|
||||
seen.add(key);
|
||||
}
|
||||
if (seen.size !== 6) throw new Error('Incomplete DeepSeek prices');
|
||||
for (const model of Object.values(rates)) {
|
||||
for (const category of ['prompt', 'completion', 'cached'] as const) {
|
||||
if (model.peak[category] < model.offPeak[category]) {
|
||||
throw new Error('DeepSeek peak price below off-peak price');
|
||||
}
|
||||
}
|
||||
if (model.offPeak.cached > model.offPeak.prompt) {
|
||||
throw new Error('DeepSeek cache price above input price');
|
||||
}
|
||||
}
|
||||
return rates;
|
||||
};
|
||||
|
||||
export class DeepSeekPricing {
|
||||
#rates: PriceSchedule = structuredClone(FALLBACK_RATES);
|
||||
#refreshPromise?: Promise<void>;
|
||||
#lastAttempt = 0;
|
||||
#timer?: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(
|
||||
private readonly fetchPage: typeof fetch = fetch,
|
||||
public onRefreshError: (error: unknown) => void = (error) =>
|
||||
console.warn('[deepseek] pricing refresh failed', error),
|
||||
) {}
|
||||
|
||||
costs(modelId: DeepSeekModelId, date = new Date()): TokenRates {
|
||||
const period = isDeepSeekPeak(date) ? 'peak' : 'offPeak';
|
||||
const rates = this.#rates[modelId][period];
|
||||
return {
|
||||
prompt: rates.prompt * 100,
|
||||
completion: rates.completion * 100,
|
||||
cached: rates.cached * 100,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
if (this.#refreshPromise) return this.#refreshPromise;
|
||||
this.#lastAttempt = Date.now();
|
||||
this.#refreshPromise = (async () => {
|
||||
const response = await this.fetchPage(PRICING_URL, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`DeepSeek pricing HTTP ${response.status}`);
|
||||
this.#rates = parseDeepSeekPrices(await response.text());
|
||||
})();
|
||||
try {
|
||||
await this.#refreshPromise;
|
||||
} finally {
|
||||
this.#refreshPromise = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshIfStale(): Promise<void> {
|
||||
if (
|
||||
!this.#refreshPromise &&
|
||||
Date.now() - this.#lastAttempt < REFRESH_MS
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await this.refresh();
|
||||
} catch (error) {
|
||||
try {
|
||||
this.onRefreshError(error);
|
||||
} catch {
|
||||
console.warn('[deepseek] pricing refresh failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.#timer) return;
|
||||
void this.refreshIfStale();
|
||||
this.#timer = setInterval(() => void this.refreshIfStale(), REFRESH_MS);
|
||||
this.#timer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
export const deepSeekPricing = new DeepSeekPricing();
|
||||
@@ -361,7 +361,7 @@ describe('MetaProvider.complete request shape', () => {
|
||||
|
||||
it('derives safety_identifier from the actor and truncates it to 64 chars', async () => {
|
||||
createMock.mockResolvedValueOnce(OK_COMPLETION);
|
||||
const userActor: Actor = makeActor({
|
||||
const userActor = makeActor({
|
||||
user: { id: 42, uuid: 'u42', username: 'alice' },
|
||||
app: { id: 7, uid: 'a'.repeat(80) },
|
||||
});
|
||||
@@ -369,29 +369,60 @@ describe('MetaProvider.complete request shape', () => {
|
||||
await complete(makeProvider(), {}, userActor);
|
||||
|
||||
const identifier = createMock.mock.calls[0]![0].safety_identifier;
|
||||
expect(identifier.startsWith('puter-42-a')).toBe(true);
|
||||
expect(identifier.startsWith('puter-u42-a')).toBe(true);
|
||||
expect(identifier.length).toBe(64);
|
||||
});
|
||||
|
||||
it('prefers an explicit custom.safety_identifier over the actor-derived one', async () => {
|
||||
it('attributes the app through effectiveApp for access-token actors', async () => {
|
||||
createMock.mockResolvedValueOnce(OK_COMPLETION);
|
||||
const userActor: Actor = { user: { id: 42, uuid: 'u42' } };
|
||||
const tokenActor = makeActor({
|
||||
user: { id: 42, uuid: 'u42', username: 'alice' },
|
||||
accessToken: {
|
||||
uid: 'tok-1',
|
||||
issuer: makeActor({
|
||||
user: { id: 42, uuid: 'u42', username: 'alice' },
|
||||
app: { id: 7, uid: 'app-abc' },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await complete(makeProvider(), {}, tokenActor);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].safety_identifier).toBe(
|
||||
'puter-u42-app-abc',
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a caller-supplied custom.safety_identifier', async () => {
|
||||
createMock.mockResolvedValueOnce(OK_COMPLETION);
|
||||
const userActor = makeActor({ user: { id: 42, uuid: 'u42' } });
|
||||
await complete(
|
||||
makeProvider(),
|
||||
{ custom: { safety_identifier: 'caller-supplied' } },
|
||||
userActor,
|
||||
);
|
||||
expect(createMock.mock.calls[0]![0].safety_identifier).toBe(
|
||||
'caller-supplied',
|
||||
'puter-u42',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits safety_identifier for the system actor (no user.id)', async () => {
|
||||
it('omits safety_identifier for the system actor', async () => {
|
||||
createMock.mockResolvedValueOnce(OK_COMPLETION);
|
||||
await complete(makeProvider());
|
||||
expect('safety_identifier' in createMock.mock.calls[0]![0]).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults prompt_cache_key to the actor identifier when not supplied', async () => {
|
||||
createMock.mockResolvedValueOnce(OK_COMPLETION);
|
||||
const userActor = makeActor({
|
||||
user: { id: 42, uuid: 'u42', username: 'alice' },
|
||||
});
|
||||
|
||||
await complete(makeProvider(), {}, userActor);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].prompt_cache_key).toBe('puter-u42');
|
||||
});
|
||||
|
||||
it('only sets stream_options.include_usage when streaming', async () => {
|
||||
const provider = makeProvider();
|
||||
createMock.mockResolvedValueOnce(OK_COMPLETION);
|
||||
|
||||
@@ -31,12 +31,10 @@ import { buildCostsOverride } from '../../utils/pricing.js';
|
||||
import { processPuterPathUploads } from '../openai/fileUpload.js';
|
||||
import { META_MODELS, MUSE_SPARK_DEFAULT_MODEL } from './models.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
|
||||
|
||||
const DEFAULT_API_BASE_URL = 'https://api.meta.ai/v1';
|
||||
|
||||
// `safety_identifier` is capped at 64 characters by the Model API.
|
||||
const SAFETY_IDENTIFIER_MAX_LENGTH = 64;
|
||||
|
||||
type MetaConfig = {
|
||||
apiBaseUrl?: string;
|
||||
apiKey: string;
|
||||
@@ -50,7 +48,6 @@ type MetaCustomParams = {
|
||||
frequency_penalty?: number;
|
||||
presence_penalty?: number;
|
||||
response_format?: unknown;
|
||||
safety_identifier?: string;
|
||||
seed?: number;
|
||||
};
|
||||
|
||||
@@ -154,8 +151,7 @@ export class MetaProvider implements IChatProvider {
|
||||
// Reasoning is always on for Muse Spark — `reasoning_effort: 'none'`
|
||||
// is a 400 — so a request to switch it off is dropped, not forwarded.
|
||||
const requestedEffort = (reasoning_effort ?? reasoning?.effort) as
|
||||
| string
|
||||
| undefined;
|
||||
string | undefined;
|
||||
const effort =
|
||||
requestedEffort && requestedEffort !== 'none'
|
||||
? requestedEffort
|
||||
@@ -168,14 +164,10 @@ export class MetaProvider implements IChatProvider {
|
||||
? 'in_memory'
|
||||
: prompt_cache_retention;
|
||||
|
||||
const safetyIdentifier =
|
||||
customParams.safety_identifier ??
|
||||
(actor?.user?.id
|
||||
? `puter-${actor.user.id}${actor.effectiveApp?.uid ? `-${actor.effectiveApp?.uid}` : ''}`.slice(
|
||||
0,
|
||||
SAFETY_IDENTIFIER_MAX_LENGTH,
|
||||
)
|
||||
: undefined);
|
||||
// The identifier is Puter's abuse attribution, so `custom` can't
|
||||
// override it. Cache key defaults to it; see upstreamUserIdentifier.
|
||||
const userIdentifier = upstreamUserIdentifier(actor);
|
||||
const cacheKey = prompt_cache_key ?? userIdentifier;
|
||||
|
||||
const completionParams = {
|
||||
messages,
|
||||
@@ -190,13 +182,11 @@ export class MetaProvider implements IChatProvider {
|
||||
...(temperature !== undefined ? { temperature } : {}),
|
||||
...(top_p !== undefined ? { top_p } : {}),
|
||||
...(effort ? { reasoning_effort: effort } : {}),
|
||||
...(prompt_cache_key !== undefined ? { prompt_cache_key } : {}),
|
||||
...(cacheKey !== undefined ? { prompt_cache_key: cacheKey } : {}),
|
||||
...(cacheRetention !== undefined
|
||||
? { prompt_cache_retention: cacheRetention }
|
||||
: {}),
|
||||
...(safetyIdentifier
|
||||
? { safety_identifier: safetyIdentifier }
|
||||
: {}),
|
||||
...(userIdentifier ? { safety_identifier: userIdentifier } : {}),
|
||||
...(customParams.response_format
|
||||
? { response_format: customParams.response_format }
|
||||
: {}),
|
||||
|
||||
@@ -53,35 +53,6 @@ export const MISTRAL_MODELS: IChatModel[] = [
|
||||
completion_tokens: 750,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/mistral-large-2512',
|
||||
id: 'mistral-large-2512',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2025-12',
|
||||
release_date: '2025-12-02',
|
||||
name: 'Mistral Large 3',
|
||||
aliases: [
|
||||
'mistral-large-latest',
|
||||
'mistral-large',
|
||||
'mistralai/mistral-large-latest',
|
||||
'mistralai/mistral-large-2512',
|
||||
],
|
||||
context: 262_144,
|
||||
max_tokens: 262_144,
|
||||
description:
|
||||
'State-of-the-art, open-weight, general-purpose multimodal model.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 150,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/mistral-small-2603',
|
||||
id: 'mistral-small-2603',
|
||||
@@ -111,29 +82,6 @@ export const MISTRAL_MODELS: IChatModel[] = [
|
||||
completion_tokens: 60,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/zai-glm-5-2',
|
||||
id: 'zai-glm-5-2',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
release_date: '2026-08-06',
|
||||
name: 'Z.ai GLM 5.2',
|
||||
aliases: ['glm-5-2'],
|
||||
context: 1_000_000,
|
||||
max_tokens: 128_000,
|
||||
description:
|
||||
'Third-party open-source model from Z.ai, hosted by Mistral for long-context coding and agentic workflows.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 140,
|
||||
completion_tokens: 440,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/codestral-2508',
|
||||
id: 'codestral-2508',
|
||||
|
||||
+109
-15
@@ -42,11 +42,16 @@ import {
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
|
||||
import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import {
|
||||
expectedIdentifierFields,
|
||||
makeActorMatrix,
|
||||
sentIdentifierFields,
|
||||
withTestActor,
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { OPEN_AI_MODELS } from './models.js';
|
||||
import { OpenAiChatProvider } from './OpenAiChatCompletionsProvider.js';
|
||||
@@ -166,6 +171,8 @@ describe('OpenAiChatProvider model catalog', () => {
|
||||
// gpt-5-nano is a Chat-Completions model, must be present.
|
||||
expect(ids).toContain('gpt-5-nano-2025-08-07');
|
||||
expect(ids).toContain('gpt-6-astra');
|
||||
expect(ids).toContain('gpt-6-sol');
|
||||
expect(ids).toContain('gpt-6-luna');
|
||||
});
|
||||
|
||||
it('list() flattens canonical ids and aliases', () => {
|
||||
@@ -270,26 +277,71 @@ describe('OpenAiChatProvider.complete request shape', () => {
|
||||
expect(args.temperature).toBe(0.4);
|
||||
});
|
||||
|
||||
it('resolves the namespaced GPT-6 Astra alias', async () => {
|
||||
it('sends the actor uuid and effective app uid as user/safety_identifier', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValue(baseCompletion);
|
||||
|
||||
for (const actor of makeActorMatrix()) {
|
||||
await withTestActor(
|
||||
() =>
|
||||
provider.complete({
|
||||
model: 'gpt-5-nano',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}),
|
||||
actor,
|
||||
);
|
||||
}
|
||||
|
||||
const fields = ['user', 'safety_identifier', 'prompt_cache_key'];
|
||||
expect(sentIdentifierFields(createMock.mock.calls, fields)).toEqual(
|
||||
expectedIdentifierFields(fields),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards a caller-supplied prompt_cache_key instead of the derived identifier', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'openai/gpt-6-astra',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}),
|
||||
await withTestActor(
|
||||
() =>
|
||||
provider.complete({
|
||||
model: 'gpt-5-nano',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
prompt_cache_key: 'caller-key',
|
||||
}),
|
||||
makeActor({ user: { id: 42, uuid: 'u42', username: 'alice' } }),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('gpt-6-astra');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
'openai:gpt-6-astra',
|
||||
expect.any(Object),
|
||||
);
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.prompt_cache_key).toBe('caller-key');
|
||||
expect(args.safety_identifier).toBe('puter-u42');
|
||||
});
|
||||
|
||||
it.each(['gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna'])(
|
||||
'resolves the namespaced %s alias',
|
||||
async (model) => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: `openai/${model}`,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
reasoning_effort: 'low',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe(model);
|
||||
expect(createMock.mock.calls[0]![0].reasoning_effort).toBe('low');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
`openai:${model}`,
|
||||
expect.any(Object),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('forwards temperature 0 and max_tokens 0 instead of dropping them', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
@@ -469,6 +521,48 @@ describe('OpenAiChatProvider.complete non-stream output', () => {
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('splits cache writes out of prompt_tokens and bills them at 1.25x input', async () => {
|
||||
const luna = OPEN_AI_MODELS.find((m) => m.id === 'gpt-6-luna')!;
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'hi', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 5000,
|
||||
completion_tokens: 12,
|
||||
prompt_tokens_details: {
|
||||
cached_tokens: 1000,
|
||||
cache_write_tokens: 3000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-6-luna',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage).toEqual({
|
||||
prompt_tokens: 1000,
|
||||
completion_tokens: 12,
|
||||
cached_tokens: 1000,
|
||||
cache_write_tokens: 3000,
|
||||
});
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 1000 * Number(luna.costs.prompt_tokens),
|
||||
completion_tokens: 12 * Number(luna.costs.completion_tokens),
|
||||
cached_tokens: 1000 * Number(luna.costs.cached_tokens),
|
||||
cache_write_tokens: 3000 * Number(luna.costs.cache_write_tokens),
|
||||
});
|
||||
});
|
||||
|
||||
it('zeroes cached_tokens when prompt_tokens_details is missing', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
|
||||
@@ -109,6 +109,7 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
reasoning_effort,
|
||||
temperature,
|
||||
text,
|
||||
prompt_cache_key,
|
||||
} = params;
|
||||
let { messages, model } = params;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -159,6 +160,8 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
// })
|
||||
|
||||
const userIdentifier = upstreamUserIdentifier(actor);
|
||||
// Cache key defaults to the actor identifier; see upstreamUserIdentifier.
|
||||
const cacheKey = prompt_cache_key ?? userIdentifier;
|
||||
|
||||
// Resolve any `puter_path` content parts into inline base64 data URLs.
|
||||
// Chat Completions doesn't support file uploads, so this is the only
|
||||
@@ -183,6 +186,7 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
const completionParams: ChatCompletionCreateParams = {
|
||||
user: userIdentifier,
|
||||
safety_identifier: userIdentifier,
|
||||
...(cacheKey !== undefined ? { prompt_cache_key: cacheKey } : {}),
|
||||
messages: messages,
|
||||
model: modelUsed.id,
|
||||
...(tools ? { tools } : {}),
|
||||
@@ -213,13 +217,26 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
|
||||
return OpenAiUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage }) => {
|
||||
const cachedTokens =
|
||||
usage.prompt_tokens_details?.cached_tokens ?? 0;
|
||||
// GPT-5.6 and later bill cache writes at 1.25x input. They're
|
||||
// reported inside `prompt_tokens`, like cached reads.
|
||||
// The SDK doesn't type `cache_write_tokens` yet.
|
||||
const cacheWriteTokens =
|
||||
(
|
||||
usage.prompt_tokens_details as
|
||||
{ cache_write_tokens?: number } | undefined
|
||||
)?.cache_write_tokens ?? 0;
|
||||
const trackedUsage = {
|
||||
prompt_tokens:
|
||||
(usage.prompt_tokens ?? 0) -
|
||||
(usage.prompt_tokens_details?.cached_tokens ?? 0),
|
||||
cachedTokens -
|
||||
cacheWriteTokens,
|
||||
completion_tokens: usage.completion_tokens ?? 0,
|
||||
cached_tokens:
|
||||
usage.prompt_tokens_details?.cached_tokens ?? 0,
|
||||
cached_tokens: cachedTokens,
|
||||
...(cacheWriteTokens
|
||||
? { cache_write_tokens: cacheWriteTokens }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const costsOverrideFromModel = buildCostsOverride(
|
||||
|
||||
@@ -48,7 +48,12 @@ import { SYSTEM_ACTOR } from '../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import {
|
||||
expectedIdentifierFields,
|
||||
makeActorMatrix,
|
||||
sentIdentifierFields,
|
||||
withTestActor,
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { OPEN_AI_MODELS } from './models.js';
|
||||
import { OpenAiResponsesChatProvider } from './OpenAiChatResponsesProvider.js';
|
||||
@@ -188,26 +193,58 @@ describe('OpenAiResponsesChatProvider model catalog', () => {
|
||||
it('models() exposes Responses-only and dual-API entries', () => {
|
||||
const { provider } = makeProvider();
|
||||
const ids = provider.models().map((m: { id: string }) => m.id);
|
||||
expect(ids).toContain('o3-pro');
|
||||
expect(ids).toContain('gpt-5.6-sol');
|
||||
expect(ids).toContain('gpt-6-astra');
|
||||
expect(ids).not.toContain('gpt-5-nano-2025-08-07');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['gpt-6-sol', '2026-04-20', 200, 20, 1000],
|
||||
['gpt-6-luna', '2026-05-18', 10, 1, 50],
|
||||
])(
|
||||
'exposes %s with current pricing and limits',
|
||||
(id, knowledge, input, cached, output) => {
|
||||
const { provider } = makeProvider();
|
||||
expect(
|
||||
provider.models().find((model) => model.id === id),
|
||||
).toMatchObject({
|
||||
puterId: `openai:openai/${id}`,
|
||||
aliases: [`openai/${id}`],
|
||||
knowledge,
|
||||
release_date: '2026-09-22',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
costs_currency: 'usd-cents',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: input,
|
||||
cached_tokens: cached,
|
||||
completion_tokens: output,
|
||||
},
|
||||
context: 1_050_000,
|
||||
max_tokens: 128_000,
|
||||
responses_api: true,
|
||||
});
|
||||
expect(provider.list()).toEqual(
|
||||
expect.arrayContaining([id, `openai/${id}`]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('models({ no_restrictions: true }) returns the entire catalog (used by complete())', () => {
|
||||
const { provider } = makeProvider();
|
||||
const ids = provider
|
||||
.models({ no_restrictions: true })
|
||||
.map((m: { id: string }) => m.id);
|
||||
// Both responses-only AND chat-only ids should be present.
|
||||
expect(ids).toContain('o3-pro');
|
||||
expect(ids).toContain('gpt-5.6-sol');
|
||||
expect(ids).toContain('gpt-5-nano-2025-08-07');
|
||||
});
|
||||
|
||||
it('list() flattens canonical ids and aliases for Responses models', () => {
|
||||
const { provider } = makeProvider();
|
||||
const ids = provider.list();
|
||||
expect(ids).toContain('o3-pro');
|
||||
expect(ids).toContain('openai/o3-pro');
|
||||
expect(ids).toContain('gpt-5.6-sol');
|
||||
expect(ids).toContain('openai/gpt-5.6-sol');
|
||||
expect(ids).toContain('gpt-6-astra');
|
||||
expect(ids).toContain('openai/gpt-6-astra');
|
||||
});
|
||||
@@ -221,7 +258,7 @@ describe('OpenAiResponsesChatProvider.complete argument validation', () => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'o3-pro',
|
||||
model: 'gpt-5.6-sol',
|
||||
messages: 'hello' as unknown as never,
|
||||
}),
|
||||
),
|
||||
@@ -246,7 +283,7 @@ describe('OpenAiResponsesChatProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'o3-pro',
|
||||
model: 'gpt-5.6-sol',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
max_tokens: 256,
|
||||
temperature: 0.4,
|
||||
@@ -254,20 +291,41 @@ describe('OpenAiResponsesChatProvider.complete request shape', () => {
|
||||
);
|
||||
|
||||
const [args] = responsesCreateMock.mock.calls[0]!;
|
||||
expect(args.model).toBe('o3-pro');
|
||||
expect(args.model).toBe('gpt-5.6-sol');
|
||||
// Responses API takes `input`, not `messages`.
|
||||
expect(args.input).toEqual([{ role: 'user', content: 'hello' }]);
|
||||
expect(args.max_output_tokens).toBe(256);
|
||||
expect(args.temperature).toBe(0.4);
|
||||
});
|
||||
|
||||
it('sends the actor uuid and effective app uid as user/safety_identifier', async () => {
|
||||
const { provider } = makeProvider();
|
||||
responsesCreateMock.mockResolvedValue(baseResponse);
|
||||
|
||||
for (const actor of makeActorMatrix()) {
|
||||
await withTestActor(
|
||||
() =>
|
||||
provider.complete({
|
||||
model: 'gpt-5.6-sol',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}),
|
||||
actor,
|
||||
);
|
||||
}
|
||||
|
||||
const fields = ['user', 'safety_identifier', 'prompt_cache_key'];
|
||||
expect(
|
||||
sentIdentifierFields(responsesCreateMock.mock.calls, fields),
|
||||
).toEqual(expectedIdentifierFields(fields));
|
||||
});
|
||||
|
||||
it('unravels function tools into the flat Responses API shape', async () => {
|
||||
const { provider } = makeProvider();
|
||||
responsesCreateMock.mockResolvedValueOnce(baseResponse);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'o3-pro',
|
||||
model: 'gpt-5.6-sol',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
tools: [
|
||||
{
|
||||
@@ -305,7 +363,7 @@ describe('OpenAiResponsesChatProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'o3-pro',
|
||||
model: 'gpt-5.6-sol',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
tool_choice: 'auto',
|
||||
parallel_tool_calls: false,
|
||||
@@ -346,11 +404,11 @@ describe('OpenAiResponsesChatProvider.complete request shape', () => {
|
||||
expect('reasoning_effort' in gpt5Args).toBe(false);
|
||||
expect('verbosity' in gpt5Args).toBe(false);
|
||||
|
||||
// o3-pro: not gpt-5 → forwards both.
|
||||
// o3: not gpt-5 → forwards both.
|
||||
responsesCreateMock.mockResolvedValueOnce(baseResponse);
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'o3-pro',
|
||||
model: 'o3',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
reasoning_effort: 'medium',
|
||||
verbosity: 'low',
|
||||
@@ -360,6 +418,58 @@ describe('OpenAiResponsesChatProvider.complete request shape', () => {
|
||||
expect(o3Args.reasoning_effort).toBe('medium');
|
||||
expect(o3Args.verbosity).toBe('low');
|
||||
});
|
||||
|
||||
it.each(['gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna'])(
|
||||
'maps flat controls to Responses options for %s aliases',
|
||||
async (model) => {
|
||||
const { provider } = makeProvider();
|
||||
responsesCreateMock.mockResolvedValueOnce(baseResponse);
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: `openai/${model}`,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
reasoning_effort: 'high',
|
||||
verbosity: 'low',
|
||||
}),
|
||||
);
|
||||
const [args] = responsesCreateMock.mock.calls[0]!;
|
||||
expect(args.model).toBe(model);
|
||||
expect(args.reasoning).toEqual({ effort: 'high' });
|
||||
expect(args.text).toEqual({ verbosity: 'low' });
|
||||
expect(args).not.toHaveProperty('reasoning_effort');
|
||||
expect(args).not.toHaveProperty('verbosity');
|
||||
expect(recordSpy.mock.calls[0]![2]).toBe(`openai:${model}`);
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves nested GPT-6 controls and gives flat options precedence', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const reasoning = { effort: 'medium', summary: 'auto' };
|
||||
const text = { verbosity: 'high', format: { type: 'text' } };
|
||||
for (const flat of [false, true]) {
|
||||
responsesCreateMock.mockResolvedValueOnce(baseResponse);
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-6-sol',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
reasoning,
|
||||
text,
|
||||
...(flat
|
||||
? { reasoning_effort: 'low', verbosity: 'low' }
|
||||
: {}),
|
||||
} as never),
|
||||
);
|
||||
const [args] = responsesCreateMock.mock.lastCall!;
|
||||
expect(args.reasoning).toEqual({
|
||||
...reasoning,
|
||||
effort: flat ? 'low' : 'medium',
|
||||
});
|
||||
expect(args.text).toEqual({
|
||||
...text,
|
||||
verbosity: flat ? 'low' : 'high',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Model resolution ────────────────────────────────────────────────
|
||||
@@ -399,17 +509,17 @@ describe('OpenAiResponsesChatProvider model resolution', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
// openai/o3-pro is an alias of o3-pro.
|
||||
model: 'openai/o3-pro',
|
||||
// openai/gpt-5.6-sol is an alias of gpt-5.6-sol.
|
||||
model: 'openai/gpt-5.6-sol',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(responsesCreateMock.mock.calls[0]![0].model).toBe('o3-pro');
|
||||
expect(responsesCreateMock.mock.calls[0]![0].model).toBe('gpt-5.6-sol');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
'openai:o3-pro',
|
||||
'openai:gpt-5.6-sol',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -455,7 +565,7 @@ describe('OpenAiResponsesChatProvider.complete non-stream output', () => {
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'o3-pro',
|
||||
model: 'gpt-5.6-sol',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
@@ -474,24 +584,107 @@ describe('OpenAiResponsesChatProvider.complete non-stream output', () => {
|
||||
cached_tokens: 10,
|
||||
});
|
||||
|
||||
// o3-pro costs: prompt=2000, completion=8000, cached=50.
|
||||
const o3pro = OPEN_AI_MODELS.find((m) => m.id === 'o3-pro')!;
|
||||
// gpt-5.6-sol costs: prompt=400, completion=2000, cached=40.
|
||||
const sol = OPEN_AI_MODELS.find((m) => m.id === 'gpt-5.6-sol')!;
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(actor).toBe(SYSTEM_ACTOR);
|
||||
expect(prefix).toBe('openai:o3-pro');
|
||||
expect(prefix).toBe('openai:gpt-5.6-sol');
|
||||
expect(usage).toEqual({
|
||||
prompt_tokens: 90,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 10,
|
||||
});
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 90 * Number(o3pro.costs.prompt_tokens),
|
||||
completion_tokens: 50 * Number(o3pro.costs.completion_tokens),
|
||||
cached_tokens: 10 * Number(o3pro.costs.cached_tokens ?? 0),
|
||||
prompt_tokens: 90 * Number(sol.costs.prompt_tokens),
|
||||
completion_tokens: 50 * Number(sol.costs.completion_tokens),
|
||||
cached_tokens: 10 * Number(sol.costs.cached_tokens ?? 0),
|
||||
});
|
||||
});
|
||||
|
||||
it('splits cache writes out of input and bills them at 1.25x input', async () => {
|
||||
const luna = OPEN_AI_MODELS.find((m) => m.id === 'gpt-6-luna')!;
|
||||
const { provider } = makeProvider();
|
||||
responsesCreateMock.mockResolvedValueOnce({
|
||||
output: [{ role: 'assistant' }],
|
||||
output_text: 'hi',
|
||||
usage: {
|
||||
input_tokens: 5000,
|
||||
output_tokens: 20,
|
||||
input_tokens_details: {
|
||||
cached_tokens: 1000,
|
||||
cache_write_tokens: 3000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-6-luna',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage).toEqual({
|
||||
prompt_tokens: 1000,
|
||||
completion_tokens: 20,
|
||||
cached_tokens: 1000,
|
||||
cache_write_tokens: 3000,
|
||||
});
|
||||
expect(luna.costs.cache_write_tokens).toBe(
|
||||
Number(luna.costs.prompt_tokens) * 1.25,
|
||||
);
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 1000 * Number(luna.costs.prompt_tokens),
|
||||
completion_tokens: 20 * Number(luna.costs.completion_tokens),
|
||||
cached_tokens: 1000 * Number(luna.costs.cached_tokens),
|
||||
cache_write_tokens: 3000 * Number(luna.costs.cache_write_tokens),
|
||||
});
|
||||
});
|
||||
|
||||
it('bills the whole request at long-context rates past 272K input tokens', async () => {
|
||||
const sol = OPEN_AI_MODELS.find((m) => m.id === 'gpt-6-sol')!;
|
||||
const { provider } = makeProvider();
|
||||
responsesCreateMock.mockResolvedValueOnce({
|
||||
output: [{ role: 'assistant' }],
|
||||
output_text: 'hi',
|
||||
usage: {
|
||||
input_tokens: 300_000,
|
||||
output_tokens: 10_000,
|
||||
input_tokens_details: {
|
||||
cached_tokens: 50_000,
|
||||
cache_write_tokens: 20_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-6-sol',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const [, , , overrides] = recordSpy.mock.calls[0]!;
|
||||
// $0.92 + $0.02 + $0.10 + $0.15 = $1.19, against $0.62 at the
|
||||
// standard rates.
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 230_000 * Number(sol.costs.prompt_tokens) * 2,
|
||||
cached_tokens: 50_000 * Number(sol.costs.cached_tokens) * 2,
|
||||
cache_write_tokens:
|
||||
20_000 * Number(sol.costs.cache_write_tokens) * 2,
|
||||
completion_tokens:
|
||||
10_000 * Number(sol.costs.completion_tokens) * 1.5,
|
||||
});
|
||||
const totalCents =
|
||||
Object.values(overrides as Record<string, number>).reduce(
|
||||
(a, b) => a + b,
|
||||
0,
|
||||
) / 1_000_000;
|
||||
expect(totalCents).toBeCloseTo(119);
|
||||
});
|
||||
|
||||
it('bills cached tokens at the input rate when the model prices no cache read', async () => {
|
||||
// gpt-5.4-pro is responses-API-only and its catalogue entry has no
|
||||
// cached_tokens rate. Cached tokens are subtracted out of the input
|
||||
@@ -549,7 +742,7 @@ describe('OpenAiResponsesChatProvider.complete non-stream output', () => {
|
||||
|
||||
const result = (await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'o3-pro',
|
||||
model: 'gpt-5.6-sol',
|
||||
messages: [{ role: 'user', content: 'do a tool call' }],
|
||||
}),
|
||||
)) as { message: { tool_calls?: unknown[] } };
|
||||
@@ -575,7 +768,7 @@ describe('OpenAiResponsesChatProvider.complete non-stream output', () => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'o3-pro',
|
||||
model: 'gpt-5.6-sol',
|
||||
messages: [{ role: 'user', content: 'silence' }],
|
||||
}),
|
||||
),
|
||||
@@ -607,7 +800,7 @@ describe('OpenAiResponsesChatProvider.complete streaming', () => {
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'o3-pro',
|
||||
model: 'gpt-5.6-sol',
|
||||
messages: [{ role: 'user', content: 'say hi' }],
|
||||
stream: true,
|
||||
}),
|
||||
@@ -633,15 +826,15 @@ describe('OpenAiResponsesChatProvider.complete streaming', () => {
|
||||
cached_tokens: 1,
|
||||
});
|
||||
|
||||
// o3-pro costs: prompt=2000, completion=8000, cached=50.
|
||||
const o3pro = OPEN_AI_MODELS.find((m) => m.id === 'o3-pro')!;
|
||||
// gpt-5.6-sol costs: prompt=400, completion=2000, cached=40.
|
||||
const sol = OPEN_AI_MODELS.find((m) => m.id === 'gpt-5.6-sol')!;
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('openai:o3-pro');
|
||||
expect(prefix).toBe('openai:gpt-5.6-sol');
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 3 * Number(o3pro.costs.prompt_tokens),
|
||||
completion_tokens: 2 * Number(o3pro.costs.completion_tokens),
|
||||
cached_tokens: 1 * Number(o3pro.costs.cached_tokens ?? 0),
|
||||
prompt_tokens: 3 * Number(sol.costs.prompt_tokens),
|
||||
completion_tokens: 2 * Number(sol.costs.completion_tokens),
|
||||
cached_tokens: 1 * Number(sol.costs.cached_tokens ?? 0),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -670,7 +863,7 @@ describe('OpenAiResponsesChatProvider.complete streaming', () => {
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'o3-pro',
|
||||
model: 'gpt-5.6-sol',
|
||||
messages: [{ role: 'user', content: 'tool call' }],
|
||||
stream: true,
|
||||
}),
|
||||
@@ -735,7 +928,7 @@ describe('OpenAiResponsesChatProvider.complete error mapping', () => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'o3-pro',
|
||||
model: 'gpt-5.6-sol',
|
||||
messages: [{ role: 'user', content: 'boom' }],
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -142,6 +142,8 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
// })
|
||||
|
||||
const userIdentifier = upstreamUserIdentifier(actor);
|
||||
// Cache key defaults to the actor identifier; see upstreamUserIdentifier.
|
||||
const cacheKey = prompt_cache_key ?? userIdentifier;
|
||||
|
||||
// Resolve any `puter_path` content parts into inline base64 data URLs
|
||||
// before the Responses API sees them.
|
||||
@@ -173,8 +175,10 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
|
||||
const requestedReasoningEffort = reasoning_effort ?? reasoning?.effort;
|
||||
const requestedVerbosity = verbosity ?? text?.verbosity;
|
||||
const isGpt6Model = modelUsed.id.startsWith('gpt-6-');
|
||||
const supportsReasoningControls =
|
||||
typeof model === 'string' && model.startsWith('gpt-5');
|
||||
isGpt6Model ||
|
||||
(typeof model === 'string' && model.startsWith('gpt-5'));
|
||||
|
||||
// Translate the neutral compaction opt-in (or pass a raw
|
||||
// `context_management` payload through) to OpenAI's Responses shape.
|
||||
@@ -204,7 +208,7 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
...(instructions !== undefined ? { instructions } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
...(prompt !== undefined ? { prompt } : {}),
|
||||
...(prompt_cache_key !== undefined ? { prompt_cache_key } : {}),
|
||||
...(cacheKey !== undefined ? { prompt_cache_key: cacheKey } : {}),
|
||||
...(prompt_cache_retention !== undefined
|
||||
? { prompt_cache_retention }
|
||||
: {}),
|
||||
@@ -230,6 +234,17 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
: {}),
|
||||
}),
|
||||
...(supportsReasoningControls && reasoning ? { reasoning } : {}),
|
||||
...(isGpt6Model && requestedReasoningEffort !== undefined
|
||||
? {
|
||||
reasoning: {
|
||||
...reasoning,
|
||||
effort: requestedReasoningEffort,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(isGpt6Model && requestedVerbosity !== undefined
|
||||
? { text: { ...text, verbosity: requestedVerbosity } }
|
||||
: {}),
|
||||
} as unknown as ResponseCreateParams;
|
||||
|
||||
// console.log("completion params: ", completionParams)
|
||||
@@ -238,14 +253,23 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
// console.log("Completion: ", completion)
|
||||
return OpenAiUtil.handle_completion_output_responses_api({
|
||||
usage_calculator: ({ usage }) => {
|
||||
const cachedTokens =
|
||||
(usage as any).input_tokens_details?.cached_tokens ?? 0;
|
||||
// GPT-5.6 and later bill cache writes at 1.25x input. They're
|
||||
// reported inside `input_tokens`, like cached reads.
|
||||
const cacheWriteTokens =
|
||||
(usage as any).input_tokens_details?.cache_write_tokens ??
|
||||
0;
|
||||
const trackedUsage = {
|
||||
prompt_tokens:
|
||||
((usage as any).input_tokens ?? 0) -
|
||||
((usage as any).input_tokens_details?.cached_tokens ??
|
||||
0),
|
||||
cachedTokens -
|
||||
cacheWriteTokens,
|
||||
completion_tokens: (usage as any).output_tokens ?? 0,
|
||||
cached_tokens:
|
||||
(usage as any).input_tokens_details?.cached_tokens ?? 0,
|
||||
cached_tokens: cachedTokens,
|
||||
...(cacheWriteTokens
|
||||
? { cache_write_tokens: cacheWriteTokens }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const costsOverrideFromModel = buildCostsOverride(
|
||||
|
||||
@@ -21,8 +21,64 @@
|
||||
|
||||
import type { IChatModel } from '../../types.js';
|
||||
|
||||
// Prompts over 272K input tokens are billed at 2x input (cached reads and
|
||||
// cache writes included) and 1.5x output for the full request.
|
||||
const GPT_LONG_CONTEXT_PRICING = {
|
||||
threshold: 272_000,
|
||||
input_multiplier: 2,
|
||||
output_multiplier: 1.5,
|
||||
};
|
||||
|
||||
// Hardcoded from https://models.dev/api.json
|
||||
export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
{
|
||||
puterId: 'openai:openai/gpt-6-sol',
|
||||
id: 'gpt-6-sol',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2026-04-20',
|
||||
release_date: '2026-09-22',
|
||||
aliases: ['openai/gpt-6-sol'],
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200,
|
||||
cached_tokens: 20,
|
||||
cache_write_tokens: 200 * 1.25,
|
||||
completion_tokens: 1000,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
context: 1_050_000,
|
||||
max_tokens: 128_000,
|
||||
responses_api: true,
|
||||
},
|
||||
{
|
||||
puterId: 'openai:openai/gpt-6-luna',
|
||||
id: 'gpt-6-luna',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2026-05-18',
|
||||
release_date: '2026-09-22',
|
||||
aliases: ['openai/gpt-6-luna'],
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 10,
|
||||
cached_tokens: 1,
|
||||
cache_write_tokens: 10 * 1.25,
|
||||
completion_tokens: 50,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
context: 1_050_000,
|
||||
max_tokens: 128_000,
|
||||
responses_api: true,
|
||||
},
|
||||
{
|
||||
puterId: 'openai:openai/gpt-6-astra',
|
||||
id: 'gpt-6-astra',
|
||||
@@ -39,8 +95,10 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 1000,
|
||||
cached_tokens: 100,
|
||||
cache_write_tokens: 1000 * 1.25,
|
||||
completion_tokens: 5000,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
context: 1_050_000,
|
||||
max_tokens: 128_000,
|
||||
responses_api: true,
|
||||
@@ -56,12 +114,15 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
// OpenAI's promotional pricing, guaranteed only through 2026-11-21.
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 500,
|
||||
cached_tokens: 50,
|
||||
completion_tokens: 3000,
|
||||
prompt_tokens: 400,
|
||||
cached_tokens: 40,
|
||||
cache_write_tokens: 400 * 1.25,
|
||||
completion_tokens: 2000,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
context: 1_050_000,
|
||||
max_tokens: 128_000,
|
||||
responses_api_only: true,
|
||||
@@ -81,8 +142,10 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200,
|
||||
cached_tokens: 20,
|
||||
cache_write_tokens: 200 * 1.25,
|
||||
completion_tokens: 1200,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
context: 1_050_000,
|
||||
max_tokens: 128_000,
|
||||
responses_api_only: true,
|
||||
@@ -102,8 +165,10 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 20,
|
||||
cached_tokens: 2,
|
||||
cache_write_tokens: 20 * 1.25,
|
||||
completion_tokens: 120,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
context: 1_050_000,
|
||||
max_tokens: 128_000,
|
||||
responses_api_only: true,
|
||||
@@ -126,6 +191,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
cached_tokens: 50,
|
||||
completion_tokens: 3000,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
context: 1_050_000,
|
||||
max_tokens: 128_000,
|
||||
},
|
||||
@@ -169,6 +235,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
cached_tokens: 25,
|
||||
completion_tokens: 1500,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
context: 1_050_000,
|
||||
max_tokens: 1_050_000,
|
||||
},
|
||||
@@ -189,6 +256,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
prompt_tokens: 3000,
|
||||
completion_tokens: 18000,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
context: 1_050_000,
|
||||
max_tokens: 128_000,
|
||||
responses_api_only: true,
|
||||
@@ -488,28 +556,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
context: 200_000,
|
||||
max_tokens: 100000,
|
||||
},
|
||||
{
|
||||
puterId: 'openai:openai/o3-pro',
|
||||
id: 'o3-pro',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2024-05',
|
||||
release_date: '2025-06-10',
|
||||
aliases: ['o3-pro-2025-06-10', 'openai/o3-pro'],
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 2000,
|
||||
cached_tokens: 50,
|
||||
completion_tokens: 8000,
|
||||
},
|
||||
context: 200_000,
|
||||
max_tokens: 100000,
|
||||
responses_api_only: true,
|
||||
},
|
||||
{
|
||||
puterId: 'openai:openai/o3-mini',
|
||||
id: 'o3-mini',
|
||||
|
||||
@@ -20,13 +20,12 @@
|
||||
/**
|
||||
* Offline unit tests for ZAIProvider.
|
||||
*
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
* redis) and constructs ZAIProvider directly against the live wired
|
||||
* `MeteringService` so the recording side is exercised end-to-end.
|
||||
* The OpenAI SDK is mocked at the module boundary — Z.AI is OpenAI-
|
||||
* compatible so the provider talks to it through the same client —
|
||||
* so the provider never reaches the network. The companion
|
||||
* integration test (ZAIProvider.integration.test.ts) exercises the
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) and
|
||||
* constructs ZAIProvider directly against the live wired `MeteringService` so
|
||||
* the recording side is exercised end-to-end. The OpenAI SDK is mocked at the
|
||||
* module boundary — Z.AI is OpenAI- compatible so the provider talks to it
|
||||
* through the same client — so the provider never reaches the network. The
|
||||
* companion integration test (ZAIProvider.integration.test.ts) exercises the
|
||||
* real Z.AI endpoint.
|
||||
*/
|
||||
|
||||
@@ -43,7 +42,6 @@ import {
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import type { Actor } from '../../../../core/actor.js';
|
||||
import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
@@ -326,7 +324,7 @@ describe('ZAIProvider.complete request shape', () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
const userActor: Actor = makeActor({
|
||||
const userActor = makeActor({
|
||||
user: { id: 42, uuid: 'u42', username: 'alice' },
|
||||
app: { id: 7, uid: 'app-uid' },
|
||||
});
|
||||
@@ -341,16 +339,42 @@ describe('ZAIProvider.complete request shape', () => {
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.user_id).toBe('puter-42-app-uid');
|
||||
expect(args.user_id).toBe('puter-u42-app-uid');
|
||||
});
|
||||
|
||||
it('prefers an explicit custom.user_id over the actor-derived one', async () => {
|
||||
it('attributes the app through effectiveApp for access-token actors', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
const userActor: Actor = {
|
||||
user: { id: 42, uuid: 'u42' },
|
||||
};
|
||||
const tokenActor = makeActor({
|
||||
user: { id: 42, uuid: 'u42', username: 'alice' },
|
||||
accessToken: {
|
||||
uid: 'tok-1',
|
||||
issuer: makeActor({
|
||||
user: { id: 42, uuid: 'u42', username: 'alice' },
|
||||
app: { id: 7, uid: 'app-uid' },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await withTestActor(
|
||||
() =>
|
||||
provider.complete({
|
||||
model: 'glm-4.6',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
tokenActor,
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.user_id).toBe('puter-u42-app-uid');
|
||||
});
|
||||
|
||||
it('ignores a caller-supplied custom.user_id', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
const userActor = makeActor({ user: { id: 42, uuid: 'u42' } });
|
||||
|
||||
await withTestActor(
|
||||
() =>
|
||||
@@ -363,10 +387,10 @@ describe('ZAIProvider.complete request shape', () => {
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.user_id).toBe('caller-supplied');
|
||||
expect(args.user_id).toBe('puter-u42');
|
||||
});
|
||||
|
||||
it('omits user_id entirely for the system actor (no user.id)', async () => {
|
||||
it('omits user_id entirely for the system actor', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
@@ -378,7 +402,7 @@ describe('ZAIProvider.complete request shape', () => {
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
// SYSTEM_ACTOR has no user.id — provider should leave the key off.
|
||||
// SYSTEM_ACTOR is excluded by isSystemActor() — the provider should leave the key off.
|
||||
expect('user_id' in args).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ import type { IChatProvider, ICompleteArguments } from '../../types.js';
|
||||
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
|
||||
import { ZAI_MODELS } from './models.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
|
||||
|
||||
// Z.AI documents `user_id` as 6-128 characters.
|
||||
const USER_ID_MAX_LENGTH = 128;
|
||||
|
||||
type ZAIConfig = {
|
||||
apiBaseUrl?: string;
|
||||
@@ -41,7 +45,6 @@ type ZAICustomParams = {
|
||||
clear_thinking?: boolean;
|
||||
};
|
||||
tool_stream?: boolean;
|
||||
user_id?: string;
|
||||
};
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> =>
|
||||
@@ -103,14 +106,8 @@ export class ZAIProvider implements IChatProvider {
|
||||
});
|
||||
|
||||
const customParams = asRecord(custom) as ZAICustomParams;
|
||||
const userId =
|
||||
customParams.user_id ??
|
||||
(actor?.user?.id
|
||||
? `puter-${actor.user.id}${actor.effectiveApp?.uid ? `-${actor.effectiveApp?.uid}` : ''}`.slice(
|
||||
0,
|
||||
128,
|
||||
)
|
||||
: undefined);
|
||||
// Puter's abuse attribution; `custom` can't override it.
|
||||
const userId = upstreamUserIdentifier(actor, USER_ID_MAX_LENGTH);
|
||||
|
||||
const completionParams: ChatCompletionCreateParams = {
|
||||
messages,
|
||||
|
||||
@@ -43,6 +43,19 @@ export interface IChatModel<T extends ModelCost = ModelCost> extends Record<
|
||||
input_cost_key?: keyof T;
|
||||
output_cost_key?: keyof T;
|
||||
costs: T;
|
||||
/** The prompt count includes cached tokens, which have their own rate. */
|
||||
promptTokensIncludeCached?: boolean;
|
||||
/**
|
||||
* A request whose input exceeds `threshold` tokens is billed at raised
|
||||
* rates for the whole request, not only the tokens past the threshold:
|
||||
* every input-side rate (uncached, cached, cache writes) is multiplied by
|
||||
* `input_multiplier` and every output-side rate by `output_multiplier`.
|
||||
*/
|
||||
long_context_pricing?: {
|
||||
threshold: number;
|
||||
input_multiplier: number;
|
||||
output_multiplier: number;
|
||||
};
|
||||
context?: number;
|
||||
max_tokens: number;
|
||||
subscriberOnly?: boolean;
|
||||
|
||||
@@ -19,7 +19,12 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { IChatModel } from '../types.js';
|
||||
import { buildCostsOverride, isFreeModel, usdPerMToken } from './pricing.js';
|
||||
import {
|
||||
buildCostsOverride,
|
||||
isFreeModel,
|
||||
longContextMultipliers,
|
||||
usdPerMToken,
|
||||
} from './pricing.js';
|
||||
|
||||
const model = (costs: Record<string, number>): IChatModel =>
|
||||
({
|
||||
@@ -166,3 +171,91 @@ describe('buildCostsOverride', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('long-context pricing', () => {
|
||||
const longContext = (costs: Record<string, number>): IChatModel => ({
|
||||
...model(costs),
|
||||
long_context_pricing: {
|
||||
threshold: 272_000,
|
||||
input_multiplier: 2,
|
||||
output_multiplier: 1.5,
|
||||
},
|
||||
});
|
||||
const rates = {
|
||||
prompt_tokens: 200,
|
||||
cached_tokens: 20,
|
||||
cache_write_tokens: 250,
|
||||
completion_tokens: 1000,
|
||||
};
|
||||
|
||||
it('bills a request at or under the threshold at standard rates', () => {
|
||||
const overrides = buildCostsOverride(
|
||||
{
|
||||
prompt_tokens: 222_000,
|
||||
cached_tokens: 50_000,
|
||||
completion_tokens: 10,
|
||||
},
|
||||
longContext(rates),
|
||||
);
|
||||
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 222_000 * 200,
|
||||
cached_tokens: 50_000 * 20,
|
||||
completion_tokens: 10 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('raises every rate for the whole request once input passes the threshold', () => {
|
||||
// 230K uncached + 50K cached + 20K cache writes = 300K input. No
|
||||
// single key crosses 272K; their sum does.
|
||||
const overrides = buildCostsOverride(
|
||||
{
|
||||
prompt_tokens: 230_000,
|
||||
cached_tokens: 50_000,
|
||||
cache_write_tokens: 20_000,
|
||||
completion_tokens: 10_000,
|
||||
},
|
||||
longContext(rates),
|
||||
);
|
||||
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 230_000 * 200 * 2,
|
||||
cached_tokens: 50_000 * 20 * 2,
|
||||
cache_write_tokens: 20_000 * 250 * 2,
|
||||
completion_tokens: 10_000 * 1000 * 1.5,
|
||||
});
|
||||
});
|
||||
|
||||
it('raises the fallback rate of an unpriced key too', () => {
|
||||
const overrides = buildCostsOverride(
|
||||
{ prompt_tokens: 300_000, thinking_tokens: 10 },
|
||||
longContext({ prompt_tokens: 200, completion_tokens: 1000 }),
|
||||
);
|
||||
|
||||
expect(overrides.thinking_tokens).toBe(10 * 1000 * 1.5);
|
||||
});
|
||||
|
||||
it('leaves a model without long-context pricing at standard rates', () => {
|
||||
const overrides = buildCostsOverride(
|
||||
{ prompt_tokens: 900_000, completion_tokens: 10 },
|
||||
model(rates),
|
||||
);
|
||||
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 900_000 * 200,
|
||||
completion_tokens: 10 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('applies the multipliers strictly above the threshold', () => {
|
||||
const m = longContext(rates);
|
||||
expect(longContextMultipliers(m, 272_000)).toEqual({
|
||||
input: 1,
|
||||
output: 1,
|
||||
});
|
||||
expect(longContextMultipliers(m, 272_001)).toEqual({
|
||||
input: 2,
|
||||
output: 1.5,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,56 @@ export const costKeys = (
|
||||
outputKey: (model.output_cost_key as string | undefined) ?? 'output_tokens',
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether a usage key is priced at the output rate when the model has no rate
|
||||
* of its own for it.
|
||||
*/
|
||||
export const isOutputCostKey = (key: string, outputKey: string): boolean =>
|
||||
key === outputKey ||
|
||||
key === 'output_tokens' ||
|
||||
key === 'completion_tokens' ||
|
||||
key === 'thinking_tokens';
|
||||
|
||||
/**
|
||||
* The rate multipliers a request pays given how many input tokens it sent —
|
||||
* cached reads and cache writes included. `1`/`1` unless the model has
|
||||
* long-context pricing and the request is past its threshold.
|
||||
*/
|
||||
export const longContextMultipliers = (
|
||||
model: IChatModel,
|
||||
inputTokens: number,
|
||||
): { input: number; output: number } => {
|
||||
const pricing = model.long_context_pricing;
|
||||
if (!pricing || !(inputTokens > pricing.threshold)) {
|
||||
return { input: 1, output: 1 };
|
||||
}
|
||||
return {
|
||||
input: pricing.input_multiplier,
|
||||
output: pricing.output_multiplier,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The input tokens a tracked-usage object carries: every key that isn't
|
||||
* output-side. Providers split one prompt into uncached, cached-read and
|
||||
* cache-write keys; the long-context threshold is measured on their sum.
|
||||
*/
|
||||
export const trackedInputTokens = (
|
||||
trackedUsage: Record<string, unknown>,
|
||||
model: IChatModel,
|
||||
): number => {
|
||||
const { outputKey } = costKeys(model);
|
||||
let total = 0;
|
||||
for (const [key, amount] of Object.entries(trackedUsage)) {
|
||||
if (key === 'tokens' || key === 'usd_cents') continue;
|
||||
if (isOutputCostKey(key, outputKey)) continue;
|
||||
if (typeof amount === 'number' && Number.isFinite(amount)) {
|
||||
total += amount;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a model costs the user nothing to run.
|
||||
*
|
||||
@@ -90,11 +140,10 @@ export const buildCostsOverride = (
|
||||
const inputRate = isRate(costs[inputKey]) ? costs[inputKey] : undefined;
|
||||
const outputRate = isRate(costs[outputKey]) ? costs[outputKey] : undefined;
|
||||
|
||||
const isOutputKey = (key: string) =>
|
||||
key === outputKey ||
|
||||
key === 'output_tokens' ||
|
||||
key === 'completion_tokens' ||
|
||||
key === 'thinking_tokens';
|
||||
const multipliers = longContextMultipliers(
|
||||
model,
|
||||
trackedInputTokens(trackedUsage, model),
|
||||
);
|
||||
|
||||
const overrides: Record<string, number> = {};
|
||||
for (const [key, amount] of Object.entries(trackedUsage)) {
|
||||
@@ -102,11 +151,13 @@ export const buildCostsOverride = (
|
||||
// not a per-unit rate.
|
||||
if (key === 'tokens') continue;
|
||||
|
||||
const isOutput = isOutputCostKey(key, outputKey);
|
||||
const rate = isRate(costs[key])
|
||||
? costs[key]
|
||||
: ((isOutputKey(key) ? outputRate : inputRate) ?? 0);
|
||||
: ((isOutput ? outputRate : inputRate) ?? 0);
|
||||
|
||||
overrides[key] = amount * rate;
|
||||
overrides[key] =
|
||||
amount * rate * (isOutput ? multipliers.output : multipliers.input);
|
||||
}
|
||||
|
||||
return overrides;
|
||||
|
||||
@@ -20,12 +20,12 @@
|
||||
/**
|
||||
* Offline unit tests for ImageGenerationDriver.
|
||||
*
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
* redis) with API keys for every image provider so the driver
|
||||
* registers and indexes them all. Then drives `server.drivers.aiImage`
|
||||
* directly. Provider SDKs are mocked at the module boundary so the
|
||||
* driver routes are exercised without real network egress. Aligns
|
||||
* with AGENTS.md: "Prefer test server over mocking deps."
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) with
|
||||
* API keys for every image provider so the driver registers and indexes them
|
||||
* all. Then drives `server.drivers.aiImage` directly. Provider SDKs are mocked
|
||||
* at the module boundary so the driver routes are exercised without real
|
||||
* network egress. Aligns with AGENTS.md: "Prefer test server over mocking
|
||||
* deps."
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -50,7 +50,8 @@ import { OPEN_AI_IMAGE_GENERATION_MODELS } from './providers/openai/models.js';
|
||||
import { REPLICATE_IMAGE_GENERATION_MODELS } from './providers/replicate/models.js';
|
||||
import { TOGETHER_IMAGE_GENERATION_MODELS } from './providers/together/models.js';
|
||||
import { XAI_IMAGE_GENERATION_MODELS } from './providers/xai/models.js';
|
||||
import type { ImageGenerationDriver } from './ImageGenerationDriver.js';
|
||||
import { ImageGenerationDriver } from './ImageGenerationDriver.js';
|
||||
import { ReplicateImageGenerationProvider } from './providers/replicate/ReplicateImageGenerationProvider.js';
|
||||
|
||||
// ── SDK mocks ──────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -119,15 +120,15 @@ vi.mock('together-ai', () => {
|
||||
return { Together, default: Together };
|
||||
});
|
||||
|
||||
const { replicateRunMock } = vi.hoisted(() => ({
|
||||
replicateRunMock: vi.fn(),
|
||||
const { createPredictionMock } = vi.hoisted(() => ({
|
||||
createPredictionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('replicate', () => {
|
||||
const Replicate = vi.fn().mockImplementation(function (
|
||||
this: Record<string, unknown>,
|
||||
) {
|
||||
this.run = replicateRunMock;
|
||||
this.predictions = { create: createPredictionMock, cancel: vi.fn() };
|
||||
});
|
||||
return { default: Replicate };
|
||||
});
|
||||
@@ -173,7 +174,7 @@ beforeEach(() => {
|
||||
googleAIGenerateContentMock.mockReset();
|
||||
googleAIGenerateImagesMock.mockReset();
|
||||
togetherImagesGenerateMock.mockReset();
|
||||
replicateRunMock.mockReset();
|
||||
createPredictionMock.mockReset();
|
||||
secureFetchMock.mockReset();
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
|
||||
eventEmitSpy = vi.spyOn(server.clients.event, 'emit') as MockInstance<
|
||||
@@ -199,7 +200,7 @@ describe('ImageGenerationDriver.generate authentication', () => {
|
||||
it('throws 401 when no actor is on the request context', async () => {
|
||||
await expect(
|
||||
driver.generate({
|
||||
model: 'gpt-image-1-mini',
|
||||
model: 'gpt-image-2',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
} as never),
|
||||
@@ -254,8 +255,8 @@ describe('ImageGenerationDriver model catalog', () => {
|
||||
const all = await driver.models();
|
||||
// Every catalog id from at least one provider must be reachable.
|
||||
const ids = all.map((m) => m.id);
|
||||
// OpenAI catalog: gpt-image-1-mini should be present (lowercased by buildModelMap).
|
||||
expect(ids).toContain('gpt-image-1-mini');
|
||||
// OpenAI catalog: gpt-image-2 should be present.
|
||||
expect(ids).toContain('gpt-image-2');
|
||||
// xAI catalog: grok-imagine-image should be present.
|
||||
expect(ids).toContain('grok-imagine-image');
|
||||
});
|
||||
@@ -272,16 +273,16 @@ describe('ImageGenerationDriver model catalog', () => {
|
||||
costValue: number;
|
||||
source: string;
|
||||
}>;
|
||||
// gpt-image-1-mini has a low:1024x1024 cost line — must surface in reportedCosts.
|
||||
// gpt-image-2 has a low:1024x1024 cost line — must surface in reportedCosts.
|
||||
const gptLine = reported.find(
|
||||
(r) =>
|
||||
r.usageType ===
|
||||
'openai-image-generation:gpt-image-1-mini:low:1024x1024',
|
||||
'openai-image-generation:gpt-image-2:low:1024x1024',
|
||||
);
|
||||
expect(gptLine).toBeDefined();
|
||||
expect(gptLine?.costValue).toBe(
|
||||
OPEN_AI_IMAGE_GENERATION_MODELS.find(
|
||||
(m) => m.id === 'gpt-image-1-mini',
|
||||
(m) => m.id === 'gpt-image-2',
|
||||
)!.costs['low:1024x1024'],
|
||||
);
|
||||
expect(gptLine?.source).toBe('driver:aiImage/openai-image-generation');
|
||||
@@ -290,15 +291,388 @@ describe('ImageGenerationDriver model catalog', () => {
|
||||
|
||||
// ── Provider routing ────────────────────────────────────────────────
|
||||
|
||||
describe('image model data policy', () => {
|
||||
it('omits excluded routes from discovery and reported costs', async () => {
|
||||
expect((await driver.models()).some((model) => model.excludedForDataPolicy)).toBe(false);
|
||||
expect((await driver.list()).some((id) => id.startsWith('togetherai:'))).toBe(false);
|
||||
expect(driver.getReportedCosts().some((cost) =>
|
||||
String(cost.usageType).startsWith('together-image-generation:'),
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(TOGETHER_IMAGE_GENERATION_MODELS)(
|
||||
'blocks the excluded route $id and its aliases before calling the provider',
|
||||
async (model) => {
|
||||
for (const id of [model.id, ...(model.aliases ?? [])]) {
|
||||
await expect(withActor(() => driver.generate({
|
||||
provider: 'together', model: id, prompt: 'hi', test_mode: true,
|
||||
}))).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('required third-party data sharing'),
|
||||
});
|
||||
}
|
||||
expect(togetherImagesGenerateMock).not.toHaveBeenCalled();
|
||||
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
|
||||
expect(createPredictionMock).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['together', ' TOGETHER-IMAGE-GENERATION '])(
|
||||
'rejects the excluded default for provider %s', async (provider) => {
|
||||
await expect(withActor(() => driver.generate({
|
||||
provider, prompt: 'hi', test_mode: true,
|
||||
}))).rejects.toThrow('required third-party data sharing');
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an excluded default selected through the legacy driver name', async () => {
|
||||
await expect(withDriverName('together-image-generation', () =>
|
||||
driver.generate({ prompt: 'hi', test_mode: true }),
|
||||
)).rejects.toThrow('required third-party data sharing');
|
||||
});
|
||||
|
||||
it('keeps a shared alias available through an allowed route', async () => {
|
||||
openaiImagesGenerateMock.mockResolvedValueOnce({ data: [{ url: 'https://oai/img.png' }] });
|
||||
await withActor(() => driver.generate({ model: 'gpt-image-2', prompt: 'hi' }));
|
||||
expect(openaiImagesGenerateMock).toHaveBeenCalledOnce();
|
||||
expect(togetherImagesGenerateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips an excluded provider when choosing the deployment default', async () => {
|
||||
const limitedDriver = new ImageGenerationDriver({
|
||||
providers: {
|
||||
'together-image-generation': { apiKey: 'tg-key' },
|
||||
'cloudflare-image-generation': { apiToken: 'cf-token', accountId: 'acct' },
|
||||
},
|
||||
} as never, server.clients, server.stores, server.services);
|
||||
await limitedDriver.onServerStart();
|
||||
await withActor(() => limitedDriver.generate({ prompt: 'hi', test_mode: true }));
|
||||
expect(eventEmitSpy).toHaveBeenCalledWith('ai.log.image', expect.objectContaining({
|
||||
service_used: 'cloudflare-image-generation',
|
||||
}), {});
|
||||
});
|
||||
|
||||
it('also blocks routes that train on customer content', async () => {
|
||||
const fixture = {
|
||||
...OPEN_AI_IMAGE_GENERATION_MODELS[0],
|
||||
id: 'training-fixture',
|
||||
aliases: ['training-fixture-alias'],
|
||||
excludedForDataPolicy: 'training' as const,
|
||||
};
|
||||
OPEN_AI_IMAGE_GENERATION_MODELS.push(fixture);
|
||||
try {
|
||||
const isolatedDriver = new ImageGenerationDriver(
|
||||
{ providers: { 'openai-image-generation': { apiKey: 'oai-key' } } } as never,
|
||||
server.clients, server.stores, server.services,
|
||||
);
|
||||
await isolatedDriver.onServerStart();
|
||||
await expect(withActor(() => isolatedDriver.generate({
|
||||
model: 'training-fixture-alias', prompt: 'hi', test_mode: true,
|
||||
}))).rejects.toThrow('training on customer content');
|
||||
expect(await isolatedDriver.list()).not.toContain('training-fixture');
|
||||
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
OPEN_AI_IMAGE_GENERATION_MODELS.pop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImageGenerationDriver.generate provider routing', () => {
|
||||
it('routes a known gpt-image-1-mini model id to the OpenAI image provider', async () => {
|
||||
it('uses the default provider when called through the main driver', async () => {
|
||||
openaiImagesGenerateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://oai/default.png' }],
|
||||
});
|
||||
await withDriverName('ai-image', () =>
|
||||
driver.generate({ prompt: 'a landscape' }),
|
||||
);
|
||||
expect(openaiImagesGenerateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ model: 'gpt-image-2' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an excluded model regardless of caller casing', async () => {
|
||||
await expect(withActor(() =>
|
||||
driver.generate({
|
||||
model: 'togetherai:qwen/qwen-image',
|
||||
prompt: 'a landscape',
|
||||
}),
|
||||
)).rejects.toThrow('required third-party data sharing');
|
||||
expect(togetherImagesGenerateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['openai', 'gemini', 'cloudflare', 'xai', 'replicate'])(
|
||||
'resolves the short provider name %s with its default model',
|
||||
async (provider) => {
|
||||
await withDriverName('ai-image', () =>
|
||||
driver.generate({
|
||||
provider,
|
||||
prompt: 'a landscape',
|
||||
test_mode: true,
|
||||
}),
|
||||
);
|
||||
expect(eventEmitSpy).toHaveBeenCalledWith(
|
||||
'ai.log.image',
|
||||
expect.objectContaining({
|
||||
service_used: `${provider}-image-generation`,
|
||||
}),
|
||||
{},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps legacy driver hints as preferences for a known model', async () => {
|
||||
openaiImagesGenerateMock.mockResolvedValue({
|
||||
data: [{ url: 'https://xai/image.png' }],
|
||||
});
|
||||
await withDriverName('openai-image-generation', () =>
|
||||
driver.generate({ model: 'grok-imagine-image', prompt: 'hi' }),
|
||||
);
|
||||
expect(openaiImagesGenerateMock.mock.calls[0][0].model).toBe(
|
||||
'grok-imagine-image',
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['AI-Image', ' ai-image '])(
|
||||
'normalizes the generic provider hint %s',
|
||||
async (provider) => {
|
||||
await withActor(() =>
|
||||
driver.generate({ provider, prompt: 'hi', test_mode: true }),
|
||||
);
|
||||
expect(eventEmitSpy).toHaveBeenCalledWith(
|
||||
'ai.log.image',
|
||||
expect.objectContaining({
|
||||
service_used: 'openai-image-generation',
|
||||
}),
|
||||
{},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('falls back to a known model when the provider preference does not offer it', async () => {
|
||||
await withActor(() =>
|
||||
driver.generate({
|
||||
provider: 'gemini',
|
||||
model: 'gpt-image-2',
|
||||
prompt: 'hi',
|
||||
test_mode: true,
|
||||
}),
|
||||
);
|
||||
expect(eventEmitSpy).toHaveBeenCalledWith(
|
||||
'ai.log.image',
|
||||
expect.objectContaining({
|
||||
service_used: 'openai-image-generation',
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'black-forest-labs/FLUX.1-schnell',
|
||||
'FLUX.1-schnell',
|
||||
'togetherai:black-forest-labs/FLUX.1-schnell',
|
||||
])(
|
||||
'does not redirect the retired Together alias %s to another provider',
|
||||
async (model) => {
|
||||
await expect(
|
||||
withActor(() =>
|
||||
driver.generate({ model, prompt: 'hi', test_mode: true }),
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'no longer available through together-image-generation',
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('does not let a provider hint resurrect a retired alias', async () => {
|
||||
await expect(
|
||||
withActor(() =>
|
||||
driver.generate({
|
||||
provider: 'cloudflare',
|
||||
model: 'black-forest-labs/FLUX.1-schnell',
|
||||
prompt: 'hi',
|
||||
test_mode: true,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow('no longer available through together-image-generation');
|
||||
expect(eventEmitSpy).not.toHaveBeenCalledWith(
|
||||
'ai.log.image',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not redirect unavailable Replicate Phoenix callers to another provider', async () => {
|
||||
await expect(withActor(() => driver.generate({
|
||||
model: 'phoenix-1.0', provider: 'cloudflare', prompt: 'hi', test_mode: true,
|
||||
}))).rejects.toThrow('no longer available through replicate-image-generation');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['gpt-image-1', 'gpt-image-1-mini', 'GPT-Image-1.5'])(
|
||||
'keeps the deprecated OpenAI id %s routable until its shutdown date',
|
||||
async (model) => {
|
||||
openaiImagesGenerateMock.mockResolvedValueOnce({ data: [{ url: 'https://oai/img.png' }] });
|
||||
await withActor(() => driver.generate({ model, prompt: 'hi' }));
|
||||
expect(openaiImagesGenerateMock).toHaveBeenCalledOnce();
|
||||
expect(openaiImagesGenerateMock.mock.calls[0]![0].model).toBe(model.toLowerCase());
|
||||
},
|
||||
);
|
||||
|
||||
it('hides delisted models from discovery but still reports their costs', async () => {
|
||||
const listed = await driver.list();
|
||||
const ids = (await driver.models()).map((m) => m.id);
|
||||
for (const id of ['gpt-image-1', 'gpt-image-1-mini', 'gpt-image-1.5']) {
|
||||
expect(ids).not.toContain(id);
|
||||
expect(listed).not.toContain(`openai:openai/${id}`);
|
||||
}
|
||||
expect(driver.getReportedCosts().some((cost) =>
|
||||
String(cost.usageType).startsWith('openai-image-generation:gpt-image-1-mini:'),
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports an id Puter never offered as not found', async () => {
|
||||
await expect(withActor(() => driver.generate({
|
||||
model: 'chatgpt-image-latest', prompt: 'hi', test_mode: true,
|
||||
}))).rejects.toThrow('Model not found: chatgpt-image-latest');
|
||||
});
|
||||
|
||||
it.each(['openai/gpt-image-2', 'openai/gpt-image-1.5'])(
|
||||
'routes the vendor-prefixed id %s to OpenAI unless Replicate is asked for',
|
||||
async (model) => {
|
||||
eventEmitSpy.mockClear();
|
||||
await withActor(() => driver.generate({ model, prompt: 'hi', test_mode: true }));
|
||||
expect(eventEmitSpy).toHaveBeenCalledWith('ai.log.image', expect.objectContaining({
|
||||
service_used: 'openai-image-generation',
|
||||
}), {});
|
||||
for (const pinned of [{ model, provider: 'replicate' }, { model: `replicate:${model}` }]) {
|
||||
eventEmitSpy.mockClear();
|
||||
await withActor(() => driver.generate({ ...pinned, prompt: 'hi', test_mode: true }));
|
||||
expect(eventEmitSpy, JSON.stringify(pinned)).toHaveBeenCalledWith('ai.log.image', expect.objectContaining({
|
||||
service_used: 'replicate-image-generation',
|
||||
}), {});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('explains why an unavailable Replicate model is refused', async () => {
|
||||
await expect(withActor(() => driver.generate({
|
||||
model: 'quiverai/arrow-1.1', prompt: 'hi', test_mode: true,
|
||||
}))).rejects.toThrow('Replicate generation fails even with the minimal input schema.');
|
||||
});
|
||||
|
||||
it('names a retired provider default instead of "undefined"', async () => {
|
||||
const schnell = REPLICATE_IMAGE_GENERATION_MODELS.find((m) => m.id === 'black-forest-labs/flux-schnell')!;
|
||||
REPLICATE_IMAGE_GENERATION_MODELS.push({ ...schnell, unavailableReason: 'fixture outage.' });
|
||||
const defaultSpy = vi.spyOn(ReplicateImageGenerationProvider.prototype, 'getDefaultModel')
|
||||
.mockReturnValue('Black-Forest-Labs/FLUX-Schnell');
|
||||
try {
|
||||
const isolatedDriver = new ImageGenerationDriver(
|
||||
{ providers: { 'replicate-image-generation': { apiKey: 'rp-key' } } } as never,
|
||||
server.clients, server.stores, server.services,
|
||||
);
|
||||
await isolatedDriver.onServerStart();
|
||||
const error = await withActor(() => isolatedDriver.generate({
|
||||
provider: 'replicate', prompt: 'hi', test_mode: true,
|
||||
})).then(() => null, (e: Error) => e);
|
||||
expect(error?.message).toMatch(/flux-schnell is no longer available through replicate-image-generation; fixture outage\./i);
|
||||
expect(error?.message).not.toContain('undefined');
|
||||
} finally {
|
||||
defaultSpy.mockRestore();
|
||||
REPLICATE_IMAGE_GENERATION_MODELS.pop();
|
||||
}
|
||||
});
|
||||
|
||||
it('omits the aliases key from catalog entries that declare none', async () => {
|
||||
const entries = await driver.models();
|
||||
const withoutAliases = entries.filter((m) => !Object.hasOwn(m, 'aliases'));
|
||||
expect(withoutAliases.length).toBeGreaterThan(0);
|
||||
for (const entry of entries) {
|
||||
if (Object.hasOwn(entry, 'aliases')) expect(Array.isArray(entry.aliases)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps Cloudflare Schnell reachable through its own id and puterId', async () => {
|
||||
for (const model of [
|
||||
'@cf/black-forest-labs/flux-1-schnell',
|
||||
'workers-ai:black-forest-labs/flux.1-schnell',
|
||||
]) {
|
||||
eventEmitSpy.mockClear();
|
||||
await withActor(() =>
|
||||
driver.generate({ model, prompt: 'hi', test_mode: true }),
|
||||
);
|
||||
expect(eventEmitSpy, model).toHaveBeenCalledWith(
|
||||
'ai.log.image',
|
||||
expect.objectContaining({
|
||||
service_used: 'cloudflare-image-generation',
|
||||
}),
|
||||
{},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('never lists a retired alias on any provider entry', async () => {
|
||||
for (const model of await driver.models()) {
|
||||
for (const alias of model.aliases ?? []) {
|
||||
expect(alias.toLowerCase()).not.toBe(
|
||||
'black-forest-labs/flux.1-schnell',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps canonical and provider-prefixed model IDs independent from shared aliases', async () => {
|
||||
const catalogs = {
|
||||
'openai-image-generation': OPEN_AI_IMAGE_GENERATION_MODELS,
|
||||
'gemini-image-generation': GEMINI_IMAGE_GENERATION_MODELS,
|
||||
'together-image-generation': TOGETHER_IMAGE_GENERATION_MODELS.filter((model) => !model.excludedForDataPolicy),
|
||||
'cloudflare-image-generation': CLOUDFLARE_IMAGE_GENERATION_MODELS,
|
||||
'xai-image-generation': XAI_IMAGE_GENERATION_MODELS,
|
||||
'replicate-image-generation': REPLICATE_IMAGE_GENERATION_MODELS.filter((model) => !model.unavailableReason),
|
||||
};
|
||||
// A vendor's own spelling (`openai/x`) beats a reseller's exact id, so
|
||||
// Replicate's `openai/*` entries route to OpenAI without a hint.
|
||||
const firstParty = new Map(OPEN_AI_IMAGE_GENERATION_MODELS.map((model) => [
|
||||
model.puterId!.replace(/^[^:/]+:/, '').toLowerCase(),
|
||||
{ model_used: model.id, service_used: 'openai-image-generation' },
|
||||
]));
|
||||
for (const [provider, models] of Object.entries(catalogs)) {
|
||||
for (const model of models) {
|
||||
for (const id of new Set(
|
||||
[model.id, model.puterId].filter(Boolean),
|
||||
)) {
|
||||
eventEmitSpy.mockClear();
|
||||
await withDriverName('ai-image', () =>
|
||||
driver.generate({
|
||||
model: id,
|
||||
prompt: 'a landscape',
|
||||
test_mode: true,
|
||||
}),
|
||||
);
|
||||
expect(eventEmitSpy, id).toHaveBeenCalledWith(
|
||||
'ai.log.image',
|
||||
expect.objectContaining(
|
||||
firstParty.get(id.toLowerCase()) ?? {
|
||||
model_used: model.id,
|
||||
service_used: provider,
|
||||
},
|
||||
),
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('routes a known gpt-image-2 model id to the OpenAI image provider', async () => {
|
||||
openaiImagesGenerateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://oai/img.png' }],
|
||||
});
|
||||
|
||||
const result = await withActor(() =>
|
||||
driver.generate({
|
||||
model: 'gpt-image-1-mini',
|
||||
model: 'gpt-image-2',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
} as never),
|
||||
@@ -308,7 +682,7 @@ describe('ImageGenerationDriver.generate provider routing', () => {
|
||||
expect(openaiImagesGenerateMock).toHaveBeenCalledTimes(1);
|
||||
// Other provider mocks must NOT have been touched.
|
||||
expect(togetherImagesGenerateMock).not.toHaveBeenCalled();
|
||||
expect(replicateRunMock).not.toHaveBeenCalled();
|
||||
expect(createPredictionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes a known grok-imagine-image id to the xAI image provider (also OpenAI-SDK shaped)', async () => {
|
||||
@@ -329,14 +703,14 @@ describe('ImageGenerationDriver.generate provider routing', () => {
|
||||
expect(sent.prompt).toBe('hi');
|
||||
});
|
||||
|
||||
it('lowercases model lookups so case variants resolve (GPT-Image-1-Mini → gpt-image-1-mini)', async () => {
|
||||
it('lowercases model lookups so case variants resolve (GPT-Image-2 → gpt-image-2)', async () => {
|
||||
openaiImagesGenerateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://oai/img.png' }],
|
||||
});
|
||||
|
||||
await withActor(() =>
|
||||
driver.generate({
|
||||
model: 'GPT-Image-1-Mini',
|
||||
model: 'GPT-Image-2',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
} as never),
|
||||
@@ -347,7 +721,11 @@ describe('ImageGenerationDriver.generate provider routing', () => {
|
||||
|
||||
it('falls through to the requested provider via Context.driverName when args.provider is not supplied', async () => {
|
||||
// We're not setting args.provider; Context.driverName takes its place.
|
||||
replicateRunMock.mockResolvedValueOnce(['https://rp/img.png']);
|
||||
createPredictionMock.mockResolvedValueOnce({
|
||||
id: 'prediction-1',
|
||||
status: 'succeeded',
|
||||
output: ['https://rp/img.png'],
|
||||
});
|
||||
|
||||
// Replicate's flux-schnell is the only registered model under id
|
||||
// `black-forest-labs/flux-schnell` matched solely by Replicate's catalog.
|
||||
@@ -359,7 +737,7 @@ describe('ImageGenerationDriver.generate provider routing', () => {
|
||||
);
|
||||
|
||||
expect(result).toBe('https://rp/img.png');
|
||||
expect(replicateRunMock).toHaveBeenCalledTimes(1);
|
||||
expect(createPredictionMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -373,7 +751,7 @@ describe('ImageGenerationDriver.generate ratio normalization', () => {
|
||||
|
||||
await withActor(() =>
|
||||
driver.generate({
|
||||
model: 'gpt-image-1-mini',
|
||||
model: 'gpt-image-2',
|
||||
prompt: 'hi',
|
||||
width: 1024,
|
||||
height: 1536,
|
||||
@@ -386,27 +764,24 @@ describe('ImageGenerationDriver.generate ratio normalization', () => {
|
||||
});
|
||||
|
||||
it('parses aspect_ratio "w:h" into ratio when width/height are absent', async () => {
|
||||
// Use a per-tier Together model that consults `ratio` directly.
|
||||
// The id is shared with Gemini via an alias collision (`gemini-3-pro-image`),
|
||||
// so disambiguate explicitly with `args.provider`.
|
||||
togetherImagesGenerateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://tg/img.png' }],
|
||||
googleAIGenerateContentMock.mockResolvedValueOnce({
|
||||
candidates: [{ content: { parts: [{ inlineData: {
|
||||
data: 'aW1n', mimeType: 'image/png',
|
||||
} }] } }],
|
||||
});
|
||||
|
||||
await withActor(() =>
|
||||
driver.generate({
|
||||
provider: 'together-image-generation',
|
||||
model: 'togetherai:google/gemini-3-pro-image',
|
||||
provider: 'gemini',
|
||||
model: 'gemini-3-pro-image',
|
||||
prompt: 'hi',
|
||||
aspect_ratio: '16:9',
|
||||
quality: '1K',
|
||||
} as never),
|
||||
);
|
||||
|
||||
// Together's resolution_map for 16:9 + 1K is 1376×768.
|
||||
const sent = togetherImagesGenerateMock.mock.calls[0]![0];
|
||||
expect(sent.width).toBe(1376);
|
||||
expect(sent.height).toBe(768);
|
||||
const sent = googleAIGenerateContentMock.mock.calls[0]![0];
|
||||
expect(sent.config.imageConfig.aspectRatio).toBe('16:9');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -420,7 +795,7 @@ describe('ImageGenerationDriver.generate audit log', () => {
|
||||
|
||||
await withActor(() =>
|
||||
driver.generate({
|
||||
model: 'gpt-image-1-mini',
|
||||
model: 'gpt-image-2',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
} as never),
|
||||
@@ -432,7 +807,7 @@ describe('ImageGenerationDriver.generate audit log', () => {
|
||||
expect(aiLogCall).toBeDefined();
|
||||
const [, payload] = aiLogCall!;
|
||||
const p = payload as Record<string, unknown>;
|
||||
expect(p.model_used).toBe('gpt-image-1-mini');
|
||||
expect(p.model_used).toBe('gpt-image-2');
|
||||
expect(p.service_used).toBe('openai-image-generation');
|
||||
// completionId is a fresh uuid-style string per call.
|
||||
expect(typeof p.completionId).toBe('string');
|
||||
@@ -446,7 +821,7 @@ describe('ImageGenerationDriver.generate audit log', () => {
|
||||
await expect(
|
||||
withActor(() =>
|
||||
driver.generate({
|
||||
model: 'gpt-image-1-mini',
|
||||
model: 'gpt-image-2',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
} as never),
|
||||
@@ -475,7 +850,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
await expect(
|
||||
withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'gpt-image-1-mini',
|
||||
model: 'gpt-image-2',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/',
|
||||
} as never),
|
||||
@@ -489,7 +864,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
await expect(
|
||||
withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'gpt-image-1-mini',
|
||||
model: 'gpt-image-2',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/image.png',
|
||||
} as never),
|
||||
@@ -506,7 +881,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
await expect(
|
||||
withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'gpt-image-1-mini',
|
||||
model: 'gpt-image-2',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/testuser/somedir/image.png',
|
||||
} as never),
|
||||
@@ -531,7 +906,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
await expect(
|
||||
withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'gpt-image-1-mini',
|
||||
model: 'gpt-image-2',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/testuser/dir/img.png',
|
||||
} as never),
|
||||
@@ -560,7 +935,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
|
||||
await withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'gpt-image-1-mini',
|
||||
model: 'gpt-image-2',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '~/images/out.png',
|
||||
} as never),
|
||||
@@ -592,7 +967,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
|
||||
const result = await withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'gpt-image-1-mini',
|
||||
model: 'gpt-image-2',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/testuser/photos/out.png',
|
||||
} as never),
|
||||
@@ -642,7 +1017,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
|
||||
await withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'gpt-image-1-mini',
|
||||
model: 'gpt-image-2',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/testuser/dir/img.png',
|
||||
} as never),
|
||||
@@ -660,7 +1035,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
Promise.resolve(
|
||||
runWithContext({ actor: noIdActor }, () =>
|
||||
driver.generate({
|
||||
model: 'gpt-image-1-mini',
|
||||
model: 'gpt-image-2',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/noone/dir/img.png',
|
||||
} as never),
|
||||
@@ -671,3 +1046,188 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('image dimension parsing at the driver boundary', () => {
|
||||
it.each([
|
||||
{ w: 2048, h: 1024, kind: 'pixels' },
|
||||
{ w: 'invalid', h: 1024, kind: 'pixels' },
|
||||
])('ignores caller-supplied internal imageSize: %j', async (imageSize) => {
|
||||
openaiImagesGenerateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://oai/img.png' }],
|
||||
});
|
||||
const args = {
|
||||
model: 'gpt-image-2',
|
||||
prompt: 'hi',
|
||||
imageSize,
|
||||
};
|
||||
const snapshot = structuredClone(args);
|
||||
await withActor(() => driver.generate(args as never));
|
||||
expect(openaiImagesGenerateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ size: '1024x1024' }),
|
||||
);
|
||||
expect(args).toEqual(snapshot);
|
||||
const logged = eventEmitSpy.mock.calls.find(
|
||||
(call) => call[0] === 'ai.log.image',
|
||||
)![1] as { parameters: Record<string, unknown> };
|
||||
expect(logged.parameters).not.toHaveProperty('imageSize');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ratio: { w: 'invalid', h: 100 } },
|
||||
{ ratio: { w: 0, h: 1 } },
|
||||
{ quality: 42 },
|
||||
{ resolution: [] },
|
||||
{ width: 1024 },
|
||||
])('rejects invalid options before a provider request: %j', (options) =>
|
||||
expect(
|
||||
withActor(() =>
|
||||
driver.generate({
|
||||
model: 'grok-imagine-image',
|
||||
prompt: 'hi',
|
||||
...options,
|
||||
} as never),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['__proto__', 'constructor'])(
|
||||
'rejects inherited object keys as unknown models: %s',
|
||||
async (model) => {
|
||||
await expect(
|
||||
withActor(() =>
|
||||
driver.generate({ provider: 'openai', model, prompt: 'hi' }),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
message: `Model not found: ${model}`,
|
||||
});
|
||||
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an unavailable provider without a model before dispatch', async () => {
|
||||
await expect(
|
||||
withActor(() =>
|
||||
driver.generate({ provider: 'byteplus', prompt: 'hi' }),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
message: 'Image provider not available: byteplus',
|
||||
});
|
||||
expect(eventEmitSpy).not.toHaveBeenCalledWith(
|
||||
'ai.log.image',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('only advertises aliases that route to that entry without a provider hint', async () => {
|
||||
for (const model of await driver.models()) {
|
||||
for (const alias of model.aliases ?? []) {
|
||||
eventEmitSpy.mockClear();
|
||||
await expect(
|
||||
withActor(() =>
|
||||
driver.generate({
|
||||
model: alias,
|
||||
prompt: 'hi',
|
||||
test_mode: true,
|
||||
}),
|
||||
),
|
||||
).resolves.toBe(
|
||||
'https://puter-sample-data.puter.site/image_example.png',
|
||||
);
|
||||
expect(eventEmitSpy, `${model.provider} ${alias}`).toHaveBeenCalledWith(
|
||||
'ai.log.image',
|
||||
expect.objectContaining({
|
||||
model_used: model.id,
|
||||
service_used: model.provider,
|
||||
}),
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('lists a shared alias only on the provider that wins it', async () => {
|
||||
const owners = (await driver.models())
|
||||
.filter((model) => model.aliases?.includes('leonardo/lucid-origin'))
|
||||
.map((model) => model.provider);
|
||||
expect(owners).toEqual(['cloudflare-image-generation']);
|
||||
});
|
||||
|
||||
it('rejects an excluded provider alias instead of falling back to another provider', async () => {
|
||||
await expect(withActor(() =>
|
||||
driver.generate({
|
||||
provider: 'together',
|
||||
model: 'gpt-image-2',
|
||||
prompt: 'hi',
|
||||
}),
|
||||
)).rejects.toThrow('required third-party data sharing');
|
||||
expect(togetherImagesGenerateMock).not.toHaveBeenCalled();
|
||||
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ width: null, height: null },
|
||||
{ aspect_ratio: null },
|
||||
{ ratio: null, width: null, height: null, aspect_ratio: '' },
|
||||
])('treats null dimension fields %j as absent', async (dimensions) => {
|
||||
await expect(
|
||||
withActor(() =>
|
||||
driver.generate({
|
||||
model: 'gpt-image-2',
|
||||
prompt: 'hi',
|
||||
test_mode: true,
|
||||
...dimensions,
|
||||
} as never),
|
||||
),
|
||||
).resolves.toBe('https://puter-sample-data.puter.site/image_example.png');
|
||||
});
|
||||
|
||||
it('leaves the caller args untouched and logs the normalized request', async () => {
|
||||
const args = {
|
||||
model: 'gpt-image-2',
|
||||
prompt: 'hi',
|
||||
width: 1024,
|
||||
height: 768,
|
||||
test_mode: true,
|
||||
};
|
||||
const snapshot = structuredClone(args);
|
||||
await withActor(() => driver.generate(args as never));
|
||||
expect(args).toEqual(snapshot);
|
||||
expect(eventEmitSpy).toHaveBeenCalledWith(
|
||||
'ai.log.image',
|
||||
expect.objectContaining({
|
||||
parameters: expect.objectContaining({
|
||||
model: 'gpt-image-2',
|
||||
provider: 'openai-image-generation',
|
||||
imageSize: { w: 1024, h: 768, kind: 'pixels' },
|
||||
ratio: { w: 1024, h: 768 },
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
);
|
||||
const logged = eventEmitSpy.mock.calls.find(
|
||||
(call) => call[0] === 'ai.log.image',
|
||||
)![1] as { parameters: Record<string, unknown> };
|
||||
expect(logged.parameters).not.toHaveProperty('width');
|
||||
expect(logged.parameters).not.toHaveProperty('height');
|
||||
});
|
||||
|
||||
it.each([undefined, null, '', ' ', 42, {}])(
|
||||
'rejects an invalid prompt %j before routing',
|
||||
async (prompt) => {
|
||||
await expect(
|
||||
withActor(() =>
|
||||
driver.generate({ prompt, model: 'no-such-model' } as never),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
message: '`prompt` must be a non-empty string',
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -17,6 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { assertImagePrompt } from './imageValidation.js';
|
||||
import crypto from 'node:crypto';
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import { assertNormalized } from '../../services/fs/resolveNode.js';
|
||||
@@ -33,6 +34,7 @@ import { CloudflareImageProvider } from './providers/cloudflare/CloudflareImageP
|
||||
import { GeminiImageProvider } from './providers/gemini/GeminiImageProvider.js';
|
||||
import { OpenAiImageProvider } from './providers/openai/OpenAiImageProvider.js';
|
||||
import { ReplicateImageGenerationProvider } from './providers/replicate/ReplicateImageGenerationProvider.js';
|
||||
import { resolveImageSize } from './imageDimensions.js';
|
||||
import { TogetherImageProvider } from './providers/together/TogetherImageProvider.js';
|
||||
import { XAIImageProvider } from './providers/xai/XAIImageProvider.js';
|
||||
import type { IGenerateParams, IImageModel, IImageProvider } from './types.js';
|
||||
@@ -71,17 +73,22 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
readonly rateLimit = AI_RATE_LIMIT;
|
||||
readonly concurrent = AI_CONCURRENT;
|
||||
|
||||
#providers: Record<string, IImageProvider> = {};
|
||||
#modelIdMap: Record<string, IImageModel[]> = {};
|
||||
#providers: Record<string, IImageProvider> = Object.create(null);
|
||||
#modelIdMap: Record<string, IImageModel[]> = Object.create(null);
|
||||
#excludedModelIdMap: Record<string, IImageModel[]> = Object.create(null);
|
||||
#retiredModelAliases = new Map<
|
||||
string,
|
||||
{ provider: string; reason?: string }
|
||||
>();
|
||||
|
||||
/** Metering scoped to this driver. Lazy: services wire up after drivers. */
|
||||
get #aiMetering(): MeteringService {
|
||||
return this.services.metering.withAiCostFactor(this.driverName);
|
||||
}
|
||||
|
||||
override onServerStart() {
|
||||
override async onServerStart() {
|
||||
this.#registerProviders();
|
||||
this.#buildModelMap();
|
||||
await this.#buildModelMap();
|
||||
}
|
||||
|
||||
async models() {
|
||||
@@ -89,10 +96,27 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
return Object.values(this.#modelIdMap)
|
||||
.flat()
|
||||
.filter((m) => {
|
||||
if (seen.has(m.id)) return false;
|
||||
seen.add(m.id);
|
||||
if (m.delisted) return false;
|
||||
const key = `${m.provider}:${m.id}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.map((model) => {
|
||||
if (!model.aliases) return model;
|
||||
// Advertise only aliases that reach this entry without a
|
||||
// provider hint; an alias another provider wins would route a
|
||||
// caller who picked it from this entry somewhere else.
|
||||
const aliases = model.aliases.filter((alias) => {
|
||||
const resolved = this.#resolveModel(alias);
|
||||
return (
|
||||
resolved != null &&
|
||||
resolved.provider === model.provider &&
|
||||
resolved.id === model.id
|
||||
);
|
||||
});
|
||||
return { ...model, aliases };
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.provider === b.provider) return a.id.localeCompare(b.id);
|
||||
return (a.provider ?? '').localeCompare(b.provider ?? '');
|
||||
@@ -137,10 +161,20 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
// Every provider reads these off `args`, and several index into them
|
||||
// directly rather than through the shared helpers, so the shape is
|
||||
// settled here — once — before any provider runs.
|
||||
assertImagePrompt(args.prompt);
|
||||
assertInputImagesShape(args, 'image generation');
|
||||
for (const option of ['quality', 'resolution'] as const) {
|
||||
if (args[option] != null && typeof args[option] !== 'string') {
|
||||
throw new HttpError(400, `${option} must be a string`, {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const puterOutputPath = args.puter_output_path;
|
||||
delete args.puter_output_path;
|
||||
// Providers and lifecycle listeners each get their own view: the
|
||||
// caller's `args` is re-emitted by reference in `.after`/`.error`
|
||||
// payloads, so it is never mutated here.
|
||||
const { puter_output_path: puterOutputPath, ...request } = args;
|
||||
|
||||
// Validate the output path early — before spending credits.
|
||||
let resolvedOutputPath: string | undefined;
|
||||
@@ -165,22 +199,85 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
typeof args.model === 'string'
|
||||
? args.model.trim().toLowerCase()
|
||||
: undefined;
|
||||
const providerHint = args.provider ?? Context.get('driverName');
|
||||
let intendedProvider =
|
||||
args.provider ?? (Context.get('driverName') as string | undefined);
|
||||
typeof providerHint === 'string'
|
||||
? providerHint.trim().toLowerCase()
|
||||
: undefined;
|
||||
if (intendedProvider === this.driverName) intendedProvider = undefined;
|
||||
if (
|
||||
intendedProvider &&
|
||||
!intendedProvider.endsWith('-image-generation')
|
||||
) {
|
||||
intendedProvider += '-image-generation';
|
||||
}
|
||||
if (
|
||||
!modelId &&
|
||||
intendedProvider &&
|
||||
!this.#providers[intendedProvider]
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Image provider not available: ${providerHint}`,
|
||||
{
|
||||
legacyCode: 'bad_request',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Default: first registered provider's default model if none given
|
||||
// Pick the first provider whose default model is available.
|
||||
if (!modelId && !intendedProvider) {
|
||||
intendedProvider = Object.keys(this.#providers)[0];
|
||||
intendedProvider = Object.keys(this.#providers).find((name) => {
|
||||
const defaultModel = this.#providers[name].getDefaultModel();
|
||||
return (
|
||||
this.#resolveModel(defaultModel, name)?.provider === name
|
||||
);
|
||||
});
|
||||
}
|
||||
if (!modelId && intendedProvider) {
|
||||
modelId = this.#providers[intendedProvider]?.getDefaultModel();
|
||||
modelId = this.#providers[intendedProvider]
|
||||
?.getDefaultModel()
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
if (!modelId)
|
||||
throw new HttpError(400, 'Missing `model`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
const excludedModels =
|
||||
this.#excludedModelIdMap[modelId.trim().toLowerCase()] ?? [];
|
||||
const dataPolicyError = (excluded: IImageModel) =>
|
||||
new HttpError(
|
||||
400,
|
||||
`Image model excluded by data policy: ${excluded.id} (${excluded.excludedForDataPolicy === 'training' ? 'training on customer content' : 'required third-party data sharing'}).`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
// A hinted provider answers for its own excluded route, even when
|
||||
// another provider retired the same bare name.
|
||||
const hintedExcludedModel = excludedModels.find(
|
||||
(entry) => entry.provider === intendedProvider,
|
||||
);
|
||||
if (hintedExcludedModel) throw dataPolicyError(hintedExcludedModel);
|
||||
|
||||
// Retired aliases are never registered in the model map, so this gate
|
||||
// mostly turns "Model not found" into a message that names the
|
||||
// provider; it also keeps a retired name from reaching a provider
|
||||
// whose exact id happens to spell the same thing.
|
||||
const retired = this.#retiredModelAliases.get(modelId);
|
||||
if (retired) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`${args.model ?? modelId} is no longer available through ${retired.provider}; ${retired.reason ?? 'choose an available model.'}`,
|
||||
{
|
||||
legacyCode: 'bad_request',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const model = this.#resolveModel(modelId, intendedProvider);
|
||||
if (!model && excludedModels[0])
|
||||
throw dataPolicyError(excludedModels[0]);
|
||||
if (!model) {
|
||||
throw new HttpError(400, `Model not found: ${args.model}`, {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -196,19 +293,33 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
);
|
||||
}
|
||||
|
||||
// `width`/`height` or `aspect_ratio` -> `ratio: {w,h}`
|
||||
this.#normalizeRatio(args);
|
||||
// Every caller-facing size field collapses into `imageSize`; the
|
||||
// `ratio` mirror carries plain dimensions for providers that need no
|
||||
// aspect-versus-pixels intent.
|
||||
const imageSize = resolveImageSize(request, model);
|
||||
delete request.width;
|
||||
delete request.height;
|
||||
delete request.aspect_ratio;
|
||||
delete request.ratio;
|
||||
delete request.imageSize;
|
||||
if (imageSize) {
|
||||
request.imageSize = imageSize;
|
||||
request.ratio = { w: imageSize.w, h: imageSize.h };
|
||||
}
|
||||
request.model = model.id;
|
||||
request.provider = model.provider;
|
||||
|
||||
// Audit log for abuse / billing. Fired before the upstream call
|
||||
// so a failed generate still shows up in the log (prompt_block
|
||||
// uses this to track user-by-user image prompts).
|
||||
// uses this to track user-by-user image prompts). Logs the
|
||||
// normalized request so the row records the resolved model and size.
|
||||
const completionId = crypto.randomUUID();
|
||||
this.clients.event.emit(
|
||||
'ai.log.image',
|
||||
{
|
||||
actor,
|
||||
completionId,
|
||||
parameters: args,
|
||||
parameters: request,
|
||||
intended_service: model.id,
|
||||
model_used: model.id,
|
||||
service_used: model.provider!,
|
||||
@@ -216,11 +327,7 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
{},
|
||||
);
|
||||
|
||||
const result = await provider.generate({
|
||||
...args,
|
||||
model: model.id,
|
||||
provider: model.provider,
|
||||
});
|
||||
const result = await provider.generate(request);
|
||||
|
||||
if (resolvedOutputPath) {
|
||||
await this.#saveToFS(actor, result, resolvedOutputPath);
|
||||
@@ -229,34 +336,6 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
return result;
|
||||
}
|
||||
|
||||
#normalizeRatio(parameters: IGenerateParams) {
|
||||
if (parameters.ratio) return;
|
||||
|
||||
const w = parameters.width as number | undefined;
|
||||
const h = parameters.height as number | undefined;
|
||||
if (typeof w === 'number' && typeof h === 'number') {
|
||||
parameters.ratio = { w, h };
|
||||
delete parameters.width;
|
||||
delete parameters.height;
|
||||
return;
|
||||
}
|
||||
|
||||
const ar = parameters.aspect_ratio as string | undefined;
|
||||
if (typeof ar === 'string' && ar.includes(':')) {
|
||||
const [aw, ah] = ar.split(':').map(Number);
|
||||
if (
|
||||
Number.isFinite(aw) &&
|
||||
Number.isFinite(ah) &&
|
||||
aw > 0 &&
|
||||
ah > 0
|
||||
) {
|
||||
parameters.ratio = { w: aw, h: ah };
|
||||
delete parameters.aspect_ratio;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#registerProviders() {
|
||||
const providers = this.config.providers ?? {};
|
||||
const m = this.#aiMetering;
|
||||
@@ -305,8 +384,7 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
const cloudflare = (providers['cloudflare-image-generation'] ??
|
||||
providers['cloudflare-workers-ai-image'] ??
|
||||
providers['cloudflare-workers-ai']) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
const cfToken =
|
||||
(cloudflare?.apiToken as string | undefined) ??
|
||||
(cloudflare?.apiKey as string | undefined) ??
|
||||
@@ -321,8 +399,7 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
apiToken: cfToken,
|
||||
accountId: cfAccount,
|
||||
apiBaseUrl: cloudflare?.apiBaseUrl as
|
||||
| string
|
||||
| undefined,
|
||||
string | undefined,
|
||||
},
|
||||
m,
|
||||
);
|
||||
@@ -354,11 +431,9 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
// pair its missing apiBaseUrl with the shared block's key (or vice
|
||||
// versa) and point a region-scoped key at the wrong endpoint.
|
||||
const byteplusImageCfg = providers['byteplus-image-generation'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
const byteplusSharedCfg = providers['byteplus'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
const byteplusKey = readKey(byteplusImageCfg, byteplusSharedCfg);
|
||||
if (byteplusKey) {
|
||||
this.#providers['byteplus-image-generation'] =
|
||||
@@ -367,8 +442,7 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
apiKey: byteplusKey,
|
||||
apiBaseUrl: (byteplusImageCfg?.apiBaseUrl ??
|
||||
byteplusSharedCfg?.apiBaseUrl) as
|
||||
| string
|
||||
| undefined,
|
||||
string | undefined,
|
||||
},
|
||||
m,
|
||||
);
|
||||
@@ -376,47 +450,66 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
async #buildModelMap() {
|
||||
for (const providerName in this.#providers) {
|
||||
const provider = this.#providers[providerName];
|
||||
for (const entry of await provider.models()) {
|
||||
// Catalogs are module-level constants that providers hand
|
||||
// back by reference, so they are read and never written:
|
||||
// normalizing the id or appending puterId in place would
|
||||
// accumulate across map builds. Work on a copy instead.
|
||||
const model = { ...entry };
|
||||
model.id = model.id.trim().toLowerCase();
|
||||
if (!this.#modelIdMap[model.id]) {
|
||||
this.#modelIdMap[model.id] = [];
|
||||
}
|
||||
this.#modelIdMap[model.id].push({
|
||||
...model,
|
||||
this.#modelIdMap = Object.create(null);
|
||||
this.#excludedModelIdMap = Object.create(null);
|
||||
this.#retiredModelAliases.clear();
|
||||
const models: IImageModel[] = [];
|
||||
for (const [providerName, provider] of Object.entries(
|
||||
this.#providers,
|
||||
)) {
|
||||
for (const alias of provider.retiredModelAliases ?? []) {
|
||||
this.#retiredModelAliases.set(alias.trim().toLowerCase(), {
|
||||
provider: providerName,
|
||||
reason: provider.retiredModelReasons?.[alias],
|
||||
});
|
||||
}
|
||||
for (const entry of await provider.models()) {
|
||||
models.push({ ...entry, provider: providerName });
|
||||
}
|
||||
}
|
||||
|
||||
if (model.puterId) {
|
||||
model.aliases = model.aliases
|
||||
? [...model.aliases, model.puterId]
|
||||
: [model.puterId];
|
||||
}
|
||||
if (model.aliases) {
|
||||
for (let alias of model.aliases) {
|
||||
alias = alias.trim().toLowerCase();
|
||||
if (!this.#modelIdMap[alias]) {
|
||||
this.#modelIdMap[alias] =
|
||||
this.#modelIdMap[model.id];
|
||||
} else if (
|
||||
this.#modelIdMap[alias] !==
|
||||
this.#modelIdMap[model.id]
|
||||
) {
|
||||
this.#modelIdMap[alias].push({
|
||||
...model,
|
||||
provider: providerName,
|
||||
});
|
||||
this.#modelIdMap[model.id] =
|
||||
this.#modelIdMap[alias];
|
||||
}
|
||||
}
|
||||
}
|
||||
const register = (id: string, model: IImageModel) => {
|
||||
const key = id.trim().toLowerCase();
|
||||
const modelMap = model.excludedForDataPolicy
|
||||
? this.#excludedModelIdMap
|
||||
: this.#modelIdMap;
|
||||
const bucket = (modelMap[key] ??= []);
|
||||
if (
|
||||
!bucket.some(
|
||||
(entry) =>
|
||||
entry.id === model.id &&
|
||||
entry.provider === model.provider,
|
||||
)
|
||||
) {
|
||||
bucket.push(model);
|
||||
}
|
||||
};
|
||||
// Exact IDs take precedence over another provider's shorthand aliases.
|
||||
// A puterId minus its provider prefix (`openai:openai/x` → `openai/x`)
|
||||
// is that provider's own spelling as well, so a reseller's exact id
|
||||
// never outranks it; ties keep provider registration order.
|
||||
const retired = (name: string) =>
|
||||
this.#retiredModelAliases.has(name.trim().toLowerCase());
|
||||
for (const model of models) {
|
||||
register(model.id, model);
|
||||
if (!model.puterId) continue;
|
||||
register(model.puterId, model);
|
||||
const ownSpelling = model.puterId.replace(/^[^:/]+:/, '');
|
||||
if (model.excludedForDataPolicy || !retired(ownSpelling))
|
||||
register(ownSpelling, model);
|
||||
}
|
||||
for (const model of models) {
|
||||
for (const alias of model.aliases ?? []) {
|
||||
// A retired name never routes anywhere, whichever catalog
|
||||
// still spells it, so the gate in generate() cannot depend on
|
||||
// which providers a deployment configures. Excluded routes
|
||||
// never route either, so they keep the alias for their error.
|
||||
if (
|
||||
!model.excludedForDataPolicy &&
|
||||
this.#retiredModelAliases.has(alias.trim().toLowerCase())
|
||||
)
|
||||
continue;
|
||||
register(alias, model);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -526,7 +619,7 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
#resolveModel(modelId: string, provider?: string): IImageModel | null {
|
||||
const models = this.#modelIdMap[modelId];
|
||||
const models = this.#modelIdMap[modelId.trim().toLowerCase()];
|
||||
if (!models || models.length === 0) return null;
|
||||
if (!provider) return models[0];
|
||||
return models.find((m) => m.provider === provider) ?? models[0];
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isAspectRatio,
|
||||
expandAspectRatio,
|
||||
resolveImageSize,
|
||||
closestAspectRatio,
|
||||
formatAspectRatio,
|
||||
} from './imageDimensions.js';
|
||||
|
||||
describe('isAspectRatio', () => {
|
||||
it.each([
|
||||
[16, 9, true],
|
||||
[32, 18, true],
|
||||
[64, 36, false],
|
||||
[1024, 1024, false],
|
||||
[0, 1, false],
|
||||
[1, -1, false],
|
||||
])('classifies %s by %s', (w, h, expected) => {
|
||||
expect(isAspectRatio({ w: Number(w), h: Number(h) })).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveImageSize', () => {
|
||||
it.each([
|
||||
[{ ratio: { w: '16', h: '9' } }, { w: 16, h: 9, kind: 'aspect' }],
|
||||
[
|
||||
{ width: '4096', height: '3072' },
|
||||
{ w: 4096, h: 3072, kind: 'pixels' },
|
||||
],
|
||||
[
|
||||
{ width: ' 512 ', height: ' 512 ' },
|
||||
{ w: 512, h: 512, kind: 'pixels' },
|
||||
],
|
||||
[{ aspect_ratio: '170:100' }, { w: 170, h: 100, kind: 'aspect' }],
|
||||
[
|
||||
{ ratio: { w: 4, h: 3 }, width: 1024, height: 1024 },
|
||||
{ w: 4, h: 3, kind: 'aspect' },
|
||||
],
|
||||
[
|
||||
{ width: 1024, height: 768, aspect_ratio: '1:1' },
|
||||
{ w: 1024, h: 768, kind: 'pixels' },
|
||||
],
|
||||
])('preserves dimension intent for %j', (params, expected) => {
|
||||
expect(resolveImageSize({ prompt: 'hi', ...params } as never)).toEqual(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
it('retains the BytePlus legacy pixel threshold', () => {
|
||||
expect(
|
||||
resolveImageSize(
|
||||
{ prompt: 'hi', width: 512, height: 512 },
|
||||
{ pixelSizeThreshold: 921_600 },
|
||||
),
|
||||
).toEqual({ w: 512, h: 512, kind: 'aspect' });
|
||||
});
|
||||
it('does not manufacture a size when none is requested', () => {
|
||||
expect(resolveImageSize({ prompt: 'hi' })).toBeUndefined();
|
||||
});
|
||||
it.each([
|
||||
{ ratio: null },
|
||||
{ width: null, height: null },
|
||||
{ aspect_ratio: null },
|
||||
{ aspect_ratio: '' },
|
||||
{ aspect_ratio: ' ' },
|
||||
{ ratio: null, width: null, height: null, aspect_ratio: null },
|
||||
])('treats null and blank dimension fields %j as absent', (params) => {
|
||||
expect(
|
||||
resolveImageSize({ prompt: 'hi', ...params } as never),
|
||||
).toBeUndefined();
|
||||
});
|
||||
it('names the missing partner when only one pixel dimension is set', () => {
|
||||
expect(() =>
|
||||
resolveImageSize({ prompt: 'hi', width: 1024 } as never),
|
||||
).toThrow(/width and height must be set together/);
|
||||
});
|
||||
it('still rejects a lone dimension when its partner is null', () => {
|
||||
expect(() =>
|
||||
resolveImageSize({ prompt: 'hi', width: 1024, height: null } as never),
|
||||
).toThrow(expect.objectContaining({ statusCode: 400 }));
|
||||
});
|
||||
it.each([
|
||||
{ ratio: '16:9' },
|
||||
{ ratio: {} },
|
||||
{ ratio: { w: 0, h: 10 } },
|
||||
{ ratio: { w: Infinity, h: 1 } },
|
||||
{ ratio: { w: true, h: 1 } },
|
||||
{ width: 'invalid', height: 1 },
|
||||
{ width: 1 },
|
||||
{ aspect_ratio: '1:2:3' },
|
||||
{ aspect_ratio: 42 },
|
||||
{ aspect_ratio: '-1:2' },
|
||||
{ width: '0x10', height: '0x10' },
|
||||
{ ratio: { w: '1e3', h: '1e3' } },
|
||||
])('rejects malformed dimensions %j', (params) => {
|
||||
expect(() =>
|
||||
resolveImageSize({ prompt: 'hi', ...params } as never),
|
||||
).toThrow(expect.objectContaining({ statusCode: 400 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('aspect helpers', () => {
|
||||
it('chooses the closest supported shape symmetrically', () => {
|
||||
const shapes = [
|
||||
{ w: 16, h: 9 },
|
||||
{ w: 9, h: 16 },
|
||||
{ w: 1, h: 1 },
|
||||
];
|
||||
expect(closestAspectRatio({ w: 17, h: 10 }, shapes)).toEqual(shapes[0]);
|
||||
expect(closestAspectRatio({ w: 10, h: 17 }, shapes)).toEqual(shapes[1]);
|
||||
});
|
||||
it.each([
|
||||
[1920, 1080, '16:9'],
|
||||
[16.5, 9.5, '33:19'],
|
||||
[1.1, 1, '11:10'],
|
||||
[0.1, 0.3, '1:3'],
|
||||
[1024, 768, '4:3'],
|
||||
[0, 1, undefined],
|
||||
])('formats %s by %s', (w, h, expected) => {
|
||||
expect(formatAspectRatio({ w: Number(w), h: Number(h) })).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
it('omits a missing ratio', () =>
|
||||
expect(formatAspectRatio()).toBeUndefined());
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ w: 4, h: 1 },
|
||||
{ w: 1e-200, h: 1e-200 },
|
||||
])('expands an aspect hint without overflowing: %j', (ratio) => {
|
||||
const result = expandAspectRatio(ratio);
|
||||
expect(result.w / result.h).toBeCloseTo(ratio.w / ratio.h);
|
||||
expect(result.w * result.h).toBeCloseTo(1024 * 1024);
|
||||
});
|
||||
|
||||
it('rejects an aspect ratio whose quotient overflows', () => {
|
||||
expect(() =>
|
||||
resolveImageSize({ prompt: 'hi', ratio: { w: 16, h: 1e-320 } }),
|
||||
).toThrow(expect.objectContaining({ statusCode: 400 }));
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type {
|
||||
IGenerateParams,
|
||||
IImageModel,
|
||||
ImageDimensions,
|
||||
ImageSize,
|
||||
} from './types.js';
|
||||
|
||||
// Decimal-only, so `'0x10'`, `'1e3'` and similar number-literal coercions
|
||||
// don't sneak into what is meant to be a plain image dimension.
|
||||
const DECIMAL_RE = /^\d+(\.\d+)?$/;
|
||||
|
||||
function dimension(value: unknown): number {
|
||||
const number =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string' && DECIMAL_RE.test(value.trim())
|
||||
? Number(value)
|
||||
: NaN;
|
||||
if (
|
||||
!Number.isFinite(number) ||
|
||||
number <= 0 ||
|
||||
number > Number.MAX_SAFE_INTEGER
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Image dimensions must be positive, finite numbers',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Null and blank strings mean "not set": form state and spread option objects
|
||||
* carry them, and JSON preserves them.
|
||||
*/
|
||||
function present<T>(value: T): value is NonNullable<T> {
|
||||
return value != null && (typeof value !== 'string' || value.trim() !== '');
|
||||
}
|
||||
|
||||
export function isAspectRatio(ratio: ImageDimensions): boolean {
|
||||
return ratio.w > 0 && ratio.h > 0 && Math.max(ratio.w, ratio.h) <= 32;
|
||||
}
|
||||
|
||||
/** Preserve dimension-source information before adapting to a model's sizes. */
|
||||
export function resolveImageSize(
|
||||
params: IGenerateParams,
|
||||
model?: Pick<IImageModel, 'pixelSizeThreshold'>,
|
||||
): ImageSize | undefined {
|
||||
let pair: { w?: unknown; h?: unknown };
|
||||
let explicitAspect = false;
|
||||
if (present(params.ratio)) {
|
||||
if (typeof params.ratio !== 'object' || Array.isArray(params.ratio)) {
|
||||
throw new HttpError(400, 'ratio must contain w and h', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
pair = params.ratio;
|
||||
} else if (present(params.width) || present(params.height)) {
|
||||
if (!present(params.width) || !present(params.height)) {
|
||||
throw new HttpError(400, 'width and height must be set together', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
pair = { w: params.width, h: params.height };
|
||||
} else if (present(params.aspect_ratio)) {
|
||||
if (
|
||||
typeof params.aspect_ratio !== 'string' ||
|
||||
params.aspect_ratio.split(':').length !== 2
|
||||
) {
|
||||
throw new HttpError(400, 'aspect_ratio must use w:h format', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const [w, h] = params.aspect_ratio.split(':');
|
||||
pair = { w, h };
|
||||
explicitAspect = true;
|
||||
} else return undefined;
|
||||
const ratio = { w: dimension(pair.w), h: dimension(pair.h) };
|
||||
const quotient = ratio.w / ratio.h;
|
||||
if (!Number.isFinite(quotient) || quotient <= 0) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Image aspect ratio is outside the supported numeric range',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
const aspect =
|
||||
explicitAspect ||
|
||||
(model?.pixelSizeThreshold
|
||||
? ratio.w * ratio.h < model.pixelSizeThreshold
|
||||
: isAspectRatio(ratio));
|
||||
return { ...ratio, kind: aspect ? 'aspect' : 'pixels' };
|
||||
}
|
||||
|
||||
export function closestAspectRatio<T extends ImageDimensions>(
|
||||
ratio: ImageDimensions,
|
||||
candidates: T[],
|
||||
): T {
|
||||
const target = ratio.w / ratio.h;
|
||||
return candidates.reduce((best, candidate) =>
|
||||
Math.abs(Math.log(candidate.w / candidate.h / target)) <
|
||||
Math.abs(Math.log(best.w / best.h / target))
|
||||
? candidate
|
||||
: best,
|
||||
);
|
||||
}
|
||||
|
||||
const isWhole = (value: number) => Math.abs(value - Math.round(value)) < 1e-6;
|
||||
|
||||
export function formatAspectRatio(ratio?: ImageDimensions): string | undefined {
|
||||
if (
|
||||
!ratio ||
|
||||
!Number.isFinite(ratio.w) ||
|
||||
!Number.isFinite(ratio.h) ||
|
||||
ratio.w <= 0 ||
|
||||
ratio.h <= 0
|
||||
)
|
||||
return undefined;
|
||||
// Euclid needs integers: `%` on doubles turns 1.1:1 into sixteen-digit
|
||||
// terms, so scale decimal pairs up first (16.5:9.5 → 165:95).
|
||||
let scale = 1;
|
||||
while (
|
||||
scale < 1e6 &&
|
||||
!(isWhole(ratio.w * scale) && isWhole(ratio.h * scale))
|
||||
)
|
||||
scale *= 10;
|
||||
const w = Math.round(ratio.w * scale);
|
||||
const h = Math.round(ratio.h * scale);
|
||||
if (!w || !h) return undefined;
|
||||
let a = w;
|
||||
let b = h;
|
||||
while (b !== 0) [a, b] = [b, a % b];
|
||||
return `${w / a}:${h / a}`;
|
||||
}
|
||||
|
||||
/** Expand a shape without multiplying tiny dimension pairs first. */
|
||||
export function expandAspectRatio(
|
||||
ratio: ImageDimensions,
|
||||
pixels = 1024 * 1024,
|
||||
): ImageDimensions {
|
||||
const side = Math.sqrt(pixels);
|
||||
const aspect = Math.sqrt(ratio.w / ratio.h);
|
||||
return { w: side * aspect, h: side / aspect };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { imageDataUri } from './imageOutput.js';
|
||||
|
||||
describe('imageDataUri', () => {
|
||||
it.each([
|
||||
['jpeg', Buffer.from([0xff, 0xd8, 0xff, 0xe0])],
|
||||
['png', Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])],
|
||||
['webp', Buffer.from('RIFF0000WEBP')],
|
||||
])('labels %s bytes without changing the payload', (format, bytes) => {
|
||||
const base64 = Buffer.from(bytes).toString('base64');
|
||||
expect(imageDataUri(base64)).toBe(
|
||||
`data:image/${format};base64,${base64}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the model fallback for an unknown signature', () => {
|
||||
expect(imageDataUri('AAAA', 'image/jpeg')).toBe(
|
||||
'data:image/jpeg;base64,AAAA',
|
||||
);
|
||||
});
|
||||
|
||||
it('labels svg when the root sits beyond the first bytes', () => {
|
||||
// `<?xml ...?>` pushes `<svg` past fixed-offset magic-number range, so
|
||||
// the sniff has to scan the full 8 KB window rather than the head only.
|
||||
const svg =
|
||||
'<?xml version="1.0" encoding="UTF-8"?>' +
|
||||
'<svg xmlns="http://www.w3.org/2000/svg">' +
|
||||
'<rect width="10" height="10"/></svg>';
|
||||
const base64 = Buffer.from(svg).toString('base64');
|
||||
expect(imageDataUri(base64)).toBe(
|
||||
`data:image/svg+xml;base64,${base64}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { SVG_SNIFF_WINDOW, sniffImageMime } from '../../util/appIcon.js';
|
||||
|
||||
// Base64 chars needed to recover SVG_SNIFF_WINDOW decoded bytes.
|
||||
const SNIFF_BASE64_CHARS = Math.ceil((SVG_SNIFF_WINDOW * 4) / 3);
|
||||
|
||||
/** Label generated bytes with the existing content-based MIME detector. */
|
||||
export function imageDataUri(
|
||||
base64: string,
|
||||
fallbackMime = 'image/png',
|
||||
): string {
|
||||
// Sniff enough of the payload to see an SVG root element; fixed-offset
|
||||
// signatures (PNG/JPEG/GIF/WEBP) appear in the first bytes regardless.
|
||||
const mime =
|
||||
sniffImageMime(
|
||||
Buffer.from(base64.slice(0, SNIFF_BASE64_CHARS), 'base64'),
|
||||
) ?? fallbackMime;
|
||||
return `data:${mime};base64,${base64}`;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
|
||||
export function assertImagePrompt(prompt: unknown): asserts prompt is string {
|
||||
if (typeof prompt !== 'string' || !prompt.trim()) {
|
||||
throw new HttpError(400, '`prompt` must be a non-empty string', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import type { IGenerateParams } from './types.js';
|
||||
export {
|
||||
assertInputImageString,
|
||||
fetchImageAsBase64,
|
||||
fetchImageBytes,
|
||||
isHttpUrl,
|
||||
parseDataUri,
|
||||
toBase64DataUri,
|
||||
|
||||
@@ -20,12 +20,11 @@
|
||||
/**
|
||||
* Offline unit tests for BytePlusImageProvider.
|
||||
*
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
* redis) and constructs BytePlusImageProvider directly against the
|
||||
* live wired `MeteringService` so the recording side is exercised
|
||||
* end-to-end. Ark's image API is OpenAI-compatible so the OpenAI SDK
|
||||
* is mocked at the module boundary; that's the real network egress
|
||||
* point.
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) and
|
||||
* constructs BytePlusImageProvider directly against the live wired
|
||||
* `MeteringService` so the recording side is exercised end-to-end. Ark's image
|
||||
* API is OpenAI-compatible so the OpenAI SDK is mocked at the module boundary;
|
||||
* that's the real network egress point.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -261,7 +260,7 @@ describe('BytePlusImageProvider.generate request shape', () => {
|
||||
quality: '1.5K',
|
||||
}),
|
||||
);
|
||||
expect(generateMock.mock.calls[0]![0].size).toBe('1.5K');
|
||||
expect(generateMock.mock.calls[0]![0].size).toBe('2K');
|
||||
});
|
||||
|
||||
it('resolves an aspect ratio + tier to the documented pixel size', async () => {
|
||||
@@ -271,7 +270,7 @@ describe('BytePlusImageProvider.generate request shape', () => {
|
||||
model: 'seedream-4-0',
|
||||
prompt: 'hi',
|
||||
quality: '1k',
|
||||
ratio: { w: 16, h: 9 },
|
||||
imageSize: { w: 16, h: 9, kind: 'aspect' },
|
||||
}),
|
||||
);
|
||||
expect(generateMock.mock.calls[0]![0].size).toBe('1424x800');
|
||||
@@ -296,19 +295,38 @@ describe('BytePlusImageProvider.generate request shape', () => {
|
||||
makeProvider().generate({
|
||||
prompt: 'hi',
|
||||
quality: '1.5k',
|
||||
ratio: { w: 16, h: 9 },
|
||||
imageSize: { w: 16, h: 9, kind: 'aspect' },
|
||||
}),
|
||||
);
|
||||
expect(generateMock.mock.calls[1]![0].size).toBe('2816x1584');
|
||||
expect(generateMock.mock.calls[1]![0].size).toBe('2848x1600');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['seedream-4-0', 512, 512, '2048x2048'],
|
||||
['seedream-5-0-lite', 640, 360, '2848x1600'],
|
||||
['dola-seedream-5-0-pro', 640, 360, '2816x1584'],
|
||||
])(
|
||||
'preserves small pixel pairs as aspect hints for %s',
|
||||
async (model, w, h, size) => {
|
||||
generateMock.mockResolvedValueOnce(sampleResponse);
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: String(model),
|
||||
prompt: 'hi',
|
||||
imageSize: { w: Number(w), h: Number(h), kind: 'aspect' },
|
||||
}),
|
||||
);
|
||||
expect(generateMock.mock.calls[0][0].size).toBe(size);
|
||||
},
|
||||
);
|
||||
|
||||
it('passes explicit pixel dimensions straight through', async () => {
|
||||
generateMock.mockResolvedValueOnce(sampleResponse);
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: 'seedream-4-0',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 2048, h: 1024 },
|
||||
imageSize: { w: 2048, h: 1024, kind: 'pixels' },
|
||||
}),
|
||||
);
|
||||
expect(generateMock.mock.calls[0]![0].size).toBe('2048x1024');
|
||||
@@ -322,7 +340,7 @@ describe('BytePlusImageProvider.generate request shape', () => {
|
||||
withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
prompt: 'hi',
|
||||
ratio: { w: 2048, h: 1024 },
|
||||
imageSize: { w: 2048, h: 1024, kind: 'pixels' },
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
@@ -336,7 +354,7 @@ describe('BytePlusImageProvider.generate request shape', () => {
|
||||
model: 'seedream-4-0',
|
||||
prompt: 'hi',
|
||||
quality: '1k',
|
||||
ratio: { w: 32, h: 18 },
|
||||
imageSize: { w: 32, h: 18, kind: 'aspect' },
|
||||
}),
|
||||
);
|
||||
expect(generateMock.mock.calls[0]![0].size).toBe('1424x800');
|
||||
@@ -347,7 +365,7 @@ describe('BytePlusImageProvider.generate request shape', () => {
|
||||
withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
prompt: 'hi',
|
||||
ratio: { w: 6000, h: 6000 },
|
||||
imageSize: { w: 6000, h: 6000, kind: 'pixels' },
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
@@ -548,3 +566,95 @@ describe('BytePlusImageProvider.generate response handling', () => {
|
||||
expect(batchIncrementUsagesSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BytePlusImageProvider model-specific output sizes', () => {
|
||||
it.each([
|
||||
['seedream-5-0-lite-260128', '3k', '3072x3072'],
|
||||
['seedream-5-0-lite-260128', '4k', '4096x4096'],
|
||||
['seedream-4-5-251128', '4k', '4096x4096'],
|
||||
['seedream-4-0-250828', '4k', '4096x4096'],
|
||||
])('supports %s at %s', async (model, quality, size) => {
|
||||
generateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://example.com/image.png' }],
|
||||
});
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model,
|
||||
quality,
|
||||
prompt: 'a landscape',
|
||||
imageSize: { w: 1, h: 1, kind: 'aspect' },
|
||||
}),
|
||||
);
|
||||
expect(generateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ size }),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts explicit 4K pixels for Lite but rejects them for Pro', async () => {
|
||||
generateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://example.com/image.png' }],
|
||||
});
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: 'seedream-5-0-lite-260128',
|
||||
prompt: 'a landscape',
|
||||
imageSize: { w: 4096, h: 4096, kind: 'pixels' },
|
||||
}),
|
||||
);
|
||||
expect(generateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ size: '4096x4096' }),
|
||||
);
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: 'dola-seedream-5-0-pro-260628',
|
||||
prompt: 'a landscape',
|
||||
imageSize: { w: 4096, h: 4096, kind: 'pixels' },
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
it('labels BytePlus base64 by its image signature', async () => {
|
||||
generateMock.mockResolvedValueOnce({
|
||||
data: [{ b64_json: 'iVBORw0KGgo=' }],
|
||||
});
|
||||
const result = await withTestActor(() =>
|
||||
makeProvider().generate({ prompt: 'hi' }),
|
||||
);
|
||||
expect(result).toBe('data:image/png;base64,iVBORw0KGgo=');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ w: 2048.5, h: 2048 },
|
||||
{ w: 2048, h: 2048.5 },
|
||||
])('rejects fractional pixel dimensions %j before billing', async (ratio) => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
prompt: 'hi',
|
||||
model: 'seedream-4-5-251128',
|
||||
imageSize: { ...ratio, kind: 'pixels' },
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400, legacyCode: 'bad_request' });
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(generateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps a nearby aspect hint to the closest supported BytePlus aspect', async () => {
|
||||
generateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://example.com/out.jpg' }],
|
||||
});
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
prompt: 'hi',
|
||||
model: 'dola-seedream-5-0-pro-260628',
|
||||
imageSize: { w: 17, h: 10, kind: 'aspect' },
|
||||
}),
|
||||
);
|
||||
expect(generateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ size: '2816x1584' }),
|
||||
);
|
||||
});
|
||||
@@ -17,7 +17,11 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { assertImagePrompt } from '../../imageValidation.js';
|
||||
import { OpenAI } from 'openai';
|
||||
import { imageDataUri } from '../../imageOutput.js';
|
||||
import { closestAspectRatio } from '../../imageDimensions.js';
|
||||
import type { ImageSize } from '../../types.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
@@ -29,24 +33,17 @@ import type {
|
||||
import { toUrlOrDataUri } from '../../inputImage.js';
|
||||
import {
|
||||
BYTEPLUS_IMAGE_GENERATION_MODELS,
|
||||
SEEDREAM_RESOLUTION_MAP,
|
||||
type BytePlusImageModel,
|
||||
} from './models.js';
|
||||
|
||||
const DEFAULT_MODEL = 'seedream-5-0-lite-260128';
|
||||
|
||||
// Ark's explicit-pixel `size` bounds ("method 2"): total pixels within
|
||||
// [1280x720, 2048x2048x1.1025] and aspect ratio within [1/16, 16].
|
||||
const MIN_TOTAL_PIXELS = 921_600;
|
||||
const MAX_TOTAL_PIXELS = 4_624_220;
|
||||
// Models restricted to the 2K tier (see SEEDREAM_2K_ONLY in models.ts)
|
||||
// enforce this higher minimum on explicit sizes too.
|
||||
const MIN_TOTAL_PIXELS_2K_ONLY = 3_686_400;
|
||||
// dola-seedream-5-0-pro's price break: ≤ 2.61MP bills the "1.5K or lower"
|
||||
// rate, above it the higher rate.
|
||||
const PRO_TIER_BREAK_PIXELS = 2_610_000;
|
||||
|
||||
// Ark's `size` tiers, smallest first.
|
||||
const TIERS = ['1k', '1.5k', '2k'] as const;
|
||||
const TIERS = ['1k', '1.5k', '2k', '3k', '4k'] as const;
|
||||
type Tier = (typeof TIERS)[number];
|
||||
const isTier = (v: string): v is Tier =>
|
||||
(TIERS as readonly string[]).includes(v);
|
||||
@@ -95,7 +92,7 @@ export class BytePlusImageProvider implements IImageProvider {
|
||||
});
|
||||
}
|
||||
|
||||
models(): IImageModel[] {
|
||||
models(): BytePlusImageModel[] {
|
||||
return BYTEPLUS_IMAGE_GENERATION_MODELS;
|
||||
}
|
||||
|
||||
@@ -104,7 +101,7 @@ export class BytePlusImageProvider implements IImageProvider {
|
||||
}
|
||||
|
||||
async generate(params: IGenerateParams): Promise<string> {
|
||||
const { prompt, test_mode, model, ratio, quality } = params;
|
||||
const { prompt, test_mode, model, quality } = params;
|
||||
let { input_images } = params;
|
||||
const { input_image, input_image_mime_type } = params;
|
||||
|
||||
@@ -115,11 +112,7 @@ export class BytePlusImageProvider implements IImageProvider {
|
||||
return 'https://puter-sample-data.puter.site/image_example.png';
|
||||
}
|
||||
|
||||
if (typeof prompt !== 'string' || prompt.trim().length === 0) {
|
||||
throw new HttpError(400, '`prompt` must be a non-empty string', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
assertImagePrompt(prompt);
|
||||
|
||||
// Backwards compat: fold singular `input_image` into `input_images`.
|
||||
if (input_image && (!input_images || input_images.length === 0)) {
|
||||
@@ -138,7 +131,8 @@ export class BytePlusImageProvider implements IImageProvider {
|
||||
const inputImageCount = input_images?.length ?? 0;
|
||||
|
||||
const tier = this.#normalizeTier(quality, selectedModel);
|
||||
const size = this.#resolveSize(tier, selectedModel, ratio);
|
||||
// The driver resolves every caller-facing size field into `imageSize`.
|
||||
const size = this.#resolveSize(tier, selectedModel, params.imageSize);
|
||||
|
||||
// The pro model bills by output pixel count; everything else is a
|
||||
// flat per-image rate.
|
||||
@@ -215,7 +209,10 @@ export class BytePlusImageProvider implements IImageProvider {
|
||||
const url =
|
||||
first?.url ||
|
||||
(first?.b64_json
|
||||
? `data:image/${first.output_format ?? 'jpeg'};base64,${first.b64_json}`
|
||||
? imageDataUri(
|
||||
first.b64_json,
|
||||
`image/${first.output_format ?? 'jpeg'}`,
|
||||
)
|
||||
: undefined);
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
@@ -279,49 +276,48 @@ export class BytePlusImageProvider implements IImageProvider {
|
||||
* - `ratio` holding real pixel dimensions → explicit `WxH` (method 2)
|
||||
* - `ratio` holding an aspect ratio with a known tier mapping → the
|
||||
* documented `WxH` for (aspect, tier)
|
||||
* - Otherwise → the tier keyword (`1K`/`1.5K`/`2K`, method 1)
|
||||
* - Otherwise → the tier keyword (`1K` through `4K`, method 1)
|
||||
*/
|
||||
#resolveSize(
|
||||
tier: Tier,
|
||||
model: IImageModel,
|
||||
ratio?: { w: number; h: number },
|
||||
model: BytePlusImageModel,
|
||||
ratio?: ImageSize,
|
||||
): string {
|
||||
if (ratio?.w && ratio?.h) {
|
||||
const pixels = ratio.w * ratio.h;
|
||||
if (pixels >= MIN_TOTAL_PIXELS) {
|
||||
// 2K-only models enforce a higher minimum on explicit sizes
|
||||
// too — fail fast with the real constraint instead of letting
|
||||
// Ark 400 the request after the round-trip.
|
||||
const minPixels = model.allowedQualityLevels?.includes('1k')
|
||||
? MIN_TOTAL_PIXELS
|
||||
: MIN_TOTAL_PIXELS_2K_ONLY;
|
||||
if (ratio.kind === 'pixels') {
|
||||
const { minPixels, maxPixels } = model;
|
||||
const aspect = ratio.w / ratio.h;
|
||||
if (
|
||||
pixels < minPixels ||
|
||||
pixels > MAX_TOTAL_PIXELS ||
|
||||
pixels > maxPixels ||
|
||||
!Number.isInteger(ratio.w) ||
|
||||
!Number.isInteger(ratio.h) ||
|
||||
aspect < 1 / 16 ||
|
||||
aspect > 16
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Requested size ${ratio.w}x${ratio.h} is outside BytePlus limits ` +
|
||||
`for ${model.id} (total pixels within [${minPixels}, ${MAX_TOTAL_PIXELS}], ` +
|
||||
`for ${model.id} (total pixels within [${minPixels}, ${maxPixels}], ` +
|
||||
'aspect ratio within [1/16, 16])',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return `${ratio.w}x${ratio.h}`;
|
||||
}
|
||||
// Reduce w:h to lowest terms so any spelling of a supported
|
||||
// aspect (8:6, 32:18, ...) finds its documented tier size.
|
||||
const gcd = (a: number, b: number): number =>
|
||||
b === 0 ? a : gcd(b, a % b);
|
||||
const d = gcd(Math.round(ratio.w), Math.round(ratio.h)) || 1;
|
||||
const key = `${Math.round(ratio.w) / d}:${Math.round(ratio.h) / d}`;
|
||||
const mapped = SEEDREAM_RESOLUTION_MAP[key]?.[tier];
|
||||
const aspects = Object.entries(model.resolution_map ?? {}).map(
|
||||
([key, sizes]) => {
|
||||
const [w, h] = key.split(':').map(Number);
|
||||
return { w, h, sizes };
|
||||
},
|
||||
);
|
||||
const mapped = aspects.length
|
||||
? closestAspectRatio(ratio, aspects).sizes[tier]
|
||||
: undefined;
|
||||
if (mapped) return `${mapped.w}x${mapped.h}`;
|
||||
}
|
||||
return { '1k': '1K', '1.5k': '1.5K', '2k': '2K' }[tier];
|
||||
return tier.toUpperCase();
|
||||
}
|
||||
|
||||
/** Pixel count of an explicit `WxH` size; undefined for tier keywords. */
|
||||
|
||||
@@ -19,12 +19,16 @@
|
||||
|
||||
import type { IImageModel } from '../../types.js';
|
||||
|
||||
// Ark's `size` "method 1" tiers and the pixel dimensions each (tier, aspect
|
||||
// ratio) pair resolves to, from the image generation API reference:
|
||||
export type BytePlusImageModel = IImageModel & {
|
||||
minPixels: number;
|
||||
maxPixels: number;
|
||||
};
|
||||
|
||||
export const BYTEPLUS_PIXEL_SIZE_THRESHOLD = 921_600;
|
||||
|
||||
// Output dimensions from the ModelArk image generation API reference.
|
||||
// https://docs.byteplus.com/en/docs/ModelArk/1541523
|
||||
// The table is identical for dola-seedream-5-0-pro, seedream-5-0-lite,
|
||||
// seedream-4-5 and seedream-4-0.
|
||||
export const SEEDREAM_RESOLUTION_MAP: Record<
|
||||
export const SEEDREAM_PRO_RESOLUTION_MAP: Record<
|
||||
string,
|
||||
Record<string, { w: number; h: number }>
|
||||
> = {
|
||||
@@ -70,12 +74,59 @@ export const SEEDREAM_RESOLUTION_MAP: Record<
|
||||
},
|
||||
};
|
||||
|
||||
const SEEDREAM_QUALITY_LEVELS = ['1k', '1.5k', '2k'];
|
||||
|
||||
// seedream-4-5 and the 5.0 series enforce a minimum output of 3,686,400
|
||||
// pixels, which every 1K and 1.5K entry in the table above falls under —
|
||||
// they only accept the 2K tier.
|
||||
const SEEDREAM_2K_ONLY = ['2k'];
|
||||
export const SEEDREAM_RESOLUTION_MAP: Record<
|
||||
string,
|
||||
Record<string, { w: number; h: number }>
|
||||
> = {
|
||||
'1:1': {
|
||||
'1k': { w: 1024, h: 1024 },
|
||||
'2k': { w: 2048, h: 2048 },
|
||||
'3k': { w: 3072, h: 3072 },
|
||||
'4k': { w: 4096, h: 4096 },
|
||||
},
|
||||
'4:3': {
|
||||
'1k': { w: 1152, h: 864 },
|
||||
'2k': { w: 2304, h: 1728 },
|
||||
'3k': { w: 3456, h: 2592 },
|
||||
'4k': { w: 4704, h: 3520 },
|
||||
},
|
||||
'3:4': {
|
||||
'1k': { w: 864, h: 1152 },
|
||||
'2k': { w: 1728, h: 2304 },
|
||||
'3k': { w: 2592, h: 3456 },
|
||||
'4k': { w: 3520, h: 4704 },
|
||||
},
|
||||
'16:9': {
|
||||
'1k': { w: 1424, h: 800 },
|
||||
'2k': { w: 2848, h: 1600 },
|
||||
'3k': { w: 4096, h: 2304 },
|
||||
'4k': { w: 5504, h: 3040 },
|
||||
},
|
||||
'9:16': {
|
||||
'1k': { w: 800, h: 1424 },
|
||||
'2k': { w: 1600, h: 2848 },
|
||||
'3k': { w: 2304, h: 4096 },
|
||||
'4k': { w: 3040, h: 5504 },
|
||||
},
|
||||
'3:2': {
|
||||
'1k': { w: 1248, h: 832 },
|
||||
'2k': { w: 2496, h: 1664 },
|
||||
'3k': { w: 3744, h: 2496 },
|
||||
'4k': { w: 4992, h: 3328 },
|
||||
},
|
||||
'2:3': {
|
||||
'1k': { w: 832, h: 1248 },
|
||||
'2k': { w: 1664, h: 2496 },
|
||||
'3k': { w: 2496, h: 3744 },
|
||||
'4k': { w: 3328, h: 4992 },
|
||||
},
|
||||
'21:9': {
|
||||
'1k': { w: 1568, h: 672 },
|
||||
'2k': { w: 3136, h: 1344 },
|
||||
'3k': { w: 4704, h: 2016 },
|
||||
'4k': { w: 6240, h: 2656 },
|
||||
},
|
||||
};
|
||||
|
||||
// Costs are in usd-cents per image, hardcoded from
|
||||
// https://docs.byteplus.com/en/docs/ModelArk/1544106 (pricing) and
|
||||
@@ -85,10 +136,13 @@ const SEEDREAM_2K_ONLY = ['2k'];
|
||||
// lower") vs above — plus a per-input-image rate from the second reference
|
||||
// image onward (the first is free). Every other model is a flat per-image
|
||||
// rate with free image input.
|
||||
export const BYTEPLUS_IMAGE_GENERATION_MODELS: IImageModel[] = [
|
||||
export const BYTEPLUS_IMAGE_GENERATION_MODELS: BytePlusImageModel[] = [
|
||||
{
|
||||
puterId: 'byteplus:byteplus/dola-seedream-5-0-pro-260628',
|
||||
id: 'dola-seedream-5-0-pro-260628',
|
||||
pixelSizeThreshold: BYTEPLUS_PIXEL_SIZE_THRESHOLD,
|
||||
minPixels: 921_600,
|
||||
maxPixels: 4_624_220,
|
||||
aliases: [
|
||||
'byteplus/dola-seedream-5-0-pro-260628',
|
||||
'dola-seedream-5-0-pro',
|
||||
@@ -105,8 +159,8 @@ export const BYTEPLUS_IMAGE_GENERATION_MODELS: IImageModel[] = [
|
||||
'output:2k': 9, // $0.09 per image > 2.61MP
|
||||
input_image: 0.3, // $0.003 per input image from the 2nd on
|
||||
},
|
||||
allowedQualityLevels: SEEDREAM_QUALITY_LEVELS,
|
||||
resolution_map: SEEDREAM_RESOLUTION_MAP,
|
||||
allowedQualityLevels: ['1k', '1.5k', '2k'],
|
||||
resolution_map: SEEDREAM_PRO_RESOLUTION_MAP,
|
||||
},
|
||||
{
|
||||
// The catalog lists `seedream-5-0-260128` as the same service
|
||||
@@ -114,6 +168,9 @@ export const BYTEPLUS_IMAGE_GENERATION_MODELS: IImageModel[] = [
|
||||
// under the -lite id, so that's the primary id here.
|
||||
puterId: 'byteplus:byteplus/seedream-5-0-lite-260128',
|
||||
id: 'seedream-5-0-lite-260128',
|
||||
pixelSizeThreshold: BYTEPLUS_PIXEL_SIZE_THRESHOLD,
|
||||
minPixels: 3_686_400,
|
||||
maxPixels: 16_777_216,
|
||||
aliases: [
|
||||
'byteplus/seedream-5-0-lite-260128',
|
||||
'seedream-5-0-lite',
|
||||
@@ -126,12 +183,15 @@ export const BYTEPLUS_IMAGE_GENERATION_MODELS: IImageModel[] = [
|
||||
pricing_unit: 'per-image',
|
||||
index_cost_key: 'per-image',
|
||||
costs: { 'per-image': 3.5 },
|
||||
allowedQualityLevels: SEEDREAM_2K_ONLY,
|
||||
allowedQualityLevels: ['2k', '3k', '4k'],
|
||||
resolution_map: SEEDREAM_RESOLUTION_MAP,
|
||||
},
|
||||
{
|
||||
puterId: 'byteplus:byteplus/seedream-4-5-251128',
|
||||
id: 'seedream-4-5-251128',
|
||||
pixelSizeThreshold: BYTEPLUS_PIXEL_SIZE_THRESHOLD,
|
||||
minPixels: 3_686_400,
|
||||
maxPixels: 16_777_216,
|
||||
aliases: [
|
||||
'byteplus/seedream-4-5-251128',
|
||||
'seedream-4-5',
|
||||
@@ -142,12 +202,15 @@ export const BYTEPLUS_IMAGE_GENERATION_MODELS: IImageModel[] = [
|
||||
pricing_unit: 'per-image',
|
||||
index_cost_key: 'per-image',
|
||||
costs: { 'per-image': 4 },
|
||||
allowedQualityLevels: SEEDREAM_2K_ONLY,
|
||||
allowedQualityLevels: ['2k', '4k'],
|
||||
resolution_map: SEEDREAM_RESOLUTION_MAP,
|
||||
},
|
||||
{
|
||||
puterId: 'byteplus:byteplus/seedream-4-0-250828',
|
||||
id: 'seedream-4-0-250828',
|
||||
pixelSizeThreshold: BYTEPLUS_PIXEL_SIZE_THRESHOLD,
|
||||
minPixels: 921_600,
|
||||
maxPixels: 16_777_216,
|
||||
aliases: [
|
||||
'byteplus/seedream-4-0-250828',
|
||||
'seedream-4-0',
|
||||
@@ -158,7 +221,7 @@ export const BYTEPLUS_IMAGE_GENERATION_MODELS: IImageModel[] = [
|
||||
pricing_unit: 'per-image',
|
||||
index_cost_key: 'per-image',
|
||||
costs: { 'per-image': 3 },
|
||||
allowedQualityLevels: SEEDREAM_QUALITY_LEVELS,
|
||||
allowedQualityLevels: ['1k', '2k', '4k'],
|
||||
resolution_map: SEEDREAM_RESOLUTION_MAP,
|
||||
},
|
||||
];
|
||||
+240
-38
@@ -20,12 +20,11 @@
|
||||
/**
|
||||
* Offline unit tests for CloudflareImageProvider.
|
||||
*
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
* redis) and constructs CloudflareImageProvider directly against the
|
||||
* live wired `MeteringService` so the recording side is exercised
|
||||
* end-to-end. Cloudflare has no SDK — the provider hits the REST API
|
||||
* directly via global `fetch`, which we stub. That's the real network
|
||||
* egress point.
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) and
|
||||
* constructs CloudflareImageProvider directly against the live wired
|
||||
* `MeteringService` so the recording side is exercised end-to-end. Cloudflare
|
||||
* has no SDK — the provider hits the REST API directly via global `fetch`,
|
||||
* which we stub. That's the real network egress point.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -47,14 +46,14 @@ import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import { CLOUDFLARE_IMAGE_GENERATION_MODELS } from './models.js';
|
||||
import { CloudflareImageProvider } from './CloudflareImageProvider.js';
|
||||
|
||||
// Stub the URL→base64 fetch so URL inputs stay offline; keep the rest real.
|
||||
const { fetchImageAsBase64Mock } = vi.hoisted(() => ({
|
||||
fetchImageAsBase64Mock: vi.fn(),
|
||||
// Keep URL downloads offline; other input parsing stays real.
|
||||
const { fetchImageBytesMock } = vi.hoisted(() => ({
|
||||
fetchImageBytesMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../inputImage.js', async (orig) => ({
|
||||
...(await orig<typeof import('../../inputImage.js')>()),
|
||||
fetchImageAsBase64: fetchImageAsBase64Mock,
|
||||
fetchImageBytes: fetchImageBytesMock,
|
||||
}));
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
@@ -91,7 +90,7 @@ const makeProvider = (
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
fetchImageAsBase64Mock.mockReset();
|
||||
fetchImageBytesMock.mockReset();
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
batchIncrementUsagesSpy = vi.spyOn(
|
||||
@@ -181,7 +180,7 @@ describe('CloudflareImageProvider.generate credit gate', () => {
|
||||
// ── Request shape ──────────────────────────────────────────────────
|
||||
|
||||
describe('CloudflareImageProvider.generate request shape', () => {
|
||||
it('POSTs JSON with width/height/steps to the account-scoped /ai/run/<model> endpoint', async () => {
|
||||
it('sends only fields supported by Schnell to its account-scoped endpoint', async () => {
|
||||
const provider = makeProvider();
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
okJsonResponse({
|
||||
@@ -195,7 +194,7 @@ describe('CloudflareImageProvider.generate request shape', () => {
|
||||
provider.generate({
|
||||
model: '@cf/black-forest-labs/flux-1-schnell',
|
||||
prompt: 'a tiny red dot',
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
imageSize: { w: 1024, h: 1024, kind: 'pixels' },
|
||||
} as never),
|
||||
);
|
||||
|
||||
@@ -209,13 +208,7 @@ describe('CloudflareImageProvider.generate request shape', () => {
|
||||
expect(headers.Authorization).toBe('Bearer cf-test-token');
|
||||
expect(headers['Content-Type']).toBe('application/json');
|
||||
const body = JSON.parse(init?.body as string);
|
||||
expect(body.prompt).toBe('a tiny red dot');
|
||||
expect(body.width).toBe(1024);
|
||||
expect(body.height).toBe(1024);
|
||||
// Schnell defaults to 4 steps; provider sends both steps + num_steps
|
||||
// for compatibility with both naming conventions.
|
||||
expect(body.steps).toBe(4);
|
||||
expect(body.num_steps).toBe(4);
|
||||
expect(body).toEqual({ prompt: 'a tiny red dot', steps: 4 });
|
||||
});
|
||||
|
||||
it('honours an apiBaseUrl override', async () => {
|
||||
@@ -253,7 +246,7 @@ describe('CloudflareImageProvider.generate request shape', () => {
|
||||
// flux-2-dev has requiresMultipart=true.
|
||||
model: '@cf/black-forest-labs/flux-2-dev',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
imageSize: { w: 1024, h: 1024, kind: 'pixels' },
|
||||
} as never),
|
||||
);
|
||||
|
||||
@@ -268,7 +261,7 @@ describe('CloudflareImageProvider.generate request shape', () => {
|
||||
expect(form.get('height')).toBe('1024');
|
||||
});
|
||||
|
||||
it('clamps user-supplied steps to [1,50]', async () => {
|
||||
it('clamps Schnell steps to [1,8]', async () => {
|
||||
const provider = makeProvider();
|
||||
|
||||
// High clamp.
|
||||
@@ -282,9 +275,9 @@ describe('CloudflareImageProvider.generate request shape', () => {
|
||||
steps: 999,
|
||||
} as never),
|
||||
);
|
||||
expect(JSON.parse(fetchSpy.mock.calls[0]![1]!.body as string).steps).toBe(
|
||||
50,
|
||||
);
|
||||
expect(
|
||||
JSON.parse(fetchSpy.mock.calls[0]![1]!.body as string).steps,
|
||||
).toBe(8);
|
||||
|
||||
// Low clamp.
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
@@ -297,9 +290,9 @@ describe('CloudflareImageProvider.generate request shape', () => {
|
||||
steps: 0,
|
||||
} as never),
|
||||
);
|
||||
expect(JSON.parse(fetchSpy.mock.calls[1]![1]!.body as string).steps).toBe(
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
JSON.parse(fetchSpy.mock.calls[1]![1]!.body as string).steps,
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -341,7 +334,7 @@ describe('CloudflareImageProvider.generate output extraction', () => {
|
||||
} as never),
|
||||
);
|
||||
|
||||
expect(result).toBe('data:image/webp;base64,AAAA');
|
||||
expect(result).toBe('data:image/jpeg;base64,AAAA');
|
||||
});
|
||||
|
||||
it('passes through an http(s) URL or data URL straight from the JSON envelope', async () => {
|
||||
@@ -426,7 +419,7 @@ describe('CloudflareImageProvider.generate cost components', () => {
|
||||
provider.generate({
|
||||
model: '@cf/black-forest-labs/flux-1-schnell',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 1024, h: 1024 }, // 2x2 tiles = 4
|
||||
imageSize: { w: 1024, h: 1024, kind: 'pixels' }, // 2x2 tiles = 4
|
||||
} as never),
|
||||
);
|
||||
|
||||
@@ -462,7 +455,7 @@ describe('CloudflareImageProvider.generate cost components', () => {
|
||||
model: '@cf/black-forest-labs/flux-2-klein-9b',
|
||||
prompt: 'hi',
|
||||
// 2 MP image to exercise both first_mp and subsequent_mp.
|
||||
ratio: { w: 2000, h: 1000 },
|
||||
imageSize: { w: 2000, h: 1000, kind: 'pixels' },
|
||||
image: 'data:image/png;base64,AAAA', // hasInputImage=true
|
||||
} as never),
|
||||
);
|
||||
@@ -497,7 +490,7 @@ describe('CloudflareImageProvider.generate input_images', () => {
|
||||
provider.generate({
|
||||
model: '@cf/black-forest-labs/flux-2-klein-9b',
|
||||
prompt: 'edit it',
|
||||
ratio: { w: 2000, h: 1000 },
|
||||
imageSize: { w: 2000, h: 1000, kind: 'pixels' },
|
||||
...extra,
|
||||
} as never),
|
||||
);
|
||||
@@ -521,12 +514,12 @@ describe('CloudflareImageProvider.generate input_images', () => {
|
||||
});
|
||||
|
||||
it('fetches an http(s) URL input via secureFetch and uses it as the input image', async () => {
|
||||
fetchImageAsBase64Mock.mockResolvedValueOnce({
|
||||
base64: 'AAAA',
|
||||
fetchImageBytesMock.mockResolvedValueOnce({
|
||||
bytes: Buffer.from('AAAA', 'base64'),
|
||||
mime: 'image/png',
|
||||
});
|
||||
await klein9bWith({ input_images: ['https://example.com/in.png'] });
|
||||
expect(fetchImageAsBase64Mock).toHaveBeenCalledWith(
|
||||
expect(fetchImageBytesMock).toHaveBeenCalledWith(
|
||||
'https://example.com/in.png',
|
||||
);
|
||||
expect(hasInputCostLine()).toBe(true);
|
||||
@@ -539,12 +532,221 @@ describe('CloudflareImageProvider.generate input_images', () => {
|
||||
provider.generate({
|
||||
model: '@cf/black-forest-labs/flux-2-klein-9b',
|
||||
prompt: 'edit it',
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
input_images: ['data:image/png;base64,AAAA', 'data:image/png;base64,BBBB'],
|
||||
imageSize: { w: 1024, h: 1024, kind: 'pixels' },
|
||||
input_images: [
|
||||
'data:image/png;base64,AAAA',
|
||||
'data:image/png;base64,BBBB',
|
||||
],
|
||||
} as never),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(fetchImageAsBase64Mock).not.toHaveBeenCalled();
|
||||
expect(fetchImageBytesMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CloudflareImageProvider reference transport', () => {
|
||||
it('labels a FLUX.2 JPEG response with its actual MIME type', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
okJsonResponse({ result: { image: '/9j/4A==' } }),
|
||||
);
|
||||
const result = await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: '@cf/black-forest-labs/flux-2-klein-4b',
|
||||
prompt: 'a landscape',
|
||||
}),
|
||||
);
|
||||
expect(result).toBe('data:image/jpeg;base64,/9j/4A==');
|
||||
});
|
||||
|
||||
it('uploads decoded image bytes using the FLUX.2 multipart field', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
okJsonResponse({ result: { image: 'AAAA' } }),
|
||||
);
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: '@cf/black-forest-labs/flux-2-klein-4b',
|
||||
prompt: 'a landscape',
|
||||
input_images: ['data:image/png;base64,AQID'],
|
||||
steps: 40,
|
||||
}),
|
||||
);
|
||||
const form = fetchSpy.mock.calls[0][1]!.body as FormData;
|
||||
const input = form.get('input_image_0') as File;
|
||||
expect(input.type).toBe('image/png');
|
||||
expect([...new Uint8Array(await input.arrayBuffer())]).toEqual([
|
||||
1, 2, 3,
|
||||
]);
|
||||
expect(form.has('image')).toBe(false);
|
||||
expect(form.has('steps')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects image inputs on text-only models before a provider request', async () => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
prompt: 'a landscape',
|
||||
input_image: 'AAAA',
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cloudflare legacy image option', () => {
|
||||
it.each([{ url: 'https://example.com/in.png' }, 123, ['AAAA']])(
|
||||
'rejects a non-string image %j as 400 before any upstream call',
|
||||
async (image) => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: '@cf/black-forest-labs/flux-2-dev',
|
||||
prompt: 'hi',
|
||||
image,
|
||||
} as never),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an input that decodes to zero bytes instead of uploading it', async () => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: '@cf/black-forest-labs/flux-2-dev',
|
||||
prompt: 'hi',
|
||||
input_images: ['data:image/png;base64,'],
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: 'Invalid input image (empty or not base64)',
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cloudflare size bounds', () => {
|
||||
it.each([
|
||||
{ w: 4, h: 1 },
|
||||
{ w: 1, h: 4 },
|
||||
{ w: 32, h: 1 },
|
||||
])('fits an aspect hint %j inside FLUX.2 side limits', async (ratio) => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
okJsonResponse({ result: { image: 'AAAA' } }),
|
||||
);
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: '@cf/black-forest-labs/flux-2-klein-4b',
|
||||
prompt: 'hi',
|
||||
imageSize: { ...ratio, kind: 'aspect' },
|
||||
}),
|
||||
);
|
||||
const body = fetchSpy.mock.calls[0][1]!.body as FormData;
|
||||
for (const side of ['width', 'height']) {
|
||||
expect(Number(body.get(side))).toBeGreaterThanOrEqual(256);
|
||||
expect(Number(body.get(side))).toBeLessThanOrEqual(1920);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ model: '@cf/leonardo/lucid-origin', negative: false },
|
||||
{ model: '@cf/leonardo/phoenix-1.0', negative: true },
|
||||
])(
|
||||
'sends the supported JSON fields for $model',
|
||||
async ({ model, negative }) => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
okJsonResponse({ result: { image: 'AAAA' } }),
|
||||
);
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model,
|
||||
prompt: 'hi',
|
||||
imageSize: { w: 1024, h: 768, kind: 'pixels' },
|
||||
steps: 12,
|
||||
seed: 42,
|
||||
guidance: 4,
|
||||
negative_prompt: 'blur',
|
||||
}),
|
||||
);
|
||||
expect(JSON.parse(fetchSpy.mock.calls[0][1]!.body as string)).toEqual({
|
||||
prompt: 'hi',
|
||||
width: 1024,
|
||||
height: 768,
|
||||
num_steps: 12,
|
||||
seed: 42,
|
||||
guidance: 4,
|
||||
...(negative ? { negative_prompt: 'blur' } : {}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('meters the bounded FLUX.2 dimensions sent upstream', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
okJsonResponse({ result: { image: 'AAAA' } }),
|
||||
);
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: '@cf/black-forest-labs/flux-2-klein-4b',
|
||||
prompt: 'hi',
|
||||
imageSize: { w: 32, h: 1, kind: 'aspect' },
|
||||
}),
|
||||
);
|
||||
const body = fetchSpy.mock.calls[0][1]!.body as FormData;
|
||||
expect(body.get('width')).toBe('1920');
|
||||
expect(body.get('height')).toBe('256');
|
||||
expect(batchIncrementUsagesSpy.mock.calls[0][1]).toEqual([
|
||||
expect.objectContaining({
|
||||
usageType: expect.stringContaining(':input_tile_512'),
|
||||
usageAmount: 4,
|
||||
costOverride: 23600,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
usageType: expect.stringContaining(':output_tile_512'),
|
||||
usageAmount: 4,
|
||||
costOverride: 114800,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('Cloudflare priced legacy models', () => {
|
||||
it('sends image and mask bytes to inpainting and preserves a published zero price', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response(new Uint8Array([1, 2, 3]), { headers: { 'content-type': 'image/png' } }));
|
||||
await withTestActor(() => makeProvider().generate({
|
||||
model: '@cf/runwayml/stable-diffusion-v1-5-inpainting', prompt: 'A cup',
|
||||
input_images: ['data:image/png;base64,AQID'], maskImage: 'data:image/png;base64,BAUG',
|
||||
imageSize: { kind: 'pixels', w: 512, h: 512 },
|
||||
}));
|
||||
expect(JSON.parse(fetchSpy.mock.calls[0]![1]!.body as string)).toMatchObject({ image: [1, 2, 3], mask: [4, 5, 6], num_steps: 20, width: 512, height: 512 });
|
||||
expect(hasCreditsSpy).toHaveBeenCalledWith(expect.anything(), 0);
|
||||
});
|
||||
|
||||
it('rejects missing inpainting inputs before credit checks', async () => {
|
||||
await expect(withTestActor(() => makeProvider().generate({ model: '@cf/runwayml/stable-diffusion-v1-5-inpainting', prompt: 'A cup' }))).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['@cf/bytedance/stable-diffusion-xl-lightning', '@cf/stabilityai/stable-diffusion-xl-base-1.0'])(
|
||||
'rejects an input image on %s, which Cloudflare serves text-to-image only', async (model) => {
|
||||
await expect(withTestActor(() => makeProvider().generate({
|
||||
model, prompt: 'A cup', input_image: 'data:image/png;base64,AQID',
|
||||
}))).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('charges Lucid at the price returned by the model API', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response(new Uint8Array([1, 2, 3]), { headers: { 'content-type': 'image/png' } }));
|
||||
await withTestActor(() => makeProvider().generate({ model: '@cf/leonardo/lucid-origin', prompt: 'A cup', imageSize: { kind: 'pixels', w: 512, h: 512 }, steps: 1 }));
|
||||
expect(hasCreditsSpy).toHaveBeenCalledWith(expect.anything(), 713200);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { assertImagePrompt } from '../../imageValidation.js';
|
||||
import { expandAspectRatio } from '../../imageDimensions.js';
|
||||
import { imageDataUri } from '../../imageOutput.js';
|
||||
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
@@ -30,9 +33,11 @@ import {
|
||||
CloudflareImageModel,
|
||||
} from './models.js';
|
||||
import {
|
||||
fetchImageAsBase64,
|
||||
assertInputImageString,
|
||||
fetchImageBytes,
|
||||
isHttpUrl,
|
||||
resolveSingleInputImage,
|
||||
parseDataUri,
|
||||
} from '../../inputImage.js';
|
||||
|
||||
type CloudflareGenerateParams = IGenerateParams & {
|
||||
@@ -41,8 +46,9 @@ type CloudflareGenerateParams = IGenerateParams & {
|
||||
seed?: number;
|
||||
guidance?: number;
|
||||
negative_prompt?: string;
|
||||
output_format?: 'jpeg' | 'png' | 'webp';
|
||||
image?: string;
|
||||
maskImage?: string;
|
||||
strength?: number;
|
||||
};
|
||||
|
||||
interface CostComponent {
|
||||
@@ -84,20 +90,18 @@ export class CloudflareImageProvider implements IImageProvider {
|
||||
}
|
||||
|
||||
async generate(params: IGenerateParams): Promise<string> {
|
||||
const options = params as CloudflareGenerateParams;
|
||||
const options = { ...params } as CloudflareGenerateParams;
|
||||
const { prompt, test_mode } = options;
|
||||
const ratio = this.#normalizeRatio(options.ratio);
|
||||
const selectedModel = this.#getModel(options.model);
|
||||
const ratio = selectedModel.fixedRatio
|
||||
? { ...selectedModel.fixedRatio }
|
||||
: this.#normalizeRatio(options, selectedModel);
|
||||
|
||||
if (test_mode) {
|
||||
return 'https://puter-sample-data.puter.site/image_example.png';
|
||||
}
|
||||
|
||||
if (typeof prompt !== 'string' || prompt.trim().length === 0) {
|
||||
throw new HttpError(400, '`prompt` must be a non-empty string', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
assertImagePrompt(prompt);
|
||||
|
||||
const actor = Context.get('actor');
|
||||
if (!actor) {
|
||||
@@ -106,21 +110,82 @@ export class CloudflareImageProvider implements IImageProvider {
|
||||
});
|
||||
}
|
||||
|
||||
// Canonical `input_images`/`input_image` → Cloudflare's `image` field.
|
||||
// Cloudflare accepts a single input image; a URL is fetched to base64
|
||||
// server-side (SSRF-guarded) since the API has no URL field.
|
||||
const singleInput = resolveSingleInputImage(options, 'Cloudflare');
|
||||
// The legacy `image` option bypasses the driver's input_images shape
|
||||
// check, so it gets the same string validation here before anything
|
||||
// reaches the base64 decoder.
|
||||
const legacyImage =
|
||||
options.image == null
|
||||
? undefined
|
||||
: assertInputImageString(options.image, 'Cloudflare');
|
||||
const singleInput =
|
||||
resolveSingleInputImage(options, 'Cloudflare') ?? legacyImage;
|
||||
let inputImage: { bytes: Buffer; mime: string } | undefined;
|
||||
if (singleInput) {
|
||||
options.image ??= isHttpUrl(singleInput)
|
||||
? (await fetchImageAsBase64(singleInput)).base64
|
||||
: singleInput;
|
||||
if (
|
||||
!selectedModel.requiresMultipart &&
|
||||
!selectedModel.supportsImageBytes
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`${selectedModel.id} does not support input images`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
if (isHttpUrl(singleInput))
|
||||
inputImage = await fetchImageBytes(singleInput);
|
||||
else {
|
||||
const data = parseDataUri(singleInput);
|
||||
inputImage = {
|
||||
bytes: Buffer.from(data?.base64 ?? singleInput, 'base64'),
|
||||
mime:
|
||||
data?.mime ??
|
||||
options.input_image_mime_type ??
|
||||
'image/png',
|
||||
};
|
||||
}
|
||||
if (inputImage.bytes.length === 0) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Invalid input image (empty or not base64)',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedModel.requiresInputImage && !inputImage) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`${selectedModel.id} requires an input image`,
|
||||
{
|
||||
legacyCode: 'bad_request',
|
||||
},
|
||||
);
|
||||
}
|
||||
let maskBytes: Buffer | undefined;
|
||||
if (options.maskImage != null) {
|
||||
const mask = assertInputImageString(options.maskImage, 'maskImage');
|
||||
if (!selectedModel.supportsImageBytes || !inputImage) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'maskImage requires a model supporting masks and an input image',
|
||||
{
|
||||
legacyCode: 'bad_request',
|
||||
},
|
||||
);
|
||||
}
|
||||
maskBytes = isHttpUrl(mask)
|
||||
? (await fetchImageBytes(mask)).bytes
|
||||
: Buffer.from(parseDataUri(mask)?.base64 ?? mask, 'base64');
|
||||
if (!maskBytes.length) {
|
||||
throw new HttpError(400, 'maskImage must contain image data', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const steps = this.#resolveSteps(selectedModel, options);
|
||||
const costComponents = this.#estimateCost(selectedModel, ratio, steps, {
|
||||
hasInputImage:
|
||||
typeof options.image === 'string' &&
|
||||
options.image.trim() !== '',
|
||||
hasInputImage: !!inputImage,
|
||||
});
|
||||
const totalCostInMicroCents = costComponents.reduce(
|
||||
(acc, component) => acc + component.totalCostMicroCents,
|
||||
@@ -142,6 +207,8 @@ export class CloudflareImageProvider implements IImageProvider {
|
||||
...options,
|
||||
ratio,
|
||||
steps,
|
||||
inputImage,
|
||||
maskBytes,
|
||||
});
|
||||
|
||||
this.#meteringService.batchIncrementUsages(
|
||||
@@ -170,33 +237,39 @@ export class CloudflareImageProvider implements IImageProvider {
|
||||
return found || models.find((m) => m.id === DEFAULT_MODEL)!;
|
||||
}
|
||||
|
||||
#normalizeRatio(ratio?: { w: number; h: number }) {
|
||||
const width = Number(ratio?.w);
|
||||
const height = Number(ratio?.h);
|
||||
if (
|
||||
Number.isFinite(width) &&
|
||||
Number.isFinite(height) &&
|
||||
width > 0 &&
|
||||
height > 0
|
||||
) {
|
||||
return {
|
||||
w: Math.max(64, Math.round(width)),
|
||||
h: Math.max(64, Math.round(height)),
|
||||
};
|
||||
}
|
||||
return { ...DEFAULT_RATIO };
|
||||
#normalizeRatio(params: IGenerateParams, model: CloudflareImageModel) {
|
||||
// The driver resolves every caller-facing size field into `imageSize`.
|
||||
const size = params.imageSize;
|
||||
const { w, h } =
|
||||
size?.kind === 'aspect'
|
||||
? expandAspectRatio(size)
|
||||
: (size ?? DEFAULT_RATIO);
|
||||
const min = model.minDimension ?? 64;
|
||||
const max = model.maxDimension ?? 2500;
|
||||
const scale =
|
||||
size?.kind === 'aspect'
|
||||
? Math.min(
|
||||
max / Math.max(w, h),
|
||||
Math.max(min / Math.min(w, h), 1),
|
||||
)
|
||||
: 1;
|
||||
return {
|
||||
w: Math.min(max, Math.max(min, Math.round(w * scale))),
|
||||
h: Math.min(max, Math.max(min, Math.round(h * scale))),
|
||||
};
|
||||
}
|
||||
|
||||
#resolveSteps(
|
||||
model: CloudflareImageModel,
|
||||
options: CloudflareGenerateParams,
|
||||
): number {
|
||||
if (model.fixedSteps) return model.fixedSteps;
|
||||
const input = Number(
|
||||
options.steps ?? options.num_steps ?? model.defaultSteps ?? 25,
|
||||
);
|
||||
const fallback = model.defaultSteps ?? 25;
|
||||
if (!Number.isFinite(input)) return fallback;
|
||||
return Math.max(1, Math.min(50, Math.round(input)));
|
||||
return Math.max(1, Math.min(model.maxSteps ?? 50, Math.round(input)));
|
||||
}
|
||||
|
||||
// Cloudflare models have *really exact* billing needs. They pretty much bill based on exactly what the model does
|
||||
@@ -329,6 +402,8 @@ export class CloudflareImageProvider implements IImageProvider {
|
||||
params: CloudflareGenerateParams & {
|
||||
ratio: { w: number; h: number };
|
||||
steps: number;
|
||||
inputImage?: { bytes: Buffer; mime: string };
|
||||
maskBytes?: Buffer;
|
||||
},
|
||||
) {
|
||||
const endpoint = `${this.#apiBaseUrl}/accounts/${this.#accountId}/ai/run/${model.id}`;
|
||||
@@ -342,7 +417,8 @@ export class CloudflareImageProvider implements IImageProvider {
|
||||
formData.append('prompt', params.prompt);
|
||||
formData.append('width', String(params.ratio.w));
|
||||
formData.append('height', String(params.ratio.h));
|
||||
formData.append('steps', String(params.steps));
|
||||
if (!model.fixedSteps)
|
||||
formData.append('steps', String(params.steps));
|
||||
|
||||
if (Number.isFinite(params.seed))
|
||||
formData.append(
|
||||
@@ -351,34 +427,43 @@ export class CloudflareImageProvider implements IImageProvider {
|
||||
);
|
||||
if (Number.isFinite(params.guidance))
|
||||
formData.append('guidance', String(params.guidance));
|
||||
if (typeof params.negative_prompt === 'string')
|
||||
formData.append('negative_prompt', params.negative_prompt);
|
||||
if (typeof params.output_format === 'string')
|
||||
formData.append('output_format', params.output_format);
|
||||
if (typeof params.image === 'string')
|
||||
formData.append('image', params.image);
|
||||
if (params.inputImage) {
|
||||
formData.append(
|
||||
'input_image_0',
|
||||
new Blob([new Uint8Array(params.inputImage.bytes)], {
|
||||
type: params.inputImage.mime,
|
||||
}),
|
||||
'input-image',
|
||||
);
|
||||
}
|
||||
body = formData;
|
||||
} else {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
body = JSON.stringify({
|
||||
prompt: params.prompt,
|
||||
width: params.ratio.w,
|
||||
height: params.ratio.h,
|
||||
steps: params.steps,
|
||||
num_steps: params.steps,
|
||||
...(Number.isFinite(params.seed)
|
||||
? { seed: Math.round(params.seed as number) }
|
||||
: {}),
|
||||
...(Number.isFinite(params.guidance)
|
||||
? { guidance: params.guidance }
|
||||
: {}),
|
||||
...(typeof params.negative_prompt === 'string'
|
||||
? { negative_prompt: params.negative_prompt }
|
||||
: {}),
|
||||
...(typeof params.output_format === 'string'
|
||||
? { output_format: params.output_format }
|
||||
: {}),
|
||||
});
|
||||
const request: Record<string, unknown> = { prompt: params.prompt };
|
||||
if (model.fixedRatio) {
|
||||
request.steps = params.steps;
|
||||
} else {
|
||||
request.width = params.ratio.w;
|
||||
request.height = params.ratio.h;
|
||||
request.num_steps = params.steps;
|
||||
if (model.supportsImageBytes && params.inputImage) {
|
||||
request.image = [...params.inputImage.bytes];
|
||||
if (params.maskBytes) request.mask = [...params.maskBytes];
|
||||
if (Number.isFinite(params.strength))
|
||||
request.strength = params.strength;
|
||||
}
|
||||
if (Number.isFinite(params.seed))
|
||||
request.seed = Math.round(params.seed!);
|
||||
if (Number.isFinite(params.guidance))
|
||||
request.guidance = params.guidance;
|
||||
if (
|
||||
model.supportsNegativePrompt &&
|
||||
typeof params.negative_prompt === 'string'
|
||||
) {
|
||||
request.negative_prompt = params.negative_prompt;
|
||||
}
|
||||
}
|
||||
body = JSON.stringify(request);
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
@@ -439,8 +524,8 @@ export class CloudflareImageProvider implements IImageProvider {
|
||||
return imageString;
|
||||
}
|
||||
|
||||
const mime = this.#mimeForFormat(params.output_format);
|
||||
return `data:${mime};base64,${imageString}`;
|
||||
const mime = model.outputMime ?? 'image/png';
|
||||
return imageDataUri(imageString, mime);
|
||||
}
|
||||
|
||||
#extractImageString(payload: unknown): string | undefined {
|
||||
@@ -502,12 +587,6 @@ export class CloudflareImageProvider implements IImageProvider {
|
||||
return (w * h) / 1_000_000;
|
||||
}
|
||||
|
||||
#mimeForFormat(format?: string) {
|
||||
if (format === 'jpeg') return 'image/jpeg';
|
||||
if (format === 'webp') return 'image/webp';
|
||||
return 'image/png';
|
||||
}
|
||||
|
||||
#costForUnits(units: number, microCentsPerUnit?: number) {
|
||||
if (!Number.isFinite(units) || units <= 0) return 0;
|
||||
if (
|
||||
|
||||
@@ -30,15 +30,70 @@ export type CloudflareImageModel = IImageModel & {
|
||||
billingScheme: CloudflareBillingScheme;
|
||||
defaultSteps?: number;
|
||||
requiresMultipart?: boolean;
|
||||
maxSteps?: number;
|
||||
fixedSteps?: number;
|
||||
outputMime?: string;
|
||||
minDimension?: number;
|
||||
maxDimension?: number;
|
||||
fixedRatio?: { w: number; h: number };
|
||||
supportsNegativePrompt?: boolean;
|
||||
supportsImageBytes?: boolean;
|
||||
requiresInputImage?: boolean;
|
||||
};
|
||||
|
||||
// Source: Cloudflare Workers AI docs and model pages.
|
||||
// Pricing values are in USD microcents for billing units.
|
||||
export const CLOUDFLARE_IMAGE_GENERATION_MODELS: CloudflareImageModel[] = [
|
||||
{
|
||||
id: '@cf/bytedance/stable-diffusion-xl-lightning',
|
||||
puterId: 'workers-ai:bytedance/stable-diffusion-xl-lightning',
|
||||
name: 'SDXL Lightning',
|
||||
costs_currency: 'usd-microcents',
|
||||
index_cost_key: 'step',
|
||||
costs: { step: 0 },
|
||||
billingScheme: 'step-only',
|
||||
defaultSteps: 20,
|
||||
maxSteps: 20,
|
||||
minDimension: 256,
|
||||
maxDimension: 2048,
|
||||
supportsNegativePrompt: true,
|
||||
},
|
||||
{
|
||||
id: '@cf/stabilityai/stable-diffusion-xl-base-1.0',
|
||||
puterId: 'workers-ai:stabilityai/stable-diffusion-xl-base-1.0',
|
||||
name: 'Stable Diffusion XL Base 1.0',
|
||||
costs_currency: 'usd-microcents',
|
||||
index_cost_key: 'step',
|
||||
costs: { step: 0 },
|
||||
billingScheme: 'step-only',
|
||||
defaultSteps: 20,
|
||||
maxSteps: 20,
|
||||
minDimension: 256,
|
||||
maxDimension: 2048,
|
||||
supportsNegativePrompt: true,
|
||||
},
|
||||
{
|
||||
id: '@cf/runwayml/stable-diffusion-v1-5-inpainting',
|
||||
puterId: 'workers-ai:runwayml/stable-diffusion-v1-5-inpainting',
|
||||
name: 'Stable Diffusion 1.5 Inpainting',
|
||||
costs_currency: 'usd-microcents',
|
||||
index_cost_key: 'step',
|
||||
costs: { step: 0 },
|
||||
billingScheme: 'step-only',
|
||||
defaultSteps: 20,
|
||||
maxSteps: 20,
|
||||
minDimension: 256,
|
||||
maxDimension: 2048,
|
||||
supportsNegativePrompt: true,
|
||||
supportsImageBytes: true,
|
||||
requiresInputImage: true,
|
||||
},
|
||||
{
|
||||
puterId: 'workers-ai:black-forest-labs/flux.1-schnell',
|
||||
id: '@cf/black-forest-labs/flux-1-schnell',
|
||||
aliases: ['black-forest-labs/flux.1-schnell'],
|
||||
// No bare `black-forest-labs/flux.1-schnell` alias: it is the retired
|
||||
// Together spelling, and a shared alias would route Together callers
|
||||
// here whenever Together is not configured.
|
||||
name: 'FLUX.1 Schnell',
|
||||
costs_currency: 'usd-microcents',
|
||||
index_cost_key: 'step',
|
||||
@@ -48,16 +103,21 @@ export const CLOUDFLARE_IMAGE_GENERATION_MODELS: CloudflareImageModel[] = [
|
||||
},
|
||||
billingScheme: 'tile-plus-step',
|
||||
defaultSteps: 4,
|
||||
maxSteps: 8,
|
||||
outputMime: 'image/jpeg',
|
||||
fixedRatio: { w: 1024, h: 1024 },
|
||||
},
|
||||
{
|
||||
puterId: 'workers-ai:leonardo/lucid-origin',
|
||||
id: '@cf/leonardo/lucid-origin',
|
||||
aliases: ['leonardo/lucid-origin'],
|
||||
name: 'Lucid Origin',
|
||||
maxDimension: 2500,
|
||||
maxSteps: 40,
|
||||
costs_currency: 'usd-microcents',
|
||||
index_cost_key: 'step',
|
||||
costs: {
|
||||
tile_512: 699600,
|
||||
tile_512: 700000,
|
||||
step: 13200,
|
||||
},
|
||||
billingScheme: 'tile-plus-step',
|
||||
@@ -66,8 +126,10 @@ export const CLOUDFLARE_IMAGE_GENERATION_MODELS: CloudflareImageModel[] = [
|
||||
{
|
||||
puterId: 'workers-ai:leonardo/phoenix-1.0',
|
||||
id: '@cf/leonardo/phoenix-1.0',
|
||||
supportsNegativePrompt: true,
|
||||
aliases: ['leonardo/phoenix-1.0'],
|
||||
name: 'Phoenix 1.0',
|
||||
maxDimension: 2048,
|
||||
costs_currency: 'usd-microcents',
|
||||
index_cost_key: 'step',
|
||||
costs: {
|
||||
@@ -91,6 +153,8 @@ export const CLOUDFLARE_IMAGE_GENERATION_MODELS: CloudflareImageModel[] = [
|
||||
billingScheme: 'flux2-dev-tile-step',
|
||||
defaultSteps: 25,
|
||||
requiresMultipart: true,
|
||||
minDimension: 256,
|
||||
maxDimension: 1920,
|
||||
},
|
||||
{
|
||||
puterId: 'workers-ai:black-forest-labs/flux.2-klein-4b',
|
||||
@@ -104,7 +168,10 @@ export const CLOUDFLARE_IMAGE_GENERATION_MODELS: CloudflareImageModel[] = [
|
||||
output_tile_512: 28700,
|
||||
},
|
||||
billingScheme: 'flux2-klein-4b-tile',
|
||||
fixedSteps: 4,
|
||||
requiresMultipart: true,
|
||||
minDimension: 256,
|
||||
maxDimension: 1920,
|
||||
},
|
||||
{
|
||||
puterId: 'workers-ai:black-forest-labs/flux.2-klein-9b',
|
||||
@@ -119,6 +186,9 @@ export const CLOUDFLARE_IMAGE_GENERATION_MODELS: CloudflareImageModel[] = [
|
||||
input_image_mp: 200000,
|
||||
},
|
||||
billingScheme: 'flux2-klein-9b-mp',
|
||||
fixedSteps: 4,
|
||||
requiresMultipart: true,
|
||||
minDimension: 256,
|
||||
maxDimension: 1920,
|
||||
},
|
||||
];
|
||||
@@ -20,11 +20,10 @@
|
||||
/**
|
||||
* Offline unit tests for GeminiImageProvider.
|
||||
*
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
* redis) and constructs GeminiImageProvider directly against the
|
||||
* live wired `MeteringService` so the recording side runs end-to-end.
|
||||
* The Google GenAI SDK is mocked at the module boundary — that's the
|
||||
* real network egress point.
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) and
|
||||
* constructs GeminiImageProvider directly against the live wired
|
||||
* `MeteringService` so the recording side runs end-to-end. The Google GenAI SDK
|
||||
* is mocked at the module boundary — that's the real network egress point.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -197,6 +196,90 @@ describe('GeminiImageProvider.generate argument validation', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(generateContentMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['gemini-3-pro-image', 'high'],
|
||||
['gemini-3-pro-image', ' 8K '],
|
||||
['gemini-3.1-flash-lite-image', '2K'],
|
||||
])(
|
||||
'rejects an unsupported quality tier %s/%j before pricing',
|
||||
async (model, quality) => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
makeProvider().generate({ model, prompt: 'hi', quality }),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
message: expect.stringContaining('Unsupported quality tier'),
|
||||
});
|
||||
expect(hasCreditsSpy).not.toHaveBeenCalled();
|
||||
expect(generateContentMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('resolves quality case-insensitively to the catalog tier', async () => {
|
||||
generateContentMock.mockResolvedValueOnce({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: 'BASE64IMG',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: { promptTokenCount: 12 },
|
||||
});
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: 'gemini-3-pro-image',
|
||||
prompt: 'hi',
|
||||
quality: ' 2k ',
|
||||
}),
|
||||
);
|
||||
expect(generateContentMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
imageConfig: expect.objectContaining({ imageSize: '2K' }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores quality on a model without tiers', async () => {
|
||||
generateContentMock.mockResolvedValueOnce({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: 'BASE64IMG',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: { promptTokenCount: 12 },
|
||||
});
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model: 'gemini-2.5-flash-image',
|
||||
prompt: 'hi',
|
||||
quality: 'high',
|
||||
}),
|
||||
);
|
||||
const config = generateContentMock.mock.calls[0][0].config;
|
||||
expect(config.imageConfig).not.toHaveProperty('imageSize');
|
||||
});
|
||||
});
|
||||
|
||||
// ── generateContent (Flash) path ────────────────────────────────────
|
||||
@@ -227,6 +310,30 @@ describe('GeminiImageProvider.generate Flash path (generateContent)', () => {
|
||||
},
|
||||
};
|
||||
|
||||
it.each(['gemini-3-pro-image-preview', 'gemini-3.1-flash-image-preview'])(
|
||||
'routes %s to its stable endpoint and accepts equivalent pixel ratios',
|
||||
async (model) => {
|
||||
generateContentMock.mockResolvedValueOnce(inlineImageResponse);
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
model,
|
||||
prompt: 'a landscape',
|
||||
ratio: { w: 1600, h: 900 },
|
||||
}),
|
||||
);
|
||||
expect(generateContentMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: model.replace('-preview', ''),
|
||||
config: expect.objectContaining({
|
||||
imageConfig: expect.objectContaining({
|
||||
aspectRatio: '16:9',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('forwards prompt + aspectRatio config and routes to generateContent', async () => {
|
||||
const provider = makeProvider();
|
||||
generateContentMock.mockResolvedValueOnce(inlineImageResponse);
|
||||
@@ -246,7 +353,7 @@ describe('GeminiImageProvider.generate Flash path (generateContent)', () => {
|
||||
expect(sent.config.imageConfig.aspectRatio).toBe('16:9');
|
||||
});
|
||||
|
||||
it('falls back to the first allowedRatio when an invalid ratio is supplied', async () => {
|
||||
it('snaps an unlisted ratio to the nearest allowed aspect', async () => {
|
||||
const provider = makeProvider();
|
||||
generateContentMock.mockResolvedValueOnce(inlineImageResponse);
|
||||
|
||||
@@ -367,3 +474,47 @@ describe('GeminiImageProvider.generate Flash path (generateContent)', () => {
|
||||
expect(imageEntry?.usageAmount).toBe(1290);
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a nearby aspect hint to the closest Gemini aspect', async () => {
|
||||
generateContentMock.mockResolvedValueOnce({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{ inlineData: { mimeType: 'image/png', data: 'AAAA' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await withTestActor(() =>
|
||||
makeProvider().generate({
|
||||
prompt: 'hi',
|
||||
model: 'gemini-2.5-flash-image',
|
||||
ratio: { w: 17, h: 10 },
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
generateContentMock.mock.calls[0][0].config.imageConfig.aspectRatio,
|
||||
).toBe('16:9');
|
||||
});
|
||||
|
||||
describe('Gemini image standard pricing', () => {
|
||||
it('meters Flash Image 3.1 input and text at standard rather than batch rates', async () => {
|
||||
generateContentMock.mockResolvedValueOnce({
|
||||
candidates: [{ content: { parts: [{ inlineData: { mimeType: 'image/png', data: 'AAAA' } }] } }],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 1200,
|
||||
candidatesTokensDetails: [{ modality: 'IMAGE', tokenCount: 1120 }, { modality: 'TEXT', tokenCount: 80 }],
|
||||
thoughtsTokenCount: 20,
|
||||
},
|
||||
});
|
||||
await withTestActor(() => makeProvider().generate({ model: 'gemini-3.1-flash-image', prompt: 'A cup' }));
|
||||
expect(batchIncrementUsagesSpy).toHaveBeenCalledWith(expect.anything(), expect.arrayContaining([
|
||||
expect.objectContaining({ usageType: 'gemini:gemini-3.1-flash-image:input', costOverride: 5000 }),
|
||||
expect.objectContaining({ usageType: 'gemini:gemini-3.1-flash-image:output:text', costOverride: 30000 }),
|
||||
expect.objectContaining({ usageType: 'gemini:gemini-3.1-flash-image:output:image', costOverride: 6720000 }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,8 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { closestAspectRatio } from '../../imageDimensions.js';
|
||||
import { assertImagePrompt } from '../../imageValidation.js';
|
||||
import { GenerateContentResponse, GoogleGenAI } from '@google/genai';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
@@ -74,26 +76,22 @@ export class GeminiImageProvider implements IImageProvider {
|
||||
let { ratio, input_images, quality } = params;
|
||||
|
||||
const selectedModel =
|
||||
this.models().find((m) => m.id === model) ||
|
||||
this.models().find((m) => m.id === this.getDefaultModel())!;
|
||||
this.models().find(
|
||||
(m) => m.id === model || m.aliases?.includes(model ?? ''),
|
||||
) || this.models().find((m) => m.id === this.getDefaultModel())!;
|
||||
|
||||
if (test_mode) {
|
||||
return 'https://puter-sample-data.puter.site/image_example.png';
|
||||
}
|
||||
|
||||
if (typeof prompt !== 'string' || prompt.trim().length === 0) {
|
||||
throw new HttpError(400, '`prompt` must be a non-empty string', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
assertImagePrompt(prompt);
|
||||
|
||||
const allowedRatios = selectedModel.allowedRatios ?? [
|
||||
GEMINI_DEFAULT_RATIO,
|
||||
];
|
||||
ratio =
|
||||
ratio && this.#isValidRatio(ratio, allowedRatios)
|
||||
? ratio
|
||||
: allowedRatios[0];
|
||||
ratio = ratio
|
||||
? closestAspectRatio(ratio, allowedRatios)
|
||||
: allowedRatios[0];
|
||||
|
||||
// Backwards compat: merge singular input_image into input_images
|
||||
if (input_image && (!input_images || input_images.length === 0)) {
|
||||
@@ -127,6 +125,11 @@ export class GeminiImageProvider implements IImageProvider {
|
||||
}
|
||||
|
||||
const actor = Context.get('actor');
|
||||
if (!actor) {
|
||||
throw new HttpError(401, 'actor not found in context', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
}
|
||||
|
||||
// --- Pre-flight cost estimation ---
|
||||
const inputImageCount = input_images?.length ?? 0;
|
||||
@@ -138,10 +141,29 @@ export class GeminiImageProvider implements IImageProvider {
|
||||
selectedModel.costs.input,
|
||||
);
|
||||
|
||||
if (!quality) {
|
||||
quality = selectedModel.allowedQualityLevels?.[0] ?? '';
|
||||
params.quality = quality;
|
||||
// Tiered models resolve `quality` case-insensitively against their
|
||||
// catalog levels, as Together and xAI do; a tier the model lacks is
|
||||
// the caller's mistake, not a missing token-table entry.
|
||||
const tiers = (selectedModel.allowedQualityLevels ?? []).filter(
|
||||
Boolean,
|
||||
);
|
||||
if (quality) {
|
||||
const wanted = quality.trim().toLowerCase();
|
||||
const requestedTier = tiers.find(
|
||||
(tier) => tier.toLowerCase() === wanted,
|
||||
);
|
||||
if (tiers.length && !requestedTier) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Unsupported quality tier: ${quality}. Expected ${tiers.join(', ')}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
quality = requestedTier ?? '';
|
||||
} else {
|
||||
quality = tiers[0] ?? '';
|
||||
}
|
||||
params.quality = quality;
|
||||
|
||||
// Estimate output image tokens
|
||||
const imageTokenKey = quality
|
||||
@@ -345,7 +367,7 @@ export class GeminiImageProvider implements IImageProvider {
|
||||
if (!Number.isFinite(centsPerMillion) || (centsPerMillion ?? 0) <= 0)
|
||||
return 0;
|
||||
|
||||
return (tokenCount / 1_000_000) * (centsPerMillion as number);
|
||||
return (tokenCount * (centsPerMillion as number)) / 1_000_000;
|
||||
}
|
||||
|
||||
#toMicroCents(cents: number): number {
|
||||
@@ -405,11 +427,4 @@ export class GeminiImageProvider implements IImageProvider {
|
||||
|
||||
return { mimeType, base64: data.substring(commaIdx + 1) };
|
||||
}
|
||||
|
||||
#isValidRatio(
|
||||
ratio: { w: number; h: number },
|
||||
allowedRatios: { w: number; h: number }[],
|
||||
) {
|
||||
return allowedRatios.some((r) => r.w === ratio.w && r.h === ratio.h);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 338 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user