mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f380172b7d | ||
|
|
8c31f9177c | ||
|
|
5531f381f5 | ||
|
|
8a198b6f0a | ||
|
|
17dc879ff5 | ||
|
|
d3321b1d0b | ||
|
|
a52f32e0d2 | ||
|
|
086e3a0028 | ||
|
|
aabbafdaab | ||
|
|
97ab0ee12b | ||
|
|
358b680b69 | ||
|
|
e534a3d09a | ||
|
|
5fd07e9ed7 | ||
|
|
e138f1e9d8 | ||
|
|
7886907bdf | ||
|
|
0eec08df98 | ||
|
|
4b5d6e034c | ||
|
|
7d53cc7ed6 | ||
|
|
86f871c9a8 | ||
|
|
c038a6983f |
No files matched your search
@@ -31,10 +31,9 @@ jobs:
|
||||
run: npm ci
|
||||
|
||||
- name: Run AI provider integration tests
|
||||
# Vitest treats positional args as filename substring filters.
|
||||
# The pattern matches every `*.integration.test.ts` file and
|
||||
# nothing else — the regular backend test suite stays out.
|
||||
run: npm run test:backend -- integration.test
|
||||
# Only the provider tests: the other `*.integration.test.ts`
|
||||
# files need no credentials and run with the backend suite.
|
||||
run: npm run test:backend -- $(git ls-files 'src/backend/drivers/ai/**/*.integration.test.ts')
|
||||
env:
|
||||
CI: 'true'
|
||||
PUTER_TEST_AI_CLAUDE_API_KEY: ${{ secrets.PUTER_TEST_AI_CLAUDE_API_KEY }}
|
||||
@@ -49,6 +48,15 @@ jobs:
|
||||
PUTER_TEST_AI_MOONSHOT_API_KEY: ${{ secrets.PUTER_TEST_AI_MOONSHOT_API_KEY }}
|
||||
PUTER_TEST_AI_ZAI_API_KEY: ${{ secrets.PUTER_TEST_AI_ZAI_API_KEY }}
|
||||
PUTER_TEST_AI_ELEVENLABS_API_KEY: ${{ secrets.PUTER_TEST_AI_ELEVENLABS_API_KEY }}
|
||||
PUTER_TEST_AI_ALIBABA_API_KEY: ${{ secrets.PUTER_TEST_AI_ALIBABA_API_KEY }}
|
||||
PUTER_TEST_AI_AZURE_OPENAI_API_KEY: ${{ secrets.PUTER_TEST_AI_AZURE_OPENAI_API_KEY }}
|
||||
PUTER_TEST_AI_AZURE_OPENAI_API_URL: ${{ secrets.PUTER_TEST_AI_AZURE_OPENAI_API_URL }}
|
||||
PUTER_TEST_AI_BYTEPLUS_API_KEY: ${{ secrets.PUTER_TEST_AI_BYTEPLUS_API_KEY }}
|
||||
PUTER_TEST_AI_HOONIFY_API_KEY: ${{ secrets.PUTER_TEST_AI_HOONIFY_API_KEY }}
|
||||
PUTER_TEST_AI_INFRON_API_KEY: ${{ secrets.PUTER_TEST_AI_INFRON_API_KEY }}
|
||||
PUTER_TEST_AI_KLING_API_KEY: ${{ secrets.PUTER_TEST_AI_KLING_API_KEY }}
|
||||
PUTER_TEST_AI_META_API_KEY: ${{ secrets.PUTER_TEST_AI_META_API_KEY }}
|
||||
PUTER_TEST_AI_MINIMAX_API_KEY: ${{ secrets.PUTER_TEST_AI_MINIMAX_API_KEY }}
|
||||
|
||||
# Realtime conversations through the SDK against live OpenAI. Builds
|
||||
# the SDK bundle these tests load itself, so it runs even when the
|
||||
|
||||
+2
-1
@@ -131,10 +131,11 @@ What the suites assert:
|
||||
- A malformed request never fails with an error that has no status, which `/drivers/call` would report as an unclassified 500.
|
||||
- For chat, with the vendors answering, a malformed request also never fails with a 5xx and never marks a route unhealthy.
|
||||
- When the upstream misbehaves, chat ends with an answer or an `HttpError`. A stream only has to end: the error line a stream carries isn't checked, so a malformed event can still end one with a 500.
|
||||
- Requests reach the vendors, and with the vendors answering some chat requests are answered, so a suite that stops exercising the providers fails.
|
||||
|
||||
The vendors are played at the HTTP boundary by `upstream` in [shared/fuzzTestUtil.ts](../src/backend/drivers/ai/shared/fuzzTestUtil.ts), which stands in for global `fetch`, undici `fetch` and axios, and makes `https.request` throw so an SDK with its own client fails the suite. The vendors' SDKs run, except Groq's, which is swapped for the OpenAI SDK, and the AWS clients, whose `send` is stubbed. The media suite calls the drivers as a user with unlimited metering, so a request gets past file input and the credit hold to the vendor. A provider that loads its catalog from the vendor registers no models there, so the suites skip it.
|
||||
|
||||
A failure lists each distinct finding with its case number and input. The random source is seeded, so the same case fails again on the next run. To search further, raise the case count:
|
||||
A failure lists each distinct finding with its case number and input. Each case is generated from the seed and its own number, so the same case fails again on the next run. Routes are picked by rendezvous hashing: a model added to a catalog takes only the cases it wins, and every other case keeps its route. To search further, raise the case count:
|
||||
|
||||
```sh
|
||||
PUTER_FUZZ_CASES=100000 npx vitest run --config src/backend/vitest.config.ts src/backend/drivers/ai/interfaces/chat/ChatCompletionDriver.fuzz.test.ts
|
||||
|
||||
@@ -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 { PuterController } from '../types.js';
|
||||
|
||||
const ALLOWED_LAYOUTS = ['icons', 'details', 'list'];
|
||||
const ALLOWED_SORT_BY = ['name', 'size', 'modified', 'type'];
|
||||
const ALLOWED_SORT_ORDER = ['asc', 'desc'];
|
||||
|
||||
/**
|
||||
* Folder view preference routes: layout and sort_by/sort_order, stored on the
|
||||
* folder's fsentries row.
|
||||
*/
|
||||
/**
|
||||
* Desktop preference writes — layout, sort order. Each persists on every call,
|
||||
* and the GUI fires them on direct user action, so a per-minute ceiling well
|
||||
* above human speed is enough to catch a stuck client.
|
||||
*/
|
||||
const PREFERENCE_WRITE_LIMIT = {
|
||||
scope: 'desktop-preference',
|
||||
limit: 120,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
};
|
||||
|
||||
export class DesktopController extends PuterController {
|
||||
registerRoutes(router) {
|
||||
// -- Folder layout -------------------------------------------
|
||||
|
||||
router.post(
|
||||
'/set_layout',
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: PREFERENCE_WRITE_LIMIT,
|
||||
},
|
||||
async (req, res) => {
|
||||
const { item_uid, item_path, layout } = req.body ?? {};
|
||||
if (!layout || !ALLOWED_LAYOUTS.includes(layout)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`\`layout\` must be one of: ${ALLOWED_LAYOUTS.join(', ')}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
await this.#updateFSEntry(
|
||||
req.actor,
|
||||
{ item_uid, item_path },
|
||||
{ layout },
|
||||
);
|
||||
res.json({});
|
||||
},
|
||||
);
|
||||
|
||||
// -- Folder sort ---------------------------------------------
|
||||
|
||||
router.post(
|
||||
'/set_sort_by',
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: PREFERENCE_WRITE_LIMIT,
|
||||
},
|
||||
async (req, res) => {
|
||||
const { item_uid, item_path, sort_by, sort_order } =
|
||||
req.body ?? {};
|
||||
if (!sort_by || !ALLOWED_SORT_BY.includes(sort_by)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`\`sort_by\` must be one of: ${ALLOWED_SORT_BY.join(', ')}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
const resolvedOrder = sort_order ?? 'asc';
|
||||
if (!ALLOWED_SORT_ORDER.includes(resolvedOrder)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`\`sort_order\` must be one of: ${ALLOWED_SORT_ORDER.join(', ')}`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
await this.#updateFSEntry(
|
||||
req.actor,
|
||||
{ item_uid, item_path },
|
||||
{
|
||||
sort_by,
|
||||
sort_order: resolvedOrder,
|
||||
},
|
||||
);
|
||||
res.json({});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// -- Helpers ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update columns on an actor-owned fsentry. Accepts either `item_uid` (fast
|
||||
* path) or `item_path` (path lookups in FSEntryStore now have a
|
||||
* recursive-CTE fallback for legacy rows with a NULL `path` column, so this
|
||||
* works for old accounts too).
|
||||
*
|
||||
* Ownership: `entry.user_id === actor.user.id`. Kept as added validation
|
||||
* against bad paths — the previous "drop user_id entirely" theory turned
|
||||
* out to be wrong (the actual legacy issue was NULL paths, not user_id
|
||||
* drift), so this filter doesn't lock out old accounts in practice.
|
||||
*/
|
||||
async #updateFSEntry(actor, { item_uid, item_path }, patch) {
|
||||
if (!item_uid && !item_path) {
|
||||
throw new HttpError(400, 'Missing `item_uid` or `item_path`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
const entry = item_uid
|
||||
? await this.stores.fsEntry.getEntryByUuid(item_uid)
|
||||
: await this.stores.fsEntry.getEntryByPath(item_path);
|
||||
if (!entry) {
|
||||
throw new HttpError(404, 'Item not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
}
|
||||
|
||||
const actorUserId = actor?.user?.id;
|
||||
if (typeof actorUserId !== 'number' || entry.userId !== actorUserId) {
|
||||
throw new HttpError(403, 'Not allowed to update this item', {
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
}
|
||||
|
||||
const keys = Object.keys(patch);
|
||||
const setClause = keys.map((k) => `\`${k}\` = ?`).join(', ');
|
||||
const values = keys.map((k) => patch[k]);
|
||||
|
||||
await this.clients.db.write(
|
||||
`UPDATE \`fsentries\` SET ${setClause} WHERE \`id\` = ?`,
|
||||
[...values, entry.id],
|
||||
);
|
||||
|
||||
await this.stores.fsEntry.invalidateEntryCacheByUuid(entry.uuid);
|
||||
}
|
||||
|
||||
onServerStart() {}
|
||||
onServerPrepareShutdown() {}
|
||||
onServerShutdown() {}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
/*
|
||||
* 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, RequestHandler, Response } from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
//
|
||||
// Boots one real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
// redis) and re-registers DesktopController's inline lambda routes
|
||||
// onto a fresh PuterRouter so each handler is reachable. Each test
|
||||
// makes its own user via `makeUser` and exercises the live controller
|
||||
// against the real wired stores (user, fsEntry) and DB client.
|
||||
|
||||
let server: PuterServer;
|
||||
let router: PuterRouter;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
router = new PuterRouter();
|
||||
server.controllers.desktop.registerRoutes(router);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
|
||||
const username = `dc-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
await generateDefaultFsentries(
|
||||
server.clients.db,
|
||||
server.stores.user,
|
||||
created,
|
||||
);
|
||||
const refreshed = (await server.stores.user.getById(created.id))!;
|
||||
return {
|
||||
userId: refreshed.id,
|
||||
actor: {
|
||||
user: {
|
||||
id: refreshed.id,
|
||||
uuid: refreshed.uuid,
|
||||
username: refreshed.username,
|
||||
email: refreshed.email ?? null,
|
||||
email_confirmed: true,
|
||||
} as Actor['user'],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
interface CapturedResponse {
|
||||
statusCode: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
const makeReq = (init: {
|
||||
body?: unknown;
|
||||
actor?: Actor;
|
||||
}): Request => {
|
||||
return {
|
||||
body: init.body ?? {},
|
||||
query: {},
|
||||
headers: {},
|
||||
actor: init.actor,
|
||||
} as unknown as Request;
|
||||
};
|
||||
|
||||
const makeRes = () => {
|
||||
const captured: CapturedResponse = { statusCode: 200, body: undefined };
|
||||
const res = {
|
||||
json: vi.fn((value: unknown) => {
|
||||
captured.body = value;
|
||||
return res;
|
||||
}),
|
||||
status: vi.fn((code: number) => {
|
||||
captured.statusCode = code;
|
||||
return res;
|
||||
}),
|
||||
setHeader: vi.fn(() => res),
|
||||
};
|
||||
return { res: res as unknown as Response, captured };
|
||||
};
|
||||
|
||||
const findHandler = (method: string, path: string): RequestHandler => {
|
||||
const route = router.routes.find(
|
||||
(r) => r.method === method && r.path === path,
|
||||
);
|
||||
if (!route) throw new Error(`No ${method.toUpperCase()} ${path} route`);
|
||||
return route.handler;
|
||||
};
|
||||
|
||||
const callRoute = async (
|
||||
method: string,
|
||||
path: string,
|
||||
req: Request,
|
||||
res: Response,
|
||||
) => {
|
||||
const handler = findHandler(method, path);
|
||||
await handler(req, res, () => {
|
||||
throw new Error('handler called next() unexpectedly');
|
||||
});
|
||||
};
|
||||
|
||||
describe('DesktopController routes', () => {
|
||||
it('has no desktop background route', () => {
|
||||
expect(
|
||||
router.routes.some((r) => r.path === '/set-desktop-bg'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('has no taskbar items route', () => {
|
||||
expect(
|
||||
router.routes.some((r) => r.path === '/update-taskbar-items'),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── /set_layout ─────────────────────────────────────────────────────
|
||||
|
||||
describe('DesktopController POST /set_layout', () => {
|
||||
it('persists the new layout on the matching fsentry', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const documents = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
);
|
||||
expect(documents).not.toBeNull();
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set_layout',
|
||||
makeReq({
|
||||
body: { item_uid: documents!.uuid, layout: 'icons' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({});
|
||||
|
||||
const refreshed = await server.stores.fsEntry.getEntryByUuid(
|
||||
documents!.uuid,
|
||||
);
|
||||
expect(refreshed?.layout).toBe('icons');
|
||||
});
|
||||
|
||||
it('resolves by item_path when item_uid is omitted', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const path = `/${username}/Pictures`;
|
||||
|
||||
const { res } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set_layout',
|
||||
makeReq({
|
||||
body: { item_path: path, layout: 'list' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
const refreshed = await server.stores.fsEntry.getEntryByPath(path);
|
||||
expect(refreshed?.layout).toBe('list');
|
||||
});
|
||||
|
||||
it('throws 400 for an unknown layout value', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const documents = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
);
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set_layout',
|
||||
makeReq({
|
||||
body: { item_uid: documents!.uuid, layout: 'gallery' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws 400 when neither item_uid nor item_path is supplied', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set_layout',
|
||||
makeReq({ body: { layout: 'icons' }, actor }),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws 404 when the fsentry cannot be resolved', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set_layout',
|
||||
makeReq({
|
||||
body: {
|
||||
item_uid: '00000000-0000-0000-0000-000000000000',
|
||||
layout: 'icons',
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it("throws 403 when the fsentry belongs to another user", async () => {
|
||||
const owner = await makeUser();
|
||||
const intruder = await makeUser();
|
||||
const ownerUsername = owner.actor.user!.username!;
|
||||
const ownerEntry = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${ownerUsername}/Documents`,
|
||||
);
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set_layout',
|
||||
makeReq({
|
||||
body: { item_uid: ownerEntry!.uuid, layout: 'icons' },
|
||||
actor: intruder.actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
|
||||
// Owner's entry remains untouched.
|
||||
const refreshed = await server.stores.fsEntry.getEntryByUuid(
|
||||
ownerEntry!.uuid,
|
||||
);
|
||||
expect(refreshed?.layout).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── /set_sort_by ────────────────────────────────────────────────────
|
||||
|
||||
describe('DesktopController POST /set_sort_by', () => {
|
||||
it('persists sort_by + sort_order on the matching fsentry', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const documents = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set_sort_by',
|
||||
makeReq({
|
||||
body: {
|
||||
item_uid: documents!.uuid,
|
||||
sort_by: 'name',
|
||||
sort_order: 'desc',
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({});
|
||||
|
||||
const refreshed = await server.stores.fsEntry.getEntryByUuid(
|
||||
documents!.uuid,
|
||||
);
|
||||
expect(refreshed?.sortBy).toBe('name');
|
||||
expect(refreshed?.sortOrder).toBe('desc');
|
||||
});
|
||||
|
||||
it('defaults sort_order to "asc" when omitted', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const pictures = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Pictures`,
|
||||
);
|
||||
|
||||
const { res } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set_sort_by',
|
||||
makeReq({
|
||||
body: { item_uid: pictures!.uuid, sort_by: 'modified' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
const refreshed = await server.stores.fsEntry.getEntryByUuid(
|
||||
pictures!.uuid,
|
||||
);
|
||||
expect(refreshed?.sortBy).toBe('modified');
|
||||
expect(refreshed?.sortOrder).toBe('asc');
|
||||
});
|
||||
|
||||
it('throws 400 for an unknown sort_by value', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const docs = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
);
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set_sort_by',
|
||||
makeReq({
|
||||
body: { item_uid: docs!.uuid, sort_by: 'random' },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws 400 for an invalid sort_order', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const docs = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
);
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set_sort_by',
|
||||
makeReq({
|
||||
body: {
|
||||
item_uid: docs!.uuid,
|
||||
sort_by: 'name',
|
||||
sort_order: 'sideways',
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
@@ -324,7 +324,7 @@ describe('DriverController — concurrent acquire/release', () => {
|
||||
const driver = {
|
||||
...makeSyntheticDriver(),
|
||||
ping: async () => {
|
||||
Context.get('keepDriverSlot')?.(
|
||||
Context.get('keepConcurrencySlot')?.(
|
||||
new Promise<void>((resolve) => {
|
||||
settleWork = resolve;
|
||||
}),
|
||||
|
||||
@@ -51,7 +51,7 @@ import { PermissionUtil } from '../../services/permission/permissionUtil.js';
|
||||
import type { WithLifecycle } from '../../types';
|
||||
import { withSpan } from '../../util/span.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { CallSlot } from './callSlot.js';
|
||||
import { CallSlot } from '../../core/http/callSlot.js';
|
||||
|
||||
type DriverInstance = WithLifecycle & Record<string, unknown>;
|
||||
|
||||
@@ -446,7 +446,7 @@ export class DriverController extends PuterController {
|
||||
});
|
||||
}
|
||||
slot.attach(handle, res);
|
||||
Context.set('keepDriverSlot', (work) => slot.keep(work));
|
||||
Context.set('keepConcurrencySlot', (work) => slot.keep(work));
|
||||
}
|
||||
|
||||
// Stash the requested driver name in Context so multi-provider
|
||||
|
||||
@@ -21,6 +21,7 @@ import { AppController } from './apps/AppController.js';
|
||||
import { AppFeedbackController } from './feedback/AppFeedbackController.js';
|
||||
import { AuthController } from './auth/AuthController.js';
|
||||
import { BroadcastController } from './broadcast/BroadcastController.js';
|
||||
import { DesktopController } from './desktop/DesktopController.js';
|
||||
import { DriverController } from './drivers/DriverController.js';
|
||||
import { EventsController } from './events/EventsController.js';
|
||||
import { FSController } from './fs/FSController.js';
|
||||
@@ -48,6 +49,7 @@ export const puterControllers = {
|
||||
profile: ProfileController,
|
||||
apps: AppController,
|
||||
appFeedback: AppFeedbackController,
|
||||
desktop: DesktopController,
|
||||
hosting: HostingController,
|
||||
system: SystemController,
|
||||
fs: FSController,
|
||||
|
||||
@@ -1304,13 +1304,18 @@ export class PuterAIController extends PuterController {
|
||||
return driver;
|
||||
}
|
||||
|
||||
/** A completion that stops, and stops billing, when the caller hangs up. */
|
||||
/**
|
||||
* A completion whose caller may hang up. It runs on to its usage report
|
||||
* either way, so it holds the route's concurrency slot until it settles.
|
||||
*/
|
||||
#complete(
|
||||
res: Response,
|
||||
args: ICompleteArguments,
|
||||
): Promise<IChatCompleteResult> {
|
||||
Context.set('abortSignal', abortOnDisconnect(res));
|
||||
return this.#driver().complete(args);
|
||||
const completion = this.#driver().complete(args);
|
||||
Context.get('keepConcurrencySlot')?.(completion);
|
||||
return completion;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,11 +69,11 @@ export interface KnownContextFields {
|
||||
*/
|
||||
abortSignal: AbortSignal;
|
||||
/**
|
||||
* Keeps the call's concurrency slot until `work` settles (set by
|
||||
* DriverController when the method is capped), for work that outlives the
|
||||
* response.
|
||||
* Keeps the request's concurrency slot until `work` settles, for work that
|
||||
* outlives the response. Set by DriverController for a capped method and by
|
||||
* the route concurrency gate.
|
||||
*/
|
||||
keepDriverSlot: (work: Promise<unknown>) => void;
|
||||
keepConcurrencySlot: (work: Promise<unknown>) => void;
|
||||
/**
|
||||
* Aborts the chat driver's upstream request. A client disconnect doesn't
|
||||
* fire it: the upstream bills a request it has accepted either way, so the
|
||||
@@ -102,7 +102,7 @@ const KNOWN_KEY_FLAGS: Record<keyof KnownContextFields, true> = {
|
||||
requestId: true,
|
||||
driverName: true,
|
||||
abortSignal: true,
|
||||
keepDriverSlot: true,
|
||||
keepConcurrencySlot: true,
|
||||
upstreamSignal: true,
|
||||
strictUpstreamErrors: true,
|
||||
platformWrite: true,
|
||||
|
||||
File renamed without changes.
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { Response } from 'express';
|
||||
import { onResponseClose } from '../../core/http/onResponseClose.js';
|
||||
import { onResponseClose } from './onResponseClose.js';
|
||||
|
||||
// Under the concurrency store's one-hour orphan TTL.
|
||||
const SLOT_RENEW_MS = 10 * 60 * 1000;
|
||||
@@ -29,9 +29,9 @@ interface SlotHandle {
|
||||
}
|
||||
|
||||
/**
|
||||
* A driver call's concurrency slot, held until the response is done and the
|
||||
* call's work has settled, including work a driver carries on after the caller
|
||||
* hung up or was answered.
|
||||
* A request's concurrency slot, held until the response is done and the work
|
||||
* registered with `keep` has settled, including work that carries on after the
|
||||
* caller hung up or was answered.
|
||||
*/
|
||||
export class CallSlot {
|
||||
#handle?: SlotHandle;
|
||||
@@ -20,8 +20,9 @@
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { withSpan } from '../../../util/span.js';
|
||||
import { Context } from '../../context.js';
|
||||
import { CallSlot } from '../callSlot.js';
|
||||
import { HttpError } from '../HttpError.js';
|
||||
import { onResponseClose } from '../onResponseClose.js';
|
||||
import { subscriptionOverride } from '../../../services/metering/consts.js';
|
||||
|
||||
/**
|
||||
@@ -413,8 +414,8 @@ async function peekKv(kv, key, limit) {
|
||||
//
|
||||
// Lifecycle is the wedge between rate and concurrent limits: rate just
|
||||
// records a tick, concurrent has to track "still in flight" → "done"
|
||||
// across a request boundary. The route middleware hooks `res.finish` /
|
||||
// `res.close`; the driver helper wraps the invocation in `try/finally`.
|
||||
// across a request boundary. The route middleware and the driver controller
|
||||
// both hold the slot in a `CallSlot`.
|
||||
|
||||
// Orphan TTL safety nets — used when the process dies between acquire
|
||||
// and release. Memory backend doesn't need one (the process is also
|
||||
@@ -1138,8 +1139,9 @@ async function resolveSubscriptionLimit(req, opts) {
|
||||
* bySubscription: { user_free: 2, unlimited: 50 } } } { concurrent: { limit:
|
||||
* 10, backend: 'redis', scope: 'expensive-op' } }
|
||||
*
|
||||
* On accept, schedules release on `res.finish` / `res.close` so even aborted
|
||||
* requests give their slot back. On reject, 429 with the same
|
||||
* On accept, holds the slot until the response is done and any work the handler
|
||||
* registered through `keepConcurrencySlot` has settled, so work that outlives a
|
||||
* caller who hung up still counts. On reject, 429 with the same
|
||||
* `too_many_requests` legacyCode as the rate gate (clients already handle that
|
||||
* branch). Fails open on backend error.
|
||||
*/
|
||||
@@ -1165,13 +1167,11 @@ export function concurrencyGate(opts) {
|
||||
return;
|
||||
}
|
||||
|
||||
// `finish` (response sent) and `close` (connection closed, possibly
|
||||
// aborted before finish) can both fire; the guard releases once.
|
||||
const { release } = guardSlot(slot, '[concurrent]');
|
||||
const onDone = () => void release();
|
||||
res.once('finish', onDone);
|
||||
onResponseClose(res, onDone);
|
||||
if (res.destroyed || res.writableFinished) onDone();
|
||||
const held = new CallSlot();
|
||||
held.attach(guardSlot(slot, '[concurrent]'), res);
|
||||
if (Context.current()) {
|
||||
Context.set('keepConcurrencySlot', (work) => held.keep(work));
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Context, runWithContext } from '../../context.js';
|
||||
import { isHttpError } from '../HttpError.js';
|
||||
import { setupTestServer } from '../../../testUtil.ts';
|
||||
import {
|
||||
@@ -977,6 +978,27 @@ describe('concurrencyGate — memory backend', () => {
|
||||
expect(isHttpError(third.err)).toBe(true);
|
||||
});
|
||||
|
||||
it('holds the slot past the response for work the handler keeps', async () => {
|
||||
const opts = { limit: 1, key: 'ip', scope: 'cg-kept-work' };
|
||||
let finishWork;
|
||||
const work = new Promise((resolve) => {
|
||||
finishWork = resolve;
|
||||
});
|
||||
const res = makeRes();
|
||||
await runWithContext({}, async () => {
|
||||
await concurrencyGate(opts)(baseReq(), res, () => {
|
||||
Context.get('keepConcurrencySlot')(work);
|
||||
});
|
||||
});
|
||||
res.emit('close');
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect((await runGate(opts, baseReq())).err?.statusCode).toBe(429);
|
||||
|
||||
finishWork();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect((await runGate(opts, baseReq())).err).toBeUndefined();
|
||||
});
|
||||
|
||||
it('different keys do not share slots', async () => {
|
||||
const opts = { limit: 1, key: 'ip', scope: 'cg-keyed' };
|
||||
const a = await runGate(opts, baseReq({ ip: '1.1.1.1' }));
|
||||
|
||||
@@ -263,10 +263,10 @@ export interface RouteOptions {
|
||||
rateLimit?: RouteRateLimit | RouteRateLimit[];
|
||||
|
||||
/**
|
||||
* Cap simultaneous in-flight requests per key; slots are released on
|
||||
* response finish or close. `bySubscription` overrides `limit` per
|
||||
* `SubscriptionPolicy.id` when metering is wired in. `key`, `scope`,
|
||||
* `backend` as in `rateLimit`.
|
||||
* Cap simultaneous in-flight requests per key; a slot is released once the
|
||||
* response is done and any work the handler kept it for has settled.
|
||||
* `bySubscription` overrides `limit` per `SubscriptionPolicy.id` when
|
||||
* metering is wired in. `key`, `scope`, `backend` as in `rateLimit`.
|
||||
*/
|
||||
concurrent?: {
|
||||
limit: number;
|
||||
|
||||
@@ -129,6 +129,17 @@ describe('model availability through real HTTP', () => {
|
||||
meteringEnforcement: { subscriptions: false },
|
||||
} as never);
|
||||
}, 120000);
|
||||
it('serves qualified chat model names through HTTP', async () => {
|
||||
const response = await fetch(
|
||||
new URL('/puterai/chat/models', env.apiOrigin),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const { models } = (await response.json()) as { models: string[] };
|
||||
expect(models).toContain('openrouter:vendor/availability-test');
|
||||
expect(models.every((id) => /^[^:/]+:[^/]+\/.+/.test(id))).toBe(true);
|
||||
expect(models).not.toContain('vendor/availability-test');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
upstream?.closeAllConnections();
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* 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 { createServer, type Server, type ServerResponse } from 'node:http';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../../../testUtil.js';
|
||||
|
||||
/**
|
||||
* A completion runs on to its usage report after the caller hangs up, so it
|
||||
* keeps counting against the caller's concurrency cap until it settles.
|
||||
*/
|
||||
describe('chat concurrency over HTTP after the caller hangs up', () => {
|
||||
let upstream: Server;
|
||||
let env: PuterTestEnv;
|
||||
let onRequest: (res: ServerResponse, body: string) => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
upstream = createServer((req, res) => {
|
||||
if (req.url === '/api/tags') {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ models: [{ name: 'slot-test' }] }));
|
||||
return;
|
||||
}
|
||||
let body = '';
|
||||
req.setEncoding('utf8');
|
||||
req.on('data', (chunk: string) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.once('end', () => onRequest(res, body));
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
upstream.listen(0, '127.0.0.1', resolve),
|
||||
);
|
||||
const port = (upstream.address() as { port: number }).port;
|
||||
env = await setupPuterTestEnv({
|
||||
providers: {
|
||||
ollama: { apiBaseUrl: `http://127.0.0.1:${port}` },
|
||||
},
|
||||
meteringEnforcement: { subscriptions: false },
|
||||
} as never);
|
||||
await vi.waitFor(async () => {
|
||||
expect(
|
||||
(await env.server.drivers.aiChat.models()).some(
|
||||
(model) => model.id === 'ollama:ollama/slot-test',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
upstream?.closeAllConnections();
|
||||
if (upstream)
|
||||
await new Promise<void>((resolve) =>
|
||||
upstream.close(() => resolve()),
|
||||
);
|
||||
});
|
||||
|
||||
const call = (route: string, stream: boolean, signal?: AbortSignal) => {
|
||||
const args = {
|
||||
model: 'ollama:ollama/slot-test',
|
||||
stream,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
max_tokens: 16,
|
||||
};
|
||||
return fetch(new URL(route, env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${env.users.user.apiToken}`,
|
||||
Origin: env.apiOrigin,
|
||||
},
|
||||
body: JSON.stringify(
|
||||
route === '/drivers/call'
|
||||
? {
|
||||
interface: 'puter-chat-completion',
|
||||
method: 'complete',
|
||||
args,
|
||||
}
|
||||
: args,
|
||||
),
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
const usage = { prompt_tokens: 3, completion_tokens: 5, total_tokens: 8 };
|
||||
const chunk = (delta: object, finish: string | null, extra = {}) =>
|
||||
`data: ${JSON.stringify({
|
||||
id: 'chatcmpl_1',
|
||||
object: 'chat.completion.chunk',
|
||||
choices: [{ index: 0, delta, finish_reason: finish }],
|
||||
...extra,
|
||||
})}\n\n`;
|
||||
|
||||
const finishAnswer = (res: ServerResponse, body: string) => {
|
||||
if (!(JSON.parse(body) as { stream?: boolean }).stream) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: 'chatcmpl_1',
|
||||
object: 'chat.completion',
|
||||
created: 0,
|
||||
model: 'slot-test',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'done' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!res.headersSent)
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.end(
|
||||
`${chunk({ content: 'done' }, 'stop')}data: ${JSON.stringify({
|
||||
id: 'chatcmpl_1',
|
||||
object: 'chat.completion.chunk',
|
||||
choices: [],
|
||||
usage,
|
||||
})}\n\ndata: [DONE]\n\n`,
|
||||
);
|
||||
};
|
||||
|
||||
/** Starts a completion, waits until it is generating, then hangs up. */
|
||||
const abandon = async (route: string, stream: boolean) => {
|
||||
let received!: () => void;
|
||||
const reached = new Promise<void>((resolve) => {
|
||||
received = resolve;
|
||||
});
|
||||
const pending: { res: ServerResponse; body: string }[] = [];
|
||||
onRequest = (res, body) => {
|
||||
pending.push({ res, body });
|
||||
if (stream) {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.write(chunk({ role: 'assistant', content: 'partial' }, null));
|
||||
}
|
||||
received();
|
||||
};
|
||||
const abort = new AbortController();
|
||||
const request = call(route, stream, abort.signal);
|
||||
const outcome = await Promise.race([
|
||||
reached.then(() => 'generating' as const),
|
||||
request.then((response) => response),
|
||||
]);
|
||||
if (outcome !== 'generating') {
|
||||
return { refused: outcome, pending };
|
||||
}
|
||||
if (stream) {
|
||||
const response = await request;
|
||||
const reader = response.body!.getReader();
|
||||
let text = '';
|
||||
while (!text.includes('partial')) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
text += new TextDecoder().decode(value);
|
||||
}
|
||||
}
|
||||
abort.abort();
|
||||
await request.catch(() => undefined);
|
||||
return { refused: undefined, pending };
|
||||
};
|
||||
|
||||
it.each([
|
||||
{ route: '/drivers/call', stream: true },
|
||||
{ route: '/drivers/call', stream: false },
|
||||
{ route: '/puterai/openai/v1/chat/completions', stream: true },
|
||||
{ route: '/puterai/openai/v1/chat/completions', stream: false },
|
||||
{ route: '/puterai/anthropic/v1/messages', stream: true },
|
||||
])(
|
||||
'counts an abandoned completion until it settles: $route (stream=$stream)',
|
||||
async ({ route, stream }) => {
|
||||
const running: { res: ServerResponse; body: string }[] = [];
|
||||
let refused: Response | undefined;
|
||||
try {
|
||||
for (let i = 0; i < 25 && !refused; i++) {
|
||||
const attempt = await abandon(route, stream);
|
||||
running.push(...attempt.pending);
|
||||
refused = attempt.refused;
|
||||
// Let the server see the hang-up before the next call.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
expect(refused?.status).toBe(429);
|
||||
expect(await refused!.text()).toContain(
|
||||
'Too many concurrent requests',
|
||||
);
|
||||
expect(running.length).toBeGreaterThan(0);
|
||||
|
||||
for (const { res, body } of running.splice(0))
|
||||
finishAnswer(res, body);
|
||||
onRequest = finishAnswer;
|
||||
await expect
|
||||
.poll(async () => (await call(route, false)).status, {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBe(200);
|
||||
} finally {
|
||||
for (const { res, body } of running) finishAnswer(res, body);
|
||||
upstream.closeAllConnections();
|
||||
// The next case starts with a fresh rate-limit window.
|
||||
await new Promise((resolve) => setTimeout(resolve, 10_100));
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
@@ -1231,4 +1231,41 @@ describe('ChatCompletionDriver streaming failure handling', () => {
|
||||
expect(events).toContainEqual({ type: 'text', text: 'hello' });
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('frees the concurrency slot before the provider cleanup hook finishes', async () => {
|
||||
let finishCleanup!: () => void;
|
||||
const cleanup = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishCleanup = resolve;
|
||||
}),
|
||||
);
|
||||
vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValue({
|
||||
stream: true,
|
||||
init_chat_stream: async ({
|
||||
chatStream,
|
||||
}: {
|
||||
chatStream: { end: (usage?: Record<string, number>) => void };
|
||||
}) => {
|
||||
chatStream.end({ input_tokens: 1, output_tokens: 1 });
|
||||
},
|
||||
finally_fn: cleanup,
|
||||
} as never);
|
||||
|
||||
const kept: Promise<unknown>[] = [];
|
||||
const result = (await withTestActor(() => {
|
||||
Context.set('keepConcurrencySlot', (work) => kept.push(work));
|
||||
return fakeOnlyDriver.complete({
|
||||
model: 'fake',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: true,
|
||||
} as never);
|
||||
})) as unknown as { stream: Readable };
|
||||
await collect(result.stream);
|
||||
|
||||
expect(kept).toHaveLength(1);
|
||||
await kept[0];
|
||||
await vi.waitFor(() => expect(cleanup).toHaveBeenCalledTimes(1));
|
||||
finishCleanup();
|
||||
});
|
||||
});
|
||||
@@ -31,14 +31,15 @@ import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { kv } from '../../../../util/kvSingleton.js';
|
||||
import { isDriverStreamResult } from '../../../meta.js';
|
||||
import {
|
||||
caseRandom,
|
||||
corruptReply,
|
||||
Findings,
|
||||
fuzzCases,
|
||||
hostileValue,
|
||||
mutate,
|
||||
pick,
|
||||
pickStable,
|
||||
type Random,
|
||||
seededRandom,
|
||||
unclassifiedFailure,
|
||||
upstream,
|
||||
type UpstreamReply,
|
||||
@@ -591,7 +592,11 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
const pickRoute = (random: Random) =>
|
||||
pick(random, routes.get(pick(random, [...routes.keys()]))!);
|
||||
pickStable(
|
||||
random,
|
||||
routes.get(pickStable(random, [...routes.keys()], (name) => name))!,
|
||||
(route) => route.id,
|
||||
);
|
||||
|
||||
/** Runs one completion to its end, stream included, and returns its failure. */
|
||||
const complete = async (args: Record<string, unknown>): Promise<unknown> => {
|
||||
@@ -635,10 +640,11 @@ describe('ChatCompletionDriver seeded fuzzing', () => {
|
||||
upstream.respond = healthyUpstream;
|
||||
upstream.unanswered.clear();
|
||||
clearUnhealthyRoutes();
|
||||
const random = seededRandom(0xc4a7);
|
||||
const findings = new Findings();
|
||||
let answered = 0;
|
||||
|
||||
for (let index = 0; index < fuzzCases(3000); index++) {
|
||||
const random = caseRandom(0xc4a7, index);
|
||||
const route = pickRoute(random);
|
||||
const args: Record<string, unknown> = {
|
||||
...mutate(pick(random, CORPUS), random, OPTION_NAMES),
|
||||
@@ -652,6 +658,7 @@ describe('ChatCompletionDriver seeded fuzzing', () => {
|
||||
}
|
||||
const label = `${route.provider}:${route.id}`;
|
||||
const error = await complete(args);
|
||||
if (!error) answered++;
|
||||
const unclassified = error && unclassifiedFailure(error);
|
||||
if (unclassified) findings.add(unclassified, index, label, args);
|
||||
else if (isHttpError(error) && error.statusCode >= 500) {
|
||||
@@ -677,13 +684,16 @@ describe('ChatCompletionDriver seeded fuzzing', () => {
|
||||
findings.add(`no upstream fixture for ${request}`, -1, '', {});
|
||||
}
|
||||
findings.assertNone();
|
||||
// A run where nothing is answered has stopped reaching the providers.
|
||||
expect(answered).toBeGreaterThan(0);
|
||||
}, 600_000);
|
||||
|
||||
it('ends every request with an answer or a classified error when the upstream misbehaves', async () => {
|
||||
const random = seededRandom(0x0b5e);
|
||||
const findings = new Findings();
|
||||
const askedBefore = upstream.asked;
|
||||
|
||||
for (let index = 0; index < fuzzCases(2000); index++) {
|
||||
const random = caseRandom(0x0b5e, index);
|
||||
const route = pickRoute(random);
|
||||
const replies: unknown[] = [];
|
||||
upstream.respond = (request) => {
|
||||
@@ -711,5 +721,6 @@ describe('ChatCompletionDriver seeded fuzzing', () => {
|
||||
clearUnhealthyRoutes();
|
||||
}
|
||||
findings.assertNone();
|
||||
expect(upstream.asked).toBeGreaterThan(askedBefore);
|
||||
}, 600_000);
|
||||
});
|
||||
@@ -207,6 +207,50 @@ describe('ChatCompletionDriver model map', () => {
|
||||
d.onServerShutdown();
|
||||
});
|
||||
|
||||
it('lists qualified model ids without publishing aliases as models', async () => {
|
||||
const ids = [
|
||||
'kimi/kimi-3',
|
||||
'ollama:plain-model',
|
||||
'ollama:vendor/qualified',
|
||||
'vendor/tagged:free',
|
||||
];
|
||||
vi.spyOn(OllamaChatProvider.prototype, 'models').mockResolvedValue(
|
||||
ids.map((id) => ({
|
||||
id,
|
||||
aliases: ['legacy-' + id],
|
||||
costs_currency: 'usd-cents',
|
||||
costs: {},
|
||||
max_tokens: 8192,
|
||||
})),
|
||||
);
|
||||
const d = newDriver({ ollama: {} });
|
||||
await d.onServerStart();
|
||||
try {
|
||||
expect(
|
||||
(await d.list()).filter(
|
||||
(id) => !['abuse', 'costly', 'fake'].includes(id),
|
||||
),
|
||||
).toEqual([
|
||||
'ollama:kimi/kimi-3',
|
||||
'ollama:ollama/plain-model',
|
||||
'ollama:vendor/qualified',
|
||||
'ollama:vendor/tagged:free',
|
||||
]);
|
||||
const models = (await d.models()).filter(
|
||||
(model) => model.provider === 'ollama',
|
||||
);
|
||||
expect(models.map((model) => model.id).sort()).toEqual(
|
||||
[...ids].sort(),
|
||||
);
|
||||
for (const model of models) {
|
||||
expect(model.aliases).toContain(model.puterId);
|
||||
expect(model.aliases).toContain('legacy-' + model.id);
|
||||
}
|
||||
} finally {
|
||||
d.onServerShutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('lists the catalog to a caller that asks while it is still loading', async () => {
|
||||
const d = newDriver({ ollama: { enabled: false } });
|
||||
d.onServerStart();
|
||||
|
||||
@@ -1308,8 +1308,17 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
}, HOLD_RENEW_INTERVAL_MS);
|
||||
renewHold.unref?.();
|
||||
|
||||
// Fire-and-forget — the stream writes happen async while the
|
||||
// response is being piped to the client.
|
||||
// The upstream runs on after a caller hangs up, so the request
|
||||
// keeps its concurrency slot until the upstream is done, not until
|
||||
// the cleanup after it.
|
||||
let upstreamDone!: () => void;
|
||||
const upstreamRunning = new Promise<void>((resolve) => {
|
||||
upstreamDone = resolve;
|
||||
});
|
||||
Context.get('keepConcurrencySlot')?.(upstreamRunning);
|
||||
|
||||
// The stream writes happen async while the response is piped to
|
||||
// the client.
|
||||
(async () => {
|
||||
try {
|
||||
await init({ chatStream });
|
||||
@@ -1334,6 +1343,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
}
|
||||
passthrough.end();
|
||||
} finally {
|
||||
upstreamDone();
|
||||
clearInterval(renewHold);
|
||||
clearTimeout(detachedLimit);
|
||||
abortSignal?.removeEventListener('abort', detachStream);
|
||||
@@ -2076,14 +2086,39 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
// instance, so they are read and never written: normalizing
|
||||
// the id or appending puterId in place would accumulate across
|
||||
// instantiations. The bucket gets its own copy instead.
|
||||
const aliases =
|
||||
entry.puterId &&
|
||||
!(entry.aliases ?? []).includes(entry.puterId)
|
||||
? [...(entry.aliases ?? []), entry.puterId]
|
||||
: entry.aliases;
|
||||
// Keep upstream ids for provider calls and publish a qualified
|
||||
// routing name even when a catalog omits its vendor namespace.
|
||||
const publicId = entry.puterId || entry.id;
|
||||
// A colon after the first slash belongs to the model name, as
|
||||
// in `vendor/model:free`, and names no service.
|
||||
const serviceSeparator = publicId
|
||||
.split('/', 1)[0]!
|
||||
.indexOf(':');
|
||||
const service =
|
||||
serviceSeparator < 0
|
||||
? providerName
|
||||
: publicId.slice(0, serviceSeparator);
|
||||
const modelPath =
|
||||
serviceSeparator < 0
|
||||
? publicId
|
||||
: publicId.slice(serviceSeparator + 1);
|
||||
const puterId =
|
||||
providerName === 'fake-chat'
|
||||
? entry.puterId
|
||||
: `${service}:${modelPath.includes('/') ? modelPath : `${service}/${modelPath}`}`;
|
||||
const aliases = puterId
|
||||
? [
|
||||
...new Set([
|
||||
...(entry.aliases ?? []),
|
||||
...(entry.puterId ? [entry.puterId] : []),
|
||||
puterId,
|
||||
]),
|
||||
]
|
||||
: entry.aliases;
|
||||
const model = {
|
||||
...entry,
|
||||
id: normalizeModelKey(entry.id),
|
||||
...(puterId ? { puterId } : {}),
|
||||
};
|
||||
// Assigned only when the entry has names to carry: models()
|
||||
// is serialized to the API, and an entry that declared no
|
||||
|
||||
@@ -496,6 +496,19 @@ describe('ImageGenerationDriver.generate provider routing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores an input image type that is not a string when there is no input image', async () => {
|
||||
openaiImagesGenerateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://oai/image.png' }],
|
||||
});
|
||||
await expect(withDriverName('ai-image', () =>
|
||||
driver.generate({
|
||||
model: 'gpt-image-2',
|
||||
prompt: 'a landscape',
|
||||
input_image_mime_type: 5,
|
||||
} as never),
|
||||
)).resolves.toBe('https://oai/image.png');
|
||||
});
|
||||
|
||||
it('rejects an excluded model regardless of caller casing', async () => {
|
||||
await expect(withActor(() =>
|
||||
driver.generate({
|
||||
|
||||
@@ -177,10 +177,14 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
// settled here — once — before any provider runs.
|
||||
assertImagePrompt(args.prompt);
|
||||
assertInputImagesShape(args, 'image generation');
|
||||
// The type hint describes an input image, so it is only read, and
|
||||
// only has to be a string, when the request carries one.
|
||||
const hasInputImage =
|
||||
args.input_image != null || (args.input_images?.length ?? 0) > 0;
|
||||
for (const option of [
|
||||
'quality',
|
||||
'resolution',
|
||||
'input_image_mime_type',
|
||||
...(hasInputImage ? (['input_image_mime_type'] as const) : []),
|
||||
] as const) {
|
||||
if (args[option] != null && typeof args[option] !== 'string') {
|
||||
throw new HttpError(400, `${option} must be a string`, {
|
||||
@@ -193,6 +197,7 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
// 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;
|
||||
if (!hasInputImage) delete request.input_image_mime_type;
|
||||
|
||||
// Validate the output path early — before spending credits.
|
||||
const resolvedOutputPath = puterOutputPath
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
fuzzCases,
|
||||
hostileValue,
|
||||
pick,
|
||||
pickStable,
|
||||
type Random,
|
||||
upstream,
|
||||
} from '../shared/fuzzTestUtil.js';
|
||||
@@ -362,9 +363,12 @@ describe.each(TARGETS)('$name driver seeded fuzzing', (target) => {
|
||||
|
||||
const pinned = (random: Random) => {
|
||||
const route: Record<string, unknown> = {
|
||||
...pick(
|
||||
...pickStable(
|
||||
random,
|
||||
byProvider.get(pick(random, target.providers))!,
|
||||
byProvider.get(
|
||||
pickStable(random, target.providers, (name) => name),
|
||||
)!,
|
||||
(candidate) => String(candidate.model),
|
||||
),
|
||||
};
|
||||
// Now and then the route itself is the malformed part.
|
||||
@@ -374,6 +378,7 @@ describe.each(TARGETS)('$name driver seeded fuzzing', (target) => {
|
||||
}
|
||||
return route;
|
||||
};
|
||||
const askedBefore = upstream.asked;
|
||||
const findings = await fuzzCalls({
|
||||
seed: 0x3ed1a,
|
||||
cases: fuzzCases(1000),
|
||||
@@ -392,5 +397,7 @@ describe.each(TARGETS)('$name driver seeded fuzzing', (target) => {
|
||||
},
|
||||
});
|
||||
findings.assertNone();
|
||||
// Some of the requests have to get as far as a vendor.
|
||||
expect(upstream.asked).toBeGreaterThan(askedBefore);
|
||||
}, 600_000);
|
||||
});
|
||||
@@ -135,9 +135,11 @@ export class OCRDriver extends PuterDriver {
|
||||
const metering = this.#aiMetering;
|
||||
|
||||
const textract = providers['aws-textract'] as
|
||||
Record<string, unknown> | undefined;
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const textractAws = (textract?.aws ?? textract) as
|
||||
Record<string, unknown> | undefined;
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const textractCredentials = awsCredentialsFrom(textractAws);
|
||||
if (textractCredentials) {
|
||||
this.#textract = new TextractOcrProvider(metering, {
|
||||
|
||||
@@ -115,9 +115,11 @@ export class VoiceChangerDriver extends PuterDriver {
|
||||
apiKey,
|
||||
apiBaseUrl: elevenlabs?.apiBaseUrl as string | undefined,
|
||||
defaultVoiceId: elevenlabs?.defaultVoiceId as
|
||||
string | undefined,
|
||||
| string
|
||||
| undefined,
|
||||
defaultModelId: elevenlabs?.speechToSpeechModelId as
|
||||
string | undefined,
|
||||
| string
|
||||
| undefined,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -340,6 +340,22 @@ describe('SpeechToTextDriver argument validation', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws 400 when prompt is a list instead of text', async () => {
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
driver.transcribe({
|
||||
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
|
||||
prompt: [null, 'names'],
|
||||
} as never),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: 'prompt must be a string',
|
||||
});
|
||||
expect(transcriptionsCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects diarized_json, which gpt-transcribe does not produce', async () => {
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
|
||||
@@ -552,6 +552,8 @@ describe('VideoGenerationDriver.generate provider routing', () => {
|
||||
'kling-3.0',
|
||||
'kling-3.0-omni',
|
||||
'kling-3.0-turbo',
|
||||
'kling-4.0',
|
||||
'kling-4.0-flash',
|
||||
'kling-o1',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -558,7 +558,8 @@ export const splitCachedPrompt = (usage) => {
|
||||
* @param {{ writeTtl?: '5m' | '30m' }} [opts] The lifetime `cache_write_tokens`
|
||||
* were written with: 5m unless the provider says otherwise (OpenAI's default
|
||||
* entries live 30m).
|
||||
* @returns {import('../../interfaces/chat/types.js').UsageDetails | undefined}
|
||||
* @returns {import('../../interfaces/chat/types.js').UsageDetails
|
||||
* | undefined}
|
||||
*/
|
||||
export const usageDetailsFromTrackedUsage = (
|
||||
trackedUsage,
|
||||
|
||||
+33
-1
@@ -113,7 +113,6 @@ describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
expect(typeof text === 'string' && text.length > 0).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
},
|
||||
);
|
||||
|
||||
@@ -377,3 +376,36 @@ describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
'Gemini 4 Carbon and Hydrogen (integration)',
|
||||
() => {
|
||||
it.each([
|
||||
['gemini-4-carbon', 'low'],
|
||||
['gemini-4-hydrogen', 'xhigh'],
|
||||
])(
|
||||
'generates a native text completion from %s at %s effort',
|
||||
{ timeout: INTEGRATION_TEST_TIMEOUT_MS },
|
||||
async (model, effort) => {
|
||||
const provider = new GeminiChatProvider(
|
||||
{ apiKey: optionalEnv(ENV_VAR)! },
|
||||
makeMeteringStub(),
|
||||
);
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model,
|
||||
messages: [{ role: 'user', content: 'Reply with OK.' }],
|
||||
reasoning_effort: effort,
|
||||
max_tokens: 32768,
|
||||
} as ICompleteArguments),
|
||||
);
|
||||
const message = result as IChatMessageResult;
|
||||
expect(typeof message.message.content).toBe('string');
|
||||
expect(
|
||||
(message.message.content as string).trim().length,
|
||||
).toBeGreaterThan(0);
|
||||
expectBilledOutput(message.usage, message.usageDetails);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -114,6 +114,8 @@ const makeProvider = (
|
||||
const ARGON = GEMINI_MODELS.find((m) => m.id === 'gemini-4-argon')!;
|
||||
const LAUNCH = new Date('2026-10-14T12:00:00Z');
|
||||
const NATIVE_IDS = [
|
||||
'gemini-4-hydrogen',
|
||||
'gemini-4-carbon',
|
||||
'gemini-4-argon',
|
||||
'gemini-3.1-flash-lite',
|
||||
'gemini-3-flash-preview',
|
||||
@@ -1616,3 +1618,119 @@ describe('geminiNativeClient deadline', () => {
|
||||
expect(isUpstreamTimeoutError(err)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gemini 4 Carbon', () => {
|
||||
it.each([
|
||||
['2026-11-30T23:59:59.999Z', 200, 1000, 10],
|
||||
['2026-12-01T00:00:00.000Z', 400, 2000, 20],
|
||||
])(
|
||||
'routes natively and meters thinking at %s',
|
||||
async (date, input, output, cached) => {
|
||||
generateContent.mockResolvedValueOnce(
|
||||
textResponse('OK', { ...LIVE_USAGE, cachedContentTokenCount: 6 }),
|
||||
);
|
||||
await complete(
|
||||
{
|
||||
model: 'google/gemini-4-carbon',
|
||||
temperature: 0.5,
|
||||
reasoning_effort: 'medium',
|
||||
},
|
||||
new Date(date),
|
||||
);
|
||||
expect(requestOf().model).toBe('gemini-4-carbon');
|
||||
expect(requestOf().config).not.toHaveProperty('temperature');
|
||||
expect(openAICreate).not.toHaveBeenCalled();
|
||||
expect(recordSpy.mock.calls[0]![3]).toMatchObject({
|
||||
prompt_tokens: 30 * input,
|
||||
completion_tokens: 5 * output,
|
||||
thinking_tokens: 69 * output,
|
||||
cached_tokens: 6 * cached,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['no max_tokens', undefined],
|
||||
['a larger max_tokens', 65_536],
|
||||
])(
|
||||
'caps a non-streamed request with %s at 32,768 output tokens',
|
||||
async (_label, maxTokens) => {
|
||||
generateContent.mockResolvedValueOnce(textResponse('OK'));
|
||||
await complete({ model: 'gemini-4-carbon', max_tokens: maxTokens });
|
||||
expect(requestOf().config.maxOutputTokens).toBe(32_768);
|
||||
},
|
||||
);
|
||||
|
||||
it('sends a stream the max_tokens it asked for', async () => {
|
||||
generateContentStream.mockResolvedValueOnce(
|
||||
asAsyncIterable([textResponse('OK')]),
|
||||
);
|
||||
await runStream({ model: 'gemini-4-carbon', max_tokens: 65_536 });
|
||||
expect(requestOf(generateContentStream).config.maxOutputTokens).toBe(
|
||||
65_536,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gemini 4 Hydrogen', () => {
|
||||
const hydrogen = (args: Partial<ICompleteArguments>) =>
|
||||
complete(
|
||||
{ model: 'gemini-4-hydrogen', ...args },
|
||||
new Date('2026-12-01T00:00:00.000Z'),
|
||||
);
|
||||
|
||||
it('routes natively and meters thinking at its own rates', async () => {
|
||||
generateContent.mockResolvedValueOnce(
|
||||
textResponse('OK', { ...LIVE_USAGE, cachedContentTokenCount: 6 }),
|
||||
);
|
||||
await hydrogen({ model: 'google/gemini-4-hydrogen', temperature: 0.5 });
|
||||
expect(requestOf().model).toBe('gemini-4-hydrogen');
|
||||
expect(requestOf().config).not.toHaveProperty('temperature');
|
||||
expect(requestOf().config).not.toHaveProperty('thinkingConfig');
|
||||
expect(openAICreate).not.toHaveBeenCalled();
|
||||
expect(recordSpy.mock.calls[0]![2]).toBe('gemini:gemini-4-hydrogen');
|
||||
expect(recordSpy.mock.calls[0]![3]).toMatchObject({
|
||||
prompt_tokens: 30 * 150,
|
||||
completion_tokens: 5 * 750,
|
||||
thinking_tokens: 69 * 750,
|
||||
cached_tokens: 6 * 7.5,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['low', 'LOW'],
|
||||
['medium', 'MEDIUM'],
|
||||
['high', 'HIGH'],
|
||||
['xhigh', 'XHIGH'],
|
||||
])('sends reasoning_effort %s as thinkingLevel %s', async (effort, level) => {
|
||||
generateContent.mockResolvedValueOnce(textResponse('OK'));
|
||||
await hydrogen({
|
||||
reasoning_effort: effort as ICompleteArguments['reasoning_effort'],
|
||||
});
|
||||
expect(requestOf().config.thinkingConfig).toEqual({
|
||||
thinkingLevel: level,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['none', 'minimal', 'max'])(
|
||||
'rejects reasoning_effort %s before calling Google',
|
||||
async (effort) => {
|
||||
await expect(
|
||||
hydrogen({
|
||||
reasoning_effort:
|
||||
effort as ICompleteArguments['reasoning_effort'],
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
expect(generateContent).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('sends a non-streamed request the max_tokens it asked for', async () => {
|
||||
generateContent.mockResolvedValueOnce(textResponse('OK'));
|
||||
await hydrogen({ max_tokens: 65_536 });
|
||||
expect(requestOf().config.maxOutputTokens).toBe(65_536);
|
||||
});
|
||||
});
|
||||
@@ -42,22 +42,18 @@ import {
|
||||
} from '../../../protocols/openaiCompatible/OpenAICompatProvider.js';
|
||||
import { usesGoogleGeminiEndpoint } from '../geminiRest.js';
|
||||
import { geminiNativeClient } from './geminiNative.js';
|
||||
import {
|
||||
GEMINI_MODELS,
|
||||
GEMINI_NATIVE_MODELS,
|
||||
GEMINI_ARGON_NON_STREAM_MAX_TOKENS,
|
||||
} from './models.js';
|
||||
import { GEMINI_MODELS, GEMINI_NATIVE_MODELS } from './models.js';
|
||||
|
||||
type GroundingContent = { grounding_metadata?: unknown };
|
||||
|
||||
/** Unary Argon uses a lower output cap than streaming. */
|
||||
/** Some native models have a lower output cap when the response isn't streamed. */
|
||||
const nativeOutputCap = (
|
||||
args: ICompleteArguments,
|
||||
model: IChatModel,
|
||||
): number | undefined =>
|
||||
model.id === 'gemini-4-argon' && !args.stream
|
||||
? GEMINI_ARGON_NON_STREAM_MAX_TOKENS
|
||||
: undefined;
|
||||
args.stream
|
||||
? undefined
|
||||
: GEMINI_NATIVE_MODELS.get(model.id)?.unaryMaxTokens;
|
||||
|
||||
const COMPAT_BASE_URL =
|
||||
'https://generativelanguage.googleapis.com/v1beta/openai/';
|
||||
|
||||
@@ -78,7 +78,7 @@ interface ChatParams {
|
||||
tool_choice?: ChatToolChoice;
|
||||
max_completion_tokens?: number;
|
||||
temperature?: number;
|
||||
reasoning_effort?: 'minimal' | 'low' | 'medium' | 'high';
|
||||
reasoning_effort?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
stream?: boolean;
|
||||
service_tier?: string;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface GeminiNativeOptions {
|
||||
reasoningEfforts: ReadonlySet<ReasoningEffort>;
|
||||
/** False where Google ignores temperature, so it isn't sent. */
|
||||
temperature: boolean;
|
||||
/** Output cap of a response that isn't streamed, where Google's is lower. */
|
||||
unaryMaxTokens?: number;
|
||||
}
|
||||
|
||||
const FLASH_NATIVE_OPTIONS: GeminiNativeOptions = {
|
||||
@@ -36,16 +38,25 @@ const FLASH_NATIVE_OPTIONS: GeminiNativeOptions = {
|
||||
temperature: true,
|
||||
};
|
||||
|
||||
// Google's model metadata reports the unary cap; streaming keeps the catalog cap.
|
||||
const ARGON_CARBON_NATIVE_OPTIONS: GeminiNativeOptions = {
|
||||
reasoningEfforts: new Set(['low', 'medium', 'high']),
|
||||
temperature: false,
|
||||
unaryMaxTokens: 32_768,
|
||||
};
|
||||
|
||||
/**
|
||||
* Served natively: the OpenAI-compatible endpoint omits these models' thinking
|
||||
* count from usage.
|
||||
*/
|
||||
export const GEMINI_NATIVE_MODELS: ReadonlyMap<string, GeminiNativeOptions> =
|
||||
new Map([
|
||||
['gemini-4-argon', ARGON_CARBON_NATIVE_OPTIONS],
|
||||
['gemini-4-carbon', ARGON_CARBON_NATIVE_OPTIONS],
|
||||
[
|
||||
'gemini-4-argon',
|
||||
'gemini-4-hydrogen',
|
||||
{
|
||||
reasoningEfforts: new Set(['low', 'medium', 'high']),
|
||||
reasoningEfforts: new Set(['low', 'medium', 'high', 'xhigh']),
|
||||
temperature: false,
|
||||
},
|
||||
],
|
||||
@@ -88,7 +99,7 @@ const FLASH_LIST_PRICING = listPricingFrom(
|
||||
FLASH_LAUNCH_COSTS,
|
||||
);
|
||||
|
||||
const ARGON_LAUNCH_COSTS: ModelCost = {
|
||||
const GEMINI_4_LAUNCH_COSTS: ModelCost = {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200,
|
||||
completion_tokens: 1000,
|
||||
@@ -119,9 +130,6 @@ const PRO_LONG_CONTEXT_PRICING: ModelPricingOverride[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// Google's unary model metadata reports this cap; streaming keeps the catalog cap.
|
||||
export const GEMINI_ARGON_NON_STREAM_MAX_TOKENS = 32_768;
|
||||
|
||||
export const GEMINI_MODELS: IChatModel[] = [
|
||||
{
|
||||
puterId: 'google:google/gemini-4-argon',
|
||||
@@ -143,12 +151,66 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.8 },
|
||||
costs: ARGON_LAUNCH_COSTS,
|
||||
costs: GEMINI_4_LAUNCH_COSTS,
|
||||
pricingOverrides: listPricingFrom(
|
||||
'2026-12-01T00:00:00Z',
|
||||
ARGON_LAUNCH_COSTS,
|
||||
GEMINI_4_LAUNCH_COSTS,
|
||||
),
|
||||
},
|
||||
{
|
||||
puterId: 'google:google/gemini-4-carbon',
|
||||
id: 'gemini-4-carbon',
|
||||
// The native adapter forwards only text and image parts.
|
||||
modalities: {
|
||||
input: ['text', 'image'],
|
||||
output: ['text'],
|
||||
},
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
release_date: '2026-11-10',
|
||||
name: 'Gemini 4 Carbon',
|
||||
aliases: ['google/gemini-4-carbon'],
|
||||
// One window shared by input, thinking and output.
|
||||
context: 1_048_576,
|
||||
max_tokens: 65_536,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.8 },
|
||||
costs: GEMINI_4_LAUNCH_COSTS,
|
||||
pricingOverrides: listPricingFrom(
|
||||
'2026-12-01T00:00:00Z',
|
||||
GEMINI_4_LAUNCH_COSTS,
|
||||
),
|
||||
},
|
||||
{
|
||||
puterId: 'google:google/gemini-4-hydrogen',
|
||||
id: 'gemini-4-hydrogen',
|
||||
// The native adapter forwards only text and image parts.
|
||||
modalities: {
|
||||
input: ['text', 'image'],
|
||||
output: ['text'],
|
||||
},
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
release_date: '2026-11-12',
|
||||
name: 'Gemini 4 Hydrogen',
|
||||
aliases: ['google/gemini-4-hydrogen'],
|
||||
// One window shared by input, thinking and output.
|
||||
context: 1_048_576,
|
||||
max_tokens: 65_536,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.8 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 150,
|
||||
completion_tokens: 750,
|
||||
thinking_tokens: 750,
|
||||
cached_tokens: 7.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'google:google/gemini-3.5-flash',
|
||||
id: 'gemini-3.5-flash',
|
||||
|
||||
@@ -193,7 +193,8 @@ interface Interaction {
|
||||
}
|
||||
|
||||
type CountTokensPart =
|
||||
{ text: string } | { inlineData: { mimeType: string; data: string } };
|
||||
| { text: string }
|
||||
| { inlineData: { mimeType: string; data: string } };
|
||||
|
||||
interface CountTokensRequest {
|
||||
contents: { parts: CountTokensPart[] }[];
|
||||
|
||||
@@ -39,10 +39,10 @@ const VIDEO_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
'KlingVideoProvider (integration)',
|
||||
() => {
|
||||
it(
|
||||
'generates a video URL from kling-2.5-turbo',
|
||||
it.each(['kling-2.5-turbo', 'kling-4.0', 'kling-4.0-flash'])(
|
||||
'generates a video URL from %s',
|
||||
{ timeout: VIDEO_TIMEOUT_MS },
|
||||
async () => {
|
||||
async (model) => {
|
||||
const provider = new KlingVideoProvider(
|
||||
{ apiKey: optionalEnv(ENV_VAR)! },
|
||||
makeMeteringStub(),
|
||||
@@ -51,7 +51,7 @@ describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
|
||||
const url = await withTestActor(() =>
|
||||
provider.generate({
|
||||
model: 'kling-2.5-turbo',
|
||||
model,
|
||||
prompt: 'a red ball rolls to the right',
|
||||
resolution: '720p',
|
||||
seconds: 5,
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import { translateProviderError } from '../../../../../controllers/drivers/DriverController.js';
|
||||
import { Context } from '../../../../../core/context.js';
|
||||
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../../server.js';
|
||||
@@ -158,10 +159,12 @@ describe('KlingVideoProvider construction and catalog', () => {
|
||||
).toThrow(/API key/i);
|
||||
});
|
||||
|
||||
it('defaults to Kling 3.0 Turbo and lists the six models', () => {
|
||||
it('defaults to Kling 3.0 Turbo and lists the supported models', () => {
|
||||
const provider = makeProvider();
|
||||
expect(provider.getDefaultModel()).toBe('kling-3.0-turbo');
|
||||
expect(provider.models().map((m) => m.id)).toEqual([
|
||||
'kling-4.0',
|
||||
'kling-4.0-flash',
|
||||
'kling-3.0-turbo',
|
||||
'kling-3.0',
|
||||
'kling-3.0-omni',
|
||||
@@ -744,3 +747,112 @@ describe('KlingVideoProvider after the caller leaves', () => {
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kling 4 generation', () => {
|
||||
it.each([
|
||||
['kling-4.0', 30, '1080p', 23.55],
|
||||
['kling-4.0-flash', 20, '720p', 9.42],
|
||||
])(
|
||||
'generates and bills %s at flagship rates',
|
||||
async (model, seconds, size, cents) => {
|
||||
mockTaskFlow(succeededTask());
|
||||
await generate({
|
||||
model,
|
||||
seconds,
|
||||
size,
|
||||
prompt: 'a'.repeat(26_000),
|
||||
generate_audio: true,
|
||||
});
|
||||
expect(sentUrl()).toBe(
|
||||
`https://api-singapore.klingai.com/text-to-video/${model}`,
|
||||
);
|
||||
expect(sentBody().settings).toMatchObject({
|
||||
duration: seconds,
|
||||
resolution: size,
|
||||
});
|
||||
expect(sentBody().settings).not.toHaveProperty('audio');
|
||||
expect(incrementUsageSpy.mock.calls[0]![3]).toBe(
|
||||
Math.round(seconds * cents * 1_000_000),
|
||||
);
|
||||
},
|
||||
);
|
||||
it('routes Flash first frames to image-to-video', async () => {
|
||||
mockTaskFlow(succeededTask());
|
||||
await generate({
|
||||
model: 'kling-4.0-flash',
|
||||
input_reference: 'https://example.com/frame.png',
|
||||
});
|
||||
expect(sentUrl()).toBe(
|
||||
'https://api-singapore.klingai.com/image-to-video/kling-4.0-flash',
|
||||
);
|
||||
});
|
||||
it('rejects Flash last frames before submitting a task', async () => {
|
||||
await expect(
|
||||
generate({
|
||||
model: 'kling-4.0-flash',
|
||||
input_reference: 'https://example.com/frame.png',
|
||||
last_frame: 'https://example.com/end.png',
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
it('routes Kling 4 first and last frames to image-to-video', async () => {
|
||||
mockTaskFlow(succeededTask());
|
||||
await generate({
|
||||
model: 'kling/kling-4.0',
|
||||
input_reference: 'https://example.com/frame.png',
|
||||
last_frame: 'https://example.com/end.png',
|
||||
seconds: 3,
|
||||
});
|
||||
expect(sentUrl()).toBe(
|
||||
'https://api-singapore.klingai.com/image-to-video/kling-4.0',
|
||||
);
|
||||
expect(sentBody().contents).toContainEqual({
|
||||
type: 'last_frame',
|
||||
url: 'https://example.com/end.png',
|
||||
});
|
||||
expect(sentBody().settings.duration).toBe(3);
|
||||
});
|
||||
|
||||
it.each(['kling-4.0', 'kling-4.0-flash'])(
|
||||
'rejects an oversized prompt on %s before submitting a task',
|
||||
async (model) => {
|
||||
await expect(
|
||||
generate({ model, prompt: 'a'.repeat(26_001) }),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("leaves Kling 4's lower non-English prompt limit to Kling", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
code: 1201,
|
||||
message:
|
||||
'prompt is too long: at most 26000 English characters or 11000 non-English characters',
|
||||
},
|
||||
400,
|
||||
),
|
||||
);
|
||||
const failure = await generate({
|
||||
model: 'kling-4.0',
|
||||
prompt: '海'.repeat(12_000),
|
||||
}).catch((e) => e);
|
||||
expect(sentBody().prompt).toHaveLength(12_000);
|
||||
expect(translateProviderError(failure)).toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'upstream_bad_request',
|
||||
});
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['kling-4.0', 'kling-4.0-flash'])(
|
||||
'omits the unsupported audio switch on %s when audio is disabled',
|
||||
async (model) => {
|
||||
mockTaskFlow(succeededTask());
|
||||
await generate({ model, generate_audio: false });
|
||||
expect(sentBody().settings).not.toHaveProperty('audio');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -62,12 +62,44 @@ const seconds = (def: number, min: number, max: number): number[] => [
|
||||
];
|
||||
|
||||
// Prices are per second of output from
|
||||
// https://kling.ai/document-api/pricing/base/video (1 unit = $0.14). Cost keys
|
||||
// are `per-second:<resolution>`, with `:audio` for native audio and
|
||||
// https://kling.ai/document-api/pricing/base/video (1 unit = $0.14) and, for
|
||||
// Kling 4, https://kling.ai/dev/pricing (1 unit = $0.157). Cost keys are
|
||||
// `per-second:<resolution>`, with `:audio` for optional native audio and
|
||||
// `:video-input` when a source video is edited.
|
||||
// `default-duration-per-video` is a 5-second clip at the default resolution,
|
||||
// for cross-provider sorting and display only.
|
||||
export const KLING_VIDEO_GENERATION_MODELS: IVideoModel[] = [
|
||||
{
|
||||
id: 'kling-4.0',
|
||||
puterId: 'kling:kling/kling-4.0',
|
||||
aliases: ['kling/kling-4.0'],
|
||||
name: 'Kling 4.0',
|
||||
costs_currency: 'usd-cents',
|
||||
output_cost_key: 'default-duration-per-video',
|
||||
costs: {
|
||||
'per-second:720p': 15.7,
|
||||
'per-second:1080p': 23.55,
|
||||
'default-duration-per-video': 78.5,
|
||||
},
|
||||
durationSeconds: seconds(5, 3, 30),
|
||||
dimensions: ['720p', '1080p'],
|
||||
fps: [24],
|
||||
},
|
||||
{
|
||||
id: 'kling-4.0-flash',
|
||||
puterId: 'kling:kling/kling-4.0-flash',
|
||||
aliases: ['kling/kling-4.0-flash'],
|
||||
name: 'Kling 4.0 Flash',
|
||||
costs_currency: 'usd-cents',
|
||||
output_cost_key: 'default-duration-per-video',
|
||||
costs: {
|
||||
'per-second:720p': 9.42,
|
||||
'default-duration-per-video': 47.1,
|
||||
},
|
||||
durationSeconds: seconds(5, 3, 20),
|
||||
dimensions: ['720p'],
|
||||
fps: [24],
|
||||
},
|
||||
{
|
||||
id: 'kling-3.0-turbo',
|
||||
puterId: 'kling:kling/kling-3.0-turbo',
|
||||
@@ -186,6 +218,26 @@ export const KLING_VIDEO_GENERATION_MODELS: IVideoModel[] = [
|
||||
// https://kling.ai/document-api/api/video and the capability map
|
||||
// https://kling.ai/document-api/guides/capability-map/video.
|
||||
export const KLING_VIDEO_SPECS: Record<string, KlingVideoSpec> = {
|
||||
// Kling 4 takes 26,000 English characters but 11,000 non-English ones,
|
||||
// and enforces the lower limit itself.
|
||||
'kling-4.0-flash': {
|
||||
textPath: '/text-to-video/kling-4.0-flash',
|
||||
imagePath: '/image-to-video/kling-4.0-flash',
|
||||
audio: 'always',
|
||||
lastFrame: false,
|
||||
maxReferenceImages: 0,
|
||||
maxReferenceImagesWithVideo: 0,
|
||||
maxPromptChars: 26_000,
|
||||
},
|
||||
'kling-4.0': {
|
||||
textPath: '/text-to-video/kling-4.0',
|
||||
imagePath: '/image-to-video/kling-4.0',
|
||||
audio: 'always',
|
||||
lastFrame: true,
|
||||
maxReferenceImages: 0,
|
||||
maxReferenceImagesWithVideo: 0,
|
||||
maxPromptChars: 26_000,
|
||||
},
|
||||
'kling-3.0-turbo': {
|
||||
textPath: '/text-to-video/kling-3.0-turbo',
|
||||
imagePath: '/image-to-video/kling-3.0-turbo',
|
||||
|
||||
@@ -83,7 +83,10 @@ let server: PuterServer;
|
||||
let recordSpy: MockInstance<MeteringService['utilRecordUsageObject']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
// The key puts Mistral on the server's own chat driver.
|
||||
server = await setupTestServer({
|
||||
providers: { mistral: { apiKey: 'test-key' } },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -187,11 +190,31 @@ describe('MistralAIProvider model catalog', () => {
|
||||
expect(ids).not.toContain('magistral-medium-latest');
|
||||
});
|
||||
|
||||
it('keeps mistral-large-2512, which Mistral still serves as active', async () => {
|
||||
it.each(['mistral-large-2512', 'mistral-medium-2604'])(
|
||||
'keeps %s, which Mistral serves until it retires the model',
|
||||
async (id) => {
|
||||
const { provider } = makeProvider();
|
||||
expect((await provider.models()).map((model) => model.id)).toContain(
|
||||
id,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('moves the floating Large aliases off the deprecated Large 3', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const ids = modelLookupNames(await provider.models());
|
||||
expect(ids).toContain('mistral-large-2512');
|
||||
expect(ids).toContain('mistral-large-latest');
|
||||
const models = await provider.models();
|
||||
const aliasesOf = (id: string) =>
|
||||
models.find((model) => model.id === id)?.aliases ?? [];
|
||||
expect(aliasesOf('mistral-large-4')).toEqual(
|
||||
expect.arrayContaining([
|
||||
'mistral-large-latest',
|
||||
'mistral-large',
|
||||
'mistralai/mistral-large-latest',
|
||||
]),
|
||||
);
|
||||
expect(aliasesOf('mistral-large-2512')).toEqual([
|
||||
'mistralai/mistral-large-2512',
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists Large 4 with standard pricing and multimodal tool support', async () => {
|
||||
@@ -204,17 +227,47 @@ describe('MistralAIProvider model catalog', () => {
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
tool_call: true,
|
||||
context: 1_000_000,
|
||||
max_tokens: 1_000_000,
|
||||
open_weights: false,
|
||||
max_tokens: 262_144,
|
||||
open_weights: true,
|
||||
costs_currency: 'usd-cents',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 136,
|
||||
cached_tokens: 14,
|
||||
completion_tokens: 418,
|
||||
prompt_tokens: 70,
|
||||
cached_tokens: 7,
|
||||
completion_tokens: 210,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['mistral-large-4', 'mistral-large-latest'])(
|
||||
'asks for no more output from %s than Mistral accepts',
|
||||
async (model) => {
|
||||
// Credit for far more output than the context window holds.
|
||||
vi.spyOn(
|
||||
server.services.metering,
|
||||
'getUsageHeadroom',
|
||||
).mockResolvedValue({ balance: 1e12, held: 0 });
|
||||
completeMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'hi', role: 'assistant' },
|
||||
finishReason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { promptTokens: 1, completionTokens: 1 },
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
server.drivers.aiChat.complete({
|
||||
model,
|
||||
provider: 'mistral',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(completeMock.mock.calls[0]![0].maxTokens).toBe(262_144);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ── Request shape (Mistral-specific quirks) ─────────────────────────
|
||||
@@ -539,7 +592,11 @@ describe('MistralAIProvider model resolution', () => {
|
||||
['mistral-large-4-0', 'mistral-large-4'],
|
||||
['mistralai/mistral-large-4', 'mistral-large-4'],
|
||||
['mistralai/mistral-large-4-0', 'mistral-large-4'],
|
||||
['mistral-large-latest', 'mistral-large-2512'],
|
||||
['mistral-large-latest', 'mistral-large-4'],
|
||||
['mistral-large', 'mistral-large-4'],
|
||||
['mistralai/mistral-large-latest', 'mistral-large-4'],
|
||||
['mistral-large-2512', 'mistral-large-2512'],
|
||||
['mistralai/mistral-large-2512', 'mistral-large-2512'],
|
||||
['zai-glm-latest', 'zai-glm-5-3'],
|
||||
['zai-glm-5', 'zai-glm-5-3'],
|
||||
['mistral-code-latest', 'codestral-2508'],
|
||||
@@ -588,8 +645,8 @@ describe('MistralAIProvider model resolution', () => {
|
||||
|
||||
describe('MistralAIProvider.complete non-stream output', () => {
|
||||
it.each([
|
||||
['mistral-large-4', false, 136, 14, 418],
|
||||
['mistral-large-4', true, 136, 14, 418],
|
||||
['mistral-large-4', false, 70, 7, 210],
|
||||
['mistral-large-4', true, 70, 7, 210],
|
||||
['mistral-small-2603', false, 15, 1.5, 60],
|
||||
['mistral-small-2603', true, 15, 1.5, 60],
|
||||
['ministral-8b-2512', false, 15, 1.5, 15],
|
||||
|
||||
@@ -25,19 +25,23 @@ export const MISTRAL_MODELS: IChatModel[] = [
|
||||
puterId: 'mistralai:mistralai/mistral-large-4',
|
||||
id: 'mistral-large-4',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: false,
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
release_date: '2026-10-06',
|
||||
name: 'Mistral Large 4',
|
||||
aliases: [
|
||||
'mistral-large-4-0',
|
||||
'mistral-large-latest',
|
||||
'mistral-large',
|
||||
'mistralai/mistral-large-4',
|
||||
'mistralai/mistral-large-4-0',
|
||||
'mistralai/mistral-large-latest',
|
||||
],
|
||||
context: 1_000_000,
|
||||
max_tokens: 1_000_000,
|
||||
// Mistral rejects a request for more completion tokens than this.
|
||||
max_tokens: 262_144,
|
||||
description:
|
||||
'Public-preview multimodal model for reasoning, coding, and agentic workflows; open weights are forthcoming.',
|
||||
'State-of-the-art, open-weight, general-purpose multimodal model for reasoning, coding, and agentic workflows.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
@@ -45,12 +49,13 @@ export const MISTRAL_MODELS: IChatModel[] = [
|
||||
serviceSpeedRates: { fast: 1.75 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 136,
|
||||
cached_tokens: 14,
|
||||
completion_tokens: 418,
|
||||
prompt_tokens: 70,
|
||||
cached_tokens: 7,
|
||||
completion_tokens: 210,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Mistral deprecated this model on 2026-10-31 and retires it on 2027-04-30.
|
||||
puterId: 'mistralai:mistralai/mistral-medium-2604',
|
||||
id: 'mistral-medium-2604',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
@@ -85,6 +90,7 @@ export const MISTRAL_MODELS: IChatModel[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
// Mistral deprecated this model on 2026-10-31 and retires it on 2027-04-30.
|
||||
puterId: 'mistralai:mistralai/mistral-large-2512',
|
||||
id: 'mistral-large-2512',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
@@ -93,12 +99,7 @@ export const MISTRAL_MODELS: IChatModel[] = [
|
||||
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',
|
||||
],
|
||||
aliases: ['mistralai/mistral-large-2512'],
|
||||
context: 262_144,
|
||||
max_tokens: 262_144,
|
||||
description:
|
||||
|
||||
@@ -74,7 +74,8 @@ const toMistralResponseFormat = (
|
||||
): Record<string, unknown> => {
|
||||
const format = value as Record<string, unknown> | null;
|
||||
const jsonSchema = (format?.jsonSchema ?? format?.json_schema) as
|
||||
Record<string, unknown> | undefined;
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const schema = jsonSchema?.schemaDefinition ?? jsonSchema?.schema;
|
||||
if (
|
||||
!format ||
|
||||
|
||||
@@ -155,6 +155,12 @@ export class OpenAISpeechToTextProvider extends SpeechToTextProvider {
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
// The SDK writes a list or an object out as several form fields.
|
||||
if (typeof args.prompt === 'object' && args.prompt !== null) {
|
||||
throw new HttpError(400, 'prompt must be a string', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
if (args.prompt && !caps.canPrompt) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
|
||||
@@ -40,9 +40,41 @@ export const seededRandom = (seed: number): Random => {
|
||||
};
|
||||
};
|
||||
|
||||
/** A case's own sequence, so a case does not depend on the ones before it. */
|
||||
export const caseRandom = (seed: number, index: number): Random =>
|
||||
seededRandom(Math.imul(seed ^ index, 0x9e3779b1));
|
||||
|
||||
export const pick = <T>(random: Random, values: readonly T[]): T =>
|
||||
values[Math.floor(random() * values.length)]!;
|
||||
|
||||
/**
|
||||
* Picks by rendezvous hashing: a value added to or removed from `values` moves
|
||||
* only the cases it wins, so a catalog change leaves every other case alone.
|
||||
*/
|
||||
export const pickStable = <T>(
|
||||
random: Random,
|
||||
values: readonly T[],
|
||||
key: (value: T) => string,
|
||||
): T => {
|
||||
const salt = (random() * 0x100000000) >>> 0;
|
||||
let best = values[0]!;
|
||||
let bestScore = -1;
|
||||
for (const value of values) {
|
||||
let hash = salt;
|
||||
for (const char of key(value)) {
|
||||
hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193);
|
||||
}
|
||||
hash = Math.imul(hash ^ (hash >>> 16), 0x85ebca6b);
|
||||
hash = Math.imul(hash ^ (hash >>> 13), 0xc2b2ae35);
|
||||
const score = (hash ^ (hash >>> 16)) >>> 0;
|
||||
if (score > bestScore) {
|
||||
best = value;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
const STRINGS = [
|
||||
'',
|
||||
' ',
|
||||
@@ -276,9 +308,9 @@ export const fuzzCalls = async (options: {
|
||||
pinned?: (random: Random) => Record<string, unknown>;
|
||||
call: (args: Record<string, unknown>) => Promise<unknown>;
|
||||
}): Promise<Findings> => {
|
||||
const random = seededRandom(options.seed);
|
||||
const findings = new Findings();
|
||||
for (let index = 0; index < options.cases; index++) {
|
||||
const random = caseRandom(options.seed, index);
|
||||
const args = {
|
||||
...mutate(
|
||||
pick(random, options.corpus),
|
||||
@@ -411,6 +443,9 @@ export const upstream = {
|
||||
/** Requests `respond` had no reply for. */
|
||||
unanswered: new Set<string>(),
|
||||
|
||||
/** How many requests reached `respond`. */
|
||||
asked: 0,
|
||||
|
||||
/**
|
||||
* Routes global `fetch` here and makes `https.request` throw, so an SDK
|
||||
* with its own client fails the suite instead of calling its vendor.
|
||||
@@ -443,6 +478,7 @@ export const upstream = {
|
||||
const text =
|
||||
init?.body ??
|
||||
(input instanceof Request ? await input.clone().text() : undefined);
|
||||
upstream.asked++;
|
||||
const reply = upstream.respond({ url, method, json: parseJson(text) });
|
||||
// Stainless SDKs sleep between retries unless told not to.
|
||||
const headers: Record<string, string> = { 'x-should-retry': 'false' };
|
||||
@@ -496,6 +532,7 @@ export const upstream = {
|
||||
method?: string;
|
||||
data?: unknown;
|
||||
}): Promise<{ status: number; data: unknown }> {
|
||||
upstream.asked++;
|
||||
const reply = upstream.respond({
|
||||
url: new URL(config.url ?? 'http://unknown.test'),
|
||||
method: (config.method ?? 'GET').toUpperCase(),
|
||||
|
||||
@@ -362,7 +362,7 @@ describe('pollUntilSettled', () => {
|
||||
const kept: Promise<unknown>[] = [];
|
||||
const rejection = rejectionOf(
|
||||
runWithContext({}, () => {
|
||||
Context.set('keepDriverSlot', (work) => kept.push(work));
|
||||
Context.set('keepConcurrencySlot', (work) => kept.push(work));
|
||||
return pollUntilSettled<Job>({
|
||||
provider: 'together',
|
||||
providerLabel: 'Together AI',
|
||||
|
||||
@@ -184,7 +184,7 @@ export const pollUntilSettled = async <T>(opts: PollOptions<T>): Promise<T> => {
|
||||
}
|
||||
if (Date.now() - start > windowMs) {
|
||||
const late = followLateJob(opts, job, opts.hold.take());
|
||||
Context.get('keepDriverSlot')?.(late);
|
||||
Context.get('keepConcurrencySlot')?.(late);
|
||||
throw pollTimeoutError(
|
||||
opts.provider,
|
||||
opts.providerLabel,
|
||||
|
||||
@@ -1327,7 +1327,8 @@ export class PuterServer {
|
||||
// 2b'. Concurrent in-flight limiting. Same auth-ordering reason
|
||||
// (user key + bySubscription resolution needs req.actor); installed
|
||||
// after rateLimitGate so a rate-rejection short-circuits before
|
||||
// we acquire a concurrency slot. Slot is released on res finish/close.
|
||||
// we acquire a concurrency slot. The slot is released once the response
|
||||
// is done and any work the handler kept it for has settled.
|
||||
if (opts.concurrent) {
|
||||
mwChain.push(
|
||||
concurrencyGate(opts.concurrent) as unknown as RequestHandler,
|
||||
|
||||
@@ -1123,13 +1123,15 @@ export class OpenAIRealtimeRelayService extends PuterService {
|
||||
identity?.type === 'app-under-user'
|
||||
? identity.session
|
||||
: undefined;
|
||||
if (!(
|
||||
sessionUids?.has(sessionUid ?? '') ||
|
||||
sessionUids?.has(parentUid ?? '') ||
|
||||
(tokenUid &&
|
||||
identity?.type === 'access-token' &&
|
||||
identity.token_uid === tokenUid)
|
||||
))
|
||||
if (
|
||||
!(
|
||||
sessionUids?.has(sessionUid ?? '') ||
|
||||
sessionUids?.has(parentUid ?? '') ||
|
||||
(tokenUid &&
|
||||
identity?.type === 'access-token' &&
|
||||
identity.token_uid === tokenUid)
|
||||
)
|
||||
)
|
||||
return;
|
||||
if (token) {
|
||||
kv.del(realtimeAuthKey(token));
|
||||
|
||||
@@ -34,7 +34,7 @@ An object containing the following properties:
|
||||
| `stream` | `Boolean` | A boolean indicating whether you want to stream the completion. Defaults to `false`. |
|
||||
| `max_tokens` | `Number` | The maximum number of tokens to generate in the completion. By default, the specific model's maximum is used. |
|
||||
| `temperature` | `Number` | A number between 0 and 2 indicating the randomness of the completion. Lower values make the output more focused and deterministic, while higher values make it more random. By default, the model's own temperature is used. See [Model-specific features](#model-specific-features). |
|
||||
| `reasoning_effort` / `reasoning.effort` | `String` | Controls how much effort reasoning models spend thinking. Supported values: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. Lower values give faster responses with less reasoning. OpenAI models, Moonshot's Kimi K3.1 Pro models, Meta's Muse Spark models, `gemini-4-argon`, `gemini-3.1-flash-lite` and `gemini-3-flash-preview` only. See [Model-specific features](#model-specific-features). |
|
||||
| `reasoning_effort` / `reasoning.effort` | `String` | Controls how much effort reasoning models spend thinking. Supported values: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. Lower values give faster responses with less reasoning. See [Model-specific features](#model-specific-features). |
|
||||
| `thinking` | `Object \| null` | Extended-thinking control: `{ type: "adaptive" \| "enabled" \| "disabled" \| "between_tools", budgetTokens?, display? }`. Pass `null` to send no thinking configuration at all (the model's own default); leaving it unset falls back to the legacy derivation from `reasoning_effort`. `budgetTokens` only applies to `type: "enabled"`. |
|
||||
| `verbosity` / `text.verbosity` | `String` | Controls how long or short responses are. Supported values: `low`, `medium`, and `high`. Lower values give shorter responses. OpenAI models only. |
|
||||
| `tools` | `Array` | Function definitions the AI can call. See [Function Calling](#function-calling) for details. |
|
||||
@@ -437,6 +437,8 @@ The `usageDetails` object on every result carries the same counts under provider
|
||||
|
||||
Fast service is available on Claude models with a Fast rate card. Ultrafast falls back to fast. When the account has no Fast quota, the request retries at standard speed. The upstream service tier and inference speed are separate fields.
|
||||
|
||||
Pass `speed: "fast"` to request Fast mode, or `speed: "standard"` for standard inference. This option applies to Claude models. An explicit `serviceSpeed` takes precedence.
|
||||
|
||||
#### Prompt Caching
|
||||
|
||||
Specific to Anthropic models, you can use the cache control feature, allowing you to optimize costs for repeated prompts.
|
||||
@@ -465,11 +467,13 @@ Fast service uses Priority inference on supported text and image requests. Other
|
||||
|
||||
#### `temperature`
|
||||
|
||||
`gemini-4-argon` ignores `temperature`. `gemini-3.1-flash-lite` and `gemini-3-flash-preview` use it.
|
||||
Gemini 4 Carbon (`gemini-4-carbon`), Gemini 4 Argon (`gemini-4-argon`) and Gemini 4 Hydrogen (`gemini-4-hydrogen`) ignore `temperature`. The `gemini-3.1-flash-lite` and `gemini-3-flash-preview` models use it.
|
||||
|
||||
#### `reasoning_effort`
|
||||
|
||||
`gemini-4-argon` accepts `low`, `medium` and `high`, and uses `medium` when you leave it unset. `gemini-3.1-flash-lite` and `gemini-3-flash-preview` accept `minimal`, `low`, `medium` and `high`, and use Google's default level when you leave it unset. For these three Gemini models, any other value fails with `400 bad_request`, and messages can contain text and images only.
|
||||
Gemini 4 Carbon and Gemini 4 Argon accept `low`, `medium` and `high`, and use `medium` when you leave it unset. Gemini 4 Hydrogen accepts `low`, `medium`, `high` and `xhigh`, and uses `high` when you leave it unset. The `gemini-3.1-flash-lite` and `gemini-3-flash-preview` models accept `minimal`, `low`, `medium` and `high`, and use Google's default level when you leave it unset. For these five Gemini models, any other value fails with `400 bad_request`, and messages can contain text and images only.
|
||||
|
||||
The other Gemini models ignore `reasoning_effort`.
|
||||
|
||||
#### Function calling
|
||||
|
||||
@@ -568,7 +572,7 @@ Models with an equivalent Highspeed variant select that variant for fast service
|
||||
|
||||
#### `reasoning_effort`
|
||||
|
||||
Kimi K3.1 Pro accepts `low`, `high` and `max`, uses `max` when you leave it unset, and fails with `400 bad_request` on any other value.
|
||||
Kimi K3.1 Pro accepts `low`, `high` and `max`, uses `max` when you leave it unset, and fails with `400 bad_request` on any other value. The other Kimi models ignore `reasoning_effort`.
|
||||
|
||||
#### Prompt Caching
|
||||
|
||||
|
||||
@@ -22,12 +22,15 @@ A string containing the provider you want to list the models for.
|
||||
|
||||
Resolves to an array of model objects. Each object always contains `id` and `provider`, and may include fields such as `name`, `aliases`, `context`, `max_tokens`, and a `cost` object (`currency`, `tokens`, `input` and `output` costs in cents). Additional provider-specific capability fields may also be present.
|
||||
|
||||
The `puterId` field contains the qualified model name in `<service>:<provider>/<model>` form. Pass this name as the `model` option to [`puter.ai.chat()`](/AI/chat/). The `id` field and aliases also remain accepted model names. When an upstream catalog supplies no provider namespace, the qualified name uses the service name as its namespace.
|
||||
|
||||
Example model entry:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "claude-opus-4-8",
|
||||
"puterId": "anthropic:anthropic/claude-opus-4-8",
|
||||
"provider": "claude",
|
||||
"name": "Claude Opus 4.8",
|
||||
"aliases": ["claude-opus-4-8-latest"],
|
||||
|
||||
@@ -233,7 +233,7 @@ The original Replicate entries also support these options:
|
||||
| `contrast` | `String` | Leonardo | Contrast preset. |
|
||||
| `prompt_enhance` | `Boolean` | Leonardo | Server-side prompt enhancement. |
|
||||
|
||||
If you abort a Replicate request, Puter cancels the prediction. A generation that completes before it's cancelled is still metered. Predictions have a 10-minute [upstream deadline](https://replicate.com/docs/topics/predictions/create-a-prediction#prediction-deadlines), after which the call fails with `upstream_timeout` (504). A call starts at most one prediction, since a failed creation request isn't retried. If checking on a running prediction fails, the call fails with `upstream_failed` (502), and the prediction isn't cancelled or metered.
|
||||
If you abort a Replicate request, Puter cancels the prediction. A generation that completes before it's cancelled is still metered. Predictions have a 10-minute [upstream deadline](https://replicate.com/docs/topics/predictions/create-a-prediction#prediction-deadlines), after which the call fails with `upstream_timeout` (504). A failed creation request isn't retried. When a model's safety checker rejects the generated image and asks for another run, the prediction is run again up to two more times within the same 10 minutes, unless you set `seed`. Only the run that succeeds is metered. If checking on a running prediction fails, the call fails with `upstream_failed` (502), and the prediction isn't cancelled or metered.
|
||||
|
||||
For more details, see the [Replicate API reference](https://replicate.com/docs) and each model's schema page on Replicate.
|
||||
|
||||
|
||||
@@ -288,6 +288,8 @@ Available when using a Kling model (provider `'kling-video-generation'`). Every
|
||||
|
||||
| Model | Alias | Duration (s) | Resolutions | Audio | Last frame | Reference images | `inputVideo` |
|
||||
|-------|-------|--------------|-------------|-------|------------|------------------|--------------|
|
||||
| `kling-4.0` | | 3 to 30, default 5 | `720p`, `1080p` | Always | Yes | No | No |
|
||||
| `kling-4.0-flash` | | 3 to 20, default 5 | `720p` | Always | No | No | No |
|
||||
| `kling-3.0-turbo` (default) | `kling-v3-turbo` | 3 to 15, default 5 | `720p`, `1080p` | Always | No | No | No |
|
||||
| `kling-3.0` | `kling-v3` | 3 to 15, default 5 | `720p`, `1080p`, `4k` | Optional | Yes | No | No |
|
||||
| `kling-3.0-omni` | `kling-v3-omni` | 3 to 15, default 5 | `720p`, `1080p`, `4k` | Optional | Yes | Up to 7 | Yes |
|
||||
@@ -299,6 +301,8 @@ The first resolution listed is the default. Prices are per second of output:
|
||||
|
||||
| Model | 720p | 1080p | 4K |
|
||||
|-------|------|-------|----|
|
||||
| `kling-4.0` | $0.157 | $0.2355 | |
|
||||
| `kling-4.0-flash` | $0.0942 | | |
|
||||
| `kling-3.0-turbo` | $0.112 | $0.14 | |
|
||||
| `kling-3.0` | $0.084, $0.126 with audio | $0.112, $0.168 with audio | $0.42 |
|
||||
| `kling-3.0-omni` | $0.084, $0.112 with audio, $0.126 with `inputVideo` | $0.112, $0.14 with audio, $0.168 with `inputVideo` | $0.42 |
|
||||
@@ -311,7 +315,7 @@ A clip is shortened to fit the balance, as described in [Cost and clip length](#
|
||||
| Option | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `model` | `String` | Video model to use, from the table above (full id or alias) |
|
||||
| `prompt` | `String` | Up to 3,072 characters on the Kling 3.0 models, 2,500 on the others and on Kling 3.0 Turbo with `input_reference` |
|
||||
| `prompt` | `String` | Up to 26,000 characters in English or 11,000 in other languages on Kling 4.0 and 4.0 Flash, 3,072 characters on the Kling 3.0 models, 2,500 on the others and on Kling 3.0 Turbo with `input_reference` |
|
||||
| `seconds` | `Number` | Clip length, from the table above. Any other value falls back to `5`. Ignored with `inputVideo` |
|
||||
| `size` | `String` | Resolution tier: `'720p'`, `'1080p'` or `'4k'` (case-insensitive), or `'WIDTHxHEIGHT'`. The `resolution` option is an alias |
|
||||
| `width` | `Number` | Optional. Only the ratio of `width` to `height` is used, to pick the aspect ratio: `16:9`, `9:16` or `1:1`. Any other ratio gives `16:9`. A clip from `input_reference` or `inputVideo` takes the image's or video's shape |
|
||||
@@ -323,6 +327,8 @@ A clip is shortened to fit the balance, as described in [Cost and clip length](#
|
||||
|
||||
An option the model lacks, or a combination it doesn't support, is rejected with `bad_request` before any credits are spent. Clips carry no watermark. Kling returns a hosted URL that stays available for 30 days.
|
||||
|
||||
On Kling 4.0 and 4.0 Flash, a prompt that mixes English with another language is within the limit when its English characters divided by 26,000 plus its other characters divided by 11,000 come to 1 or less. A prompt over 26,000 characters fails with `bad_request`. Kling rejects a shorter prompt that is over the limit for its language with `upstream_bad_request`. Neither is charged.
|
||||
|
||||
Kling 3.0 Omni and Kling O1 edit a video passed as `inputVideo`. The video must be MP4 or QuickTime, up to 200 MiB for a Puter file, and 3 to 15.5 seconds long on Kling 3.0 Omni or 3 to 10 seconds on Kling O1. Kling doesn't take a `data:` URI. It fetches a URL itself, and gets a Puter file as a link that works for 19 minutes, which it fetches when the job starts. A video Kling can't decode fails with `upstream_bad_request` and isn't charged. Up to 4 `reference_images` can go with the video.
|
||||
|
||||
For more details, see the [Kling video API reference](https://kling.ai/document-api/api/video/3-0-omni/text-to-video).
|
||||
|
||||
@@ -62,6 +62,8 @@ Shared by chat, image generation, video, TTS, speech and OCR. Each interface and
|
||||
| Requests per 10 s | 200 | 30 | 20 |
|
||||
| Concurrent requests | 20 | 3 | 2 |
|
||||
|
||||
A chat completion counts as a concurrent request until the model finishes it, even if your app disconnects first, because the model keeps generating and the completion is still billed. That's at most 10 minutes after the disconnect.
|
||||
|
||||
| Input | Limit |
|
||||
| ---------------------------------------------- | ---------------- |
|
||||
| Text for `txt2speech()` | 3,000 characters |
|
||||
@@ -79,7 +81,9 @@ The standard chat timeout is 10 minutes for a response to start, or to finish if
|
||||
|
||||
GPT-6.1 Terra has a 1,050,000-token context window and a maximum output of 128,000 tokens. Requests with more than 272,000 input tokens are charged at twice the input and cache rates and 1.5 times the output rate for the whole request.
|
||||
|
||||
A `gemini-4-argon` response that isn't streamed is capped at 32,768 output tokens, and a larger `max_tokens` is lowered to that cap. Streaming uses the model-directory limit.
|
||||
Gemini 4 Carbon and Gemini 4 Hydrogen accept text and images within a shared 1,048,576-token window and generate up to 65,536 tokens, including thinking.
|
||||
|
||||
A `gemini-4-argon` or `gemini-4-carbon` response that isn't streamed is capped at 32,768 output tokens, and a larger `max_tokens` is lowered to that cap. Streaming uses the model-directory limit.
|
||||
|
||||
Text-to-speech calls to ElevenLabs, xAI, Speechify, Kling and Gemini time out after 2 minutes; xAI speech-to-text and voice-changer calls after 10 minutes, upload included. A call that times out before any audio or transcript comes back fails with `504 upstream_timeout`. For Gemini, the 2 minutes cover the whole reply, audio included.
|
||||
|
||||
@@ -181,7 +185,7 @@ A web search is metered at a flat per-request rate in addition to the tokens it
|
||||
| xAI | Up to 5 reference images; more fails with `bad_request`. |
|
||||
| Together | Image routes are excluded because they require third-party data sharing; generation fails before any upstream call. |
|
||||
| Cloudflare | Output dimensions are clamped per side: FLUX.2 256–1920; Lucid Origin 64–2500; Phoenix 64–2048; SDXL and Inpainting 256–2048. Schnell is fixed at 1024×1024. One reference image on FLUX.2 and Inpainting models. Steps: Schnell 1–8; Lucid Origin 1–40; Phoenix and FLUX.2 Dev 1–50; Klein exactly 4; SDXL and Inpainting 1–20. |
|
||||
| Replicate | At most 10 references, or the model's lower limit; FLUX 1.1 Pro accepts one. Each fetched reference is capped at 30 MB. Riverflow accepts at most two fonts. Native options follow each model's schema. For other models with width/height controls, dimensions round to multiples of 8 and clamp to the schema bounds (64–4096 per side if none are given). Predictions expire after 10 minutes and are polled every 2 seconds. Cancellation cleanup polls for up to 30 seconds, plus an in-flight request. Network timeouts: 90 seconds for creation, 30 seconds for other requests. |
|
||||
| Replicate | At most 10 references, or the model's lower limit; FLUX 1.1 Pro accepts one. Each fetched reference is capped at 30 MB. Riverflow accepts at most two fonts. Native options follow each model's schema. For other models with width/height controls, dimensions round to multiples of 8 and clamp to the schema bounds (64–4096 per side if none are given). A call has 10 minutes for its predictions, which are polled every 2 seconds. A prediction that a model's safety checker rejects is run again up to two more times when no `seed` is set. Cancellation cleanup polls for up to 30 seconds, plus an in-flight request. Network timeouts: 90 seconds for creation, 30 seconds for other requests. |
|
||||
| BytePlus | Pro: 10 references; other Seedream models: 14. |
|
||||
| Kling | `kling-v3-omni` and `kling-image-o1`: 10 references; `kling-v3` and `kling-v2-1`: one. Prompt up to 2,500 characters. Each reference is a JPEG or PNG up to 10 MB. Jobs are polled every 2 seconds for up to 5 minutes, then fail with `504 upstream_timeout` and aren't charged. Each call to Kling times out after 60 seconds. |
|
||||
| BytePlus explicit output size | Pro: 921,600–4,624,220 total pixels. Lite and 4.5: 3,686,400–16,777,216. 4.0: 921,600–16,777,216. All require integer dimensions and an aspect ratio between 1:16 and 16:1. Sizes under 921,600 total pixels are treated as aspect-ratio hints, not exact sizes. |
|
||||
@@ -207,7 +211,7 @@ A longer prompt, a larger image or video, or input over the token limit fails wi
|
||||
|
||||
The reservation is a conservative estimate based on verified generation usage, not a maximum charge. Google can return more output than the requested 65,536 tokens, and the charge is for the input, video, output and thinking tokens Google reports, even when that's more than the reservation. A reply that reports token usage is charged for it even when the reply failed or has no usable clip.
|
||||
|
||||
Kling video models take a prompt of up to 3,072 characters (Kling 3.0 models) or 2,500 (the others, and Kling 3.0 Turbo with `input_reference`). Kling 3.0 Omni and Kling O1 take a source video (`inputVideo`) as a URL or as a Puter file of up to 200 MiB, which fails with `413 input_too_large` above that. Kling fetches either one itself. A Puter file is passed as a link that works for 19 minutes. The source must be an MP4 or QuickTime video of 3 to 15.5 seconds (10 on Kling O1). Kling rejects a source outside its limits, or one it can't decode, with `upstream_bad_request`, and the request isn't charged. An edit holds the price of a 16-second clip (10 on Kling O1). Each call to Kling times out after 60 seconds, and a job is polled every 5 seconds.
|
||||
Kling 4.0 and 4.0 Flash accept a prompt of up to 26,000 characters in English or 11,000 in other languages, and generate 3 to 30 seconds or 3 to 20 seconds respectively. A prompt that mixes English with another language is within the limit when its English characters divided by 26,000 plus its other characters divided by 11,000 come to 1 or less. A prompt over 26,000 characters fails with `bad_request`, and Kling rejects a shorter prompt that is over the limit for its language with `upstream_bad_request`. Neither is charged. Kling 4.0 supports 720p and 1080p, and Flash supports 720p. Other Kling video models take a prompt of up to 3,072 characters (Kling 3.0 models) or 2,500 (the others, and Kling 3.0 Turbo with `input_reference`). Kling 3.0 Omni and Kling O1 take a source video (`inputVideo`) as a URL or as a Puter file of up to 200 MiB, which fails with `413 input_too_large` above that. Kling fetches either one itself. A Puter file is passed as a link that works for 19 minutes. The source must be an MP4 or QuickTime video of 3 to 15.5 seconds (10 on Kling O1). Kling rejects a source outside its limits, or one it can't decode, with `upstream_bad_request`, and the request isn't charged. An edit holds the price of a 16-second clip (10 on Kling O1). Each call to Kling times out after 60 seconds, and a job is polled every 5 seconds.
|
||||
|
||||
MiniMax video models take a prompt of up to 7,000 characters (H3 models) or 2,000 (Hailuo models), and up to 5 images as frames and references together (2 on H3 Max and Hailuo 02, 1 on Hailuo 2.3 and 2.3 Fast). Hailuo 2.3 Fast requires a first-frame image. Hailuo 02 supports first and last frames together at `768p` or `1080p`, and a first frame alone at `512p`. MiniMax fetches an image URL itself, and a Puter file is sent inline. Each call to MiniMax times out after 60 seconds, and a job is polled every 5 seconds.
|
||||
|
||||
|
||||
+58
-12
@@ -24,7 +24,6 @@ import UIAuthLogin from './Auth/UIAuthLogin.js';
|
||||
import UIWindowItemProperties from './UIWindowItemProperties.js';
|
||||
import new_context_menu_item from '../helpers/newContextMenuItem.js';
|
||||
import refresh_item_container from '../helpers/refreshItemContainer.js';
|
||||
import { setFolderView } from '../helpers/folderViewPrefs.js';
|
||||
import { atPath } from '../helpers/pathElements.js';
|
||||
import { resolve_typed_path } from '../helpers/expandHomePath.js';
|
||||
import UIWindowAppFeedback from './UIWindowAppFeedback.js';
|
||||
@@ -3090,6 +3089,9 @@ window.update_window_path = async function (el_window, target_path) {
|
||||
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
|
||||
$(el_window).addClass(`window-${ fsentry.id}`);
|
||||
$(el_window).attr('data-uid', fsentry.id);
|
||||
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
|
||||
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
|
||||
$(el_window).attr('data-layout', fsentry.layout ?? 'icons');
|
||||
$(el_window_item_container).attr('data-uid', fsentry.id);
|
||||
// title - use i18n for system directories
|
||||
if ( target_path === window.home_path )
|
||||
@@ -3130,7 +3132,12 @@ window.update_window_path = async function (el_window, target_path) {
|
||||
$(el_window).attr('data-path', target_path);
|
||||
$(el_window_navbar_path_input).val(target_path);
|
||||
$(el_window_navbar_path_input).attr('data-path', target_path);
|
||||
// The folder's sort and layout are applied by refresh_item_container below.
|
||||
// update layout
|
||||
window.update_window_layout(el_window, fsentry.layout);
|
||||
// update explore header if in details view
|
||||
if ( fsentry.layout === 'details' ) {
|
||||
window.update_details_layout_sort_visuals(el_window, fsentry.sort_by, fsentry.sort_order);
|
||||
}
|
||||
}).catch(() => {
|
||||
if ( $(el_window).attr('data-path') !== target_path ) {
|
||||
return;
|
||||
@@ -3482,7 +3489,27 @@ window.set_sort_by = function (item_uid, sort_by, sort_order) {
|
||||
sort_order = 'asc';
|
||||
}
|
||||
|
||||
setFolderView(item_uid, { sort_by, sort_order });
|
||||
$.ajax({
|
||||
url: `${window.api_origin }/set_sort_by`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({
|
||||
sort_by: sort_by,
|
||||
item_uid: item_uid,
|
||||
sort_order: sort_order,
|
||||
}),
|
||||
async: true,
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${window.auth_token}`,
|
||||
},
|
||||
statusCode: {
|
||||
401: function (xhr) {
|
||||
window.handle401(xhr);
|
||||
},
|
||||
},
|
||||
success: function () {
|
||||
},
|
||||
});
|
||||
// update the sort_by & sort_order attr of every matching element
|
||||
$(`[data-uid="${item_uid}"]`).attr({
|
||||
'data-sort_by': sort_by,
|
||||
@@ -4960,15 +4987,34 @@ $(document).on('click', '.explore-table-headers-th', function (e) {
|
||||
});
|
||||
|
||||
window.set_layout = function (item_uid, layout) {
|
||||
setFolderView(item_uid, { layout });
|
||||
if ( layout === 'details' ) {
|
||||
let el_window = $(`.window[data-uid="${item_uid}"]`);
|
||||
if ( el_window.length > 0 ) {
|
||||
let sort_by = el_window.attr('data-sort_by');
|
||||
let sort_order = el_window.attr('data-sort_order');
|
||||
window.update_details_layout_sort_visuals(el_window, sort_by, sort_order);
|
||||
}
|
||||
}
|
||||
$.ajax({
|
||||
url: `${window.api_origin }/set_layout`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({
|
||||
item_uid: item_uid,
|
||||
layout: layout,
|
||||
}),
|
||||
async: true,
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${window.auth_token}`,
|
||||
},
|
||||
statusCode: {
|
||||
401: function (xhr) {
|
||||
window.handle401(xhr);
|
||||
},
|
||||
},
|
||||
success: function () {
|
||||
if ( layout === 'details' ) {
|
||||
let el_window = $(`.window[data-uid="${item_uid}"]`);
|
||||
if ( el_window.length > 0 ) {
|
||||
let sort_by = el_window.attr('data-sort_by');
|
||||
let sort_order = el_window.attr('data-sort_order');
|
||||
window.update_details_layout_sort_visuals(el_window, sort_by, sort_order);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
window.update_details_layout_sort_visuals = function (el_window, sort_by, sort_order) {
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How each folder is shown — its sort and its layout — kept in the user's
|
||||
* key-value store, one key per folder uid, as `{ sort_by, sort_order, layout }`.
|
||||
*/
|
||||
|
||||
export const LAYOUTS = ['icons', 'list', 'details'];
|
||||
export const SORT_BYS = ['name', 'modified', 'type', 'size'];
|
||||
export const SORT_ORDERS = ['asc', 'desc'];
|
||||
|
||||
export const DEFAULT_FOLDER_VIEW = Object.freeze({
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc',
|
||||
layout: 'icons',
|
||||
});
|
||||
|
||||
const ALLOWED = { sort_by: SORT_BYS, sort_order: SORT_ORDERS, layout: LAYOUTS };
|
||||
|
||||
export const folderViewKey = uid => `folder_view:${uid}`;
|
||||
|
||||
/**
|
||||
* The known preferences in `value`, without anything unrecognized.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @returns {{ sort_by?: string, sort_order?: string, layout?: string }}
|
||||
*/
|
||||
export function pickFolderView(value) {
|
||||
if ( typeof value === 'string' ) {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
const picked = {};
|
||||
if ( !value || typeof value !== 'object' ) return picked;
|
||||
for ( const [field, allowed] of Object.entries(ALLOWED) ) {
|
||||
if ( allowed.includes(value[field]) ) picked[field] = value[field];
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
const usableUid = uid => typeof uid === 'string' && uid !== '' && uid !== 'null' && uid !== 'undefined';
|
||||
|
||||
// The last stored value this session knows of, per folder, so a change to
|
||||
// one preference keeps the others it doesn't touch.
|
||||
const known = new Map();
|
||||
|
||||
const kv = () => globalThis.puter.kv;
|
||||
|
||||
/**
|
||||
* How to show a folder: what's stored for it, then what its fsentry carries
|
||||
* (the server kept a layout there before), then the defaults.
|
||||
*
|
||||
* @param {object | null | undefined} fsentry - from `puter.fs.stat()`
|
||||
* @returns {Promise<{ sort_by: string, sort_order: string, layout: string }>}
|
||||
*/
|
||||
export async function getFolderView(fsentry) {
|
||||
const uid = fsentry?.uid ?? fsentry?.id;
|
||||
let stored = {};
|
||||
if ( usableUid(uid) ) {
|
||||
const read = kv().get(folderViewKey(uid)).then(pickFolderView);
|
||||
known.set(uid, read.catch(() => ({})));
|
||||
try {
|
||||
stored = await read;
|
||||
} catch ( err ) {
|
||||
console.warn('Could not load folder view:', err);
|
||||
}
|
||||
}
|
||||
return { ...DEFAULT_FOLDER_VIEW, ...pickFolderView(fsentry), ...stored };
|
||||
}
|
||||
|
||||
/**
|
||||
* Store some of a folder's preferences; the rest stay as they were.
|
||||
*
|
||||
* @param {string} uid
|
||||
* @param {{ sort_by?: string, sort_order?: string, layout?: string }} changes
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function setFolderView(uid, changes) {
|
||||
if ( ! usableUid(uid) ) return Promise.resolve();
|
||||
const base = known.get(uid)
|
||||
?? kv().get(folderViewKey(uid)).then(pickFolderView).catch(() => ({}));
|
||||
const next = base.then(stored => ({ ...stored, ...pickFolderView(changes) }));
|
||||
known.set(uid, next);
|
||||
return next
|
||||
.then(value => kv().set(folderViewKey(uid), value))
|
||||
.catch(err => console.warn('Could not save folder view:', err));
|
||||
}
|
||||
|
||||
/** Forget what this session knows of stored values; for tests. */
|
||||
export function resetFolderViewCache() {
|
||||
known.clear();
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
/*
|
||||
* 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
DEFAULT_FOLDER_VIEW,
|
||||
folderViewKey,
|
||||
getFolderView,
|
||||
pickFolderView,
|
||||
resetFolderViewCache,
|
||||
setFolderView,
|
||||
} from './folderViewPrefs.js';
|
||||
|
||||
let store;
|
||||
let kv;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new Map();
|
||||
kv = {
|
||||
get: vi.fn(async key => store.get(key) ?? null),
|
||||
set: vi.fn(async (key, value) => {
|
||||
store.set(key, value);
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
vi.stubGlobal('puter', { kv });
|
||||
resetFolderViewCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('pickFolderView', () => {
|
||||
it('keeps known values only', () => {
|
||||
expect(pickFolderView({
|
||||
sort_by: 'size',
|
||||
sort_order: 'sideways',
|
||||
layout: 'details',
|
||||
extra: 1,
|
||||
})).toEqual({ sort_by: 'size', layout: 'details' });
|
||||
});
|
||||
|
||||
it('reads a JSON string and ignores anything else', () => {
|
||||
expect(pickFolderView('{"sort_order":"desc"}')).toEqual({ sort_order: 'desc' });
|
||||
expect(pickFolderView('nope')).toEqual({});
|
||||
expect(pickFolderView(null)).toEqual({});
|
||||
expect(pickFolderView(7)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFolderView', () => {
|
||||
it('prefers what is stored, then the fsentry, then the defaults', async () => {
|
||||
store.set(folderViewKey('dir'), { sort_by: 'modified' });
|
||||
expect(await getFolderView({ uid: 'dir', layout: 'list', sort_by: 'size' })).toEqual({
|
||||
sort_by: 'modified',
|
||||
sort_order: 'asc',
|
||||
layout: 'list',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back when the store can\'t be read', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
kv.get.mockRejectedValue(new Error('offline'));
|
||||
expect(await getFolderView({ id: 'dir', layout: 'details' }))
|
||||
.toEqual({ ...DEFAULT_FOLDER_VIEW, layout: 'details' });
|
||||
});
|
||||
|
||||
it('doesn\'t ask the store about a folder without a uid', async () => {
|
||||
expect(await getFolderView({ uid: 'null' })).toEqual(DEFAULT_FOLDER_VIEW);
|
||||
expect(await getFolderView(undefined)).toEqual(DEFAULT_FOLDER_VIEW);
|
||||
expect(kv.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setFolderView', () => {
|
||||
it('keeps the preferences a change doesn\'t touch', async () => {
|
||||
store.set(folderViewKey('dir'), { layout: 'details', sort_by: 'size' });
|
||||
await setFolderView('dir', { sort_by: 'name', sort_order: 'desc' });
|
||||
expect(store.get(folderViewKey('dir'))).toEqual({
|
||||
layout: 'details',
|
||||
sort_by: 'name',
|
||||
sort_order: 'desc',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds on the last change, not on a read that predates it', async () => {
|
||||
await getFolderView({ uid: 'dir' });
|
||||
const sort = setFolderView('dir', { sort_by: 'type' });
|
||||
const layout = setFolderView('dir', { layout: 'list' });
|
||||
await Promise.all([sort, layout]);
|
||||
expect(store.get(folderViewKey('dir'))).toEqual({ sort_by: 'type', layout: 'list' });
|
||||
expect(kv.get).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('drops unknown values and folders without a uid', async () => {
|
||||
await setFolderView('dir', { layout: 'grid', sort_by: 'size' });
|
||||
expect(store.get(folderViewKey('dir'))).toEqual({ sort_by: 'size' });
|
||||
await setFolderView('null', { layout: 'list' });
|
||||
await setFolderView(undefined, { layout: 'list' });
|
||||
expect(kv.set).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('reports a failed save instead of throwing', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
kv.set.mockRejectedValue(new Error('offline'));
|
||||
await expect(setFolderView('dir', { layout: 'list' })).resolves.toBeUndefined();
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -410,13 +410,11 @@ export const showFramedUpgrade = (win, attribution, tell) => showFramedDialog(wi
|
||||
* the user bought credit, once the dialog closes.
|
||||
*
|
||||
* @param {Window} win
|
||||
* @param {{ appUid?: string }} attribution - the app whose page frames it; an empty one is passed as null
|
||||
* @param {(state: 'ready') => void} tell
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const showFramedTopUp = (win, attribution, tell) => showFramedDialog(win, (closed) => {
|
||||
export const showFramedTopUp = (win, tell) => showFramedDialog(win, (closed) => {
|
||||
const dialog = new win.UITopUp({
|
||||
attribution: Object.keys(attribution).length ? attribution : null,
|
||||
onClose: (result) => closed(result?.toppedUp === true),
|
||||
});
|
||||
return dialog.open();
|
||||
|
||||
@@ -443,27 +443,17 @@ describe('showFramedTopUp', () => {
|
||||
let options;
|
||||
const result = showFramedTopUp(topUpWindow(async (o) => {
|
||||
options = o;
|
||||
}), { appUid: 'app-1' }, tell);
|
||||
}), tell);
|
||||
await vi.waitFor(() => expect(tell).toHaveBeenCalledWith('ready'));
|
||||
expect(options.attribution).toEqual({ appUid: 'app-1' });
|
||||
expect(tell).toHaveBeenCalledTimes(1);
|
||||
|
||||
options.onClose({ toppedUp: true });
|
||||
await expect(result).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('passes no attribution when the framing page resolves to no app', async () => {
|
||||
let options;
|
||||
await showFramedTopUp(topUpWindow(async (o) => {
|
||||
options = o;
|
||||
o.onClose();
|
||||
}), {}, vi.fn());
|
||||
expect(options.attribution).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves false for a close without a purchase', async () => {
|
||||
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose({ toppedUp: false })), {}, vi.fn())).resolves.toBe(false);
|
||||
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose()), {}, vi.fn())).resolves.toBe(false);
|
||||
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose({ toppedUp: false })), vi.fn())).resolves.toBe(false);
|
||||
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose()), vi.fn())).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('resolves false once the dialog is gone without reporting a close', async () => {
|
||||
@@ -472,7 +462,7 @@ describe('showFramedTopUp', () => {
|
||||
root.setAttribute('aria-modal', 'true');
|
||||
const result = showFramedTopUp(topUpWindow(async () => {
|
||||
document.body.append(root);
|
||||
}), {}, vi.fn());
|
||||
}), vi.fn());
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
root.remove();
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
@@ -482,7 +472,7 @@ describe('showFramedTopUp', () => {
|
||||
it('rejects when the dialog fails to open', async () => {
|
||||
await expect(showFramedTopUp(topUpWindow(async () => {
|
||||
throw new Error('no dialog');
|
||||
}), {}, vi.fn())).rejects.toThrow('no dialog');
|
||||
}), vi.fn())).rejects.toThrow('no dialog');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ import item_icon from './itemIcon.js';
|
||||
import list_all_shared from './listAllShared.js';
|
||||
import parse_item_metadata from './parseItemMetadata.js';
|
||||
import { remember_shared_roots } from './sharedAccess.js';
|
||||
import { getFolderView } from './folderViewPrefs.js';
|
||||
|
||||
const refresh_item_container = function (el_item_container, options) {
|
||||
// start a transaction
|
||||
@@ -106,6 +105,9 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
// the class is how a deleted folder's windows are found, and a window opened by path starts without it
|
||||
$(el_window).removeClass(`window-${$(el_window).attr('data-uid')}`).addClass(`window-${fsentry.id}`);
|
||||
$(el_window).attr('data-uid', fsentry.id);
|
||||
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
|
||||
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
|
||||
$(el_window).attr('data-layout', fsentry.layout ?? 'icons');
|
||||
// data-name
|
||||
$(el_window).attr('data-name', fsentry.name);
|
||||
// data-path
|
||||
@@ -113,27 +115,17 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
$(el_window).find('.window-navbar-path-input').val(container_path);
|
||||
$(el_window).find('.window-navbar-path-input').attr('data-path', container_path);
|
||||
}
|
||||
return getFolderView(fsentry).then(view => {
|
||||
if ( ! is_current() ) {
|
||||
return;
|
||||
}
|
||||
// Items may already be in, placed by the sort the container had before.
|
||||
const resort = $(el_item_container).attr('data-sort_by') !== view.sort_by
|
||||
|| $(el_item_container).attr('data-sort_order') !== view.sort_order;
|
||||
$(el_item_container).attr('data-sort_by', view.sort_by);
|
||||
$(el_item_container).attr('data-sort_order', view.sort_order);
|
||||
if ( el_window && el_window.length > 0 ) {
|
||||
$(el_window).attr('data-sort_by', view.sort_by);
|
||||
$(el_window).attr('data-sort_order', view.sort_order);
|
||||
window.update_window_layout(el_window, view.layout);
|
||||
if ( view.layout === 'details' ) {
|
||||
window.update_details_layout_sort_visuals(el_window, view.sort_by, view.sort_order);
|
||||
}
|
||||
}
|
||||
if ( resort ) {
|
||||
window.sort_items(el_item_container, view.sort_by, view.sort_order);
|
||||
}
|
||||
});
|
||||
$(el_item_container).attr('data-sort_by', fsentry.sort_by ?? 'name');
|
||||
$(el_item_container).attr('data-sort_order', fsentry.sort_order ?? 'asc');
|
||||
// update layout
|
||||
if ( el_window && el_window.length > 0 )
|
||||
{
|
||||
window.update_window_layout(el_window, fsentry.layout);
|
||||
}
|
||||
//
|
||||
if ( fsentry.layout === 'details' ) {
|
||||
window.update_details_layout_sort_visuals(el_window, fsentry.sort_by, fsentry.sort_order);
|
||||
}
|
||||
}).catch(() => {
|
||||
// The listing request below reports the failure to the user.
|
||||
});
|
||||
|
||||
@@ -229,57 +229,3 @@ describe('refresh_item_container after the window moves to another folder', () =
|
||||
expect(renderedPaths()).toEqual(['/alice/Documents/a.txt']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh_item_container and the folder\'s stored view', () => {
|
||||
let views;
|
||||
|
||||
beforeEach(() => {
|
||||
views = {};
|
||||
puter.kv = {
|
||||
get: vi.fn((key) => {
|
||||
views[key] ??= deferred();
|
||||
return views[key].promise;
|
||||
}),
|
||||
};
|
||||
puter.fs.stat.mockImplementation(async ({ path }) => ({ id: path.slice(1), name: path.slice(1) }));
|
||||
puter.fs.readdir.mockResolvedValue([]);
|
||||
vi.spyOn(window, 'sort_items');
|
||||
vi.spyOn(window, 'update_window_layout');
|
||||
document.body.innerHTML =
|
||||
'<div class="window"><div class="item-container" data-sort_by="name" data-sort_order="asc"></div></div>';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete puter.kv;
|
||||
});
|
||||
|
||||
it('lays out and re-sorts the folder by what is stored for it', async () => {
|
||||
navigate('/docs');
|
||||
await vi.waitFor(() => expect(views['folder_view:docs']).toBeDefined());
|
||||
views['folder_view:docs'].resolve({ sort_by: 'size', sort_order: 'desc', layout: 'details' });
|
||||
|
||||
await vi.waitFor(() => expect(window.sort_items).toHaveBeenCalledWith(
|
||||
expect.anything(), 'size', 'desc',
|
||||
));
|
||||
expect($('.item-container').attr('data-sort_by')).toBe('size');
|
||||
expect($('.window').attr('data-sort_order')).toBe('desc');
|
||||
expect(window.update_window_layout).toHaveBeenCalledWith(expect.anything(), 'details');
|
||||
});
|
||||
|
||||
it('ignores the view of a folder the window has left', async () => {
|
||||
navigate('/docs');
|
||||
await vi.waitFor(() => expect(views['folder_view:docs']).toBeDefined());
|
||||
navigate('/pics');
|
||||
await vi.waitFor(() => expect(views['folder_view:pics']).toBeDefined());
|
||||
|
||||
views['folder_view:pics'].resolve(null);
|
||||
views['folder_view:docs'].resolve({ sort_by: 'size', layout: 'list' });
|
||||
await vi.waitFor(() => expect(window.update_window_layout).toHaveBeenCalledOnce());
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(window.update_window_layout).toHaveBeenCalledWith(expect.anything(), 'icons');
|
||||
expect(window.update_window_layout).not.toHaveBeenCalledWith(expect.anything(), 'list');
|
||||
expect($('.item-container').attr('data-sort_by')).toBe('name');
|
||||
expect(window.sort_items).not.toHaveBeenCalledWith(expect.anything(), 'size', expect.anything());
|
||||
});
|
||||
});
|
||||
@@ -1089,17 +1089,12 @@ const postAuthActions = async (action) => {
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// Action: top-up — the deployment's top-up dialog for the first-party page framing it,
|
||||
// which hears whether the user bought credit once the dialog closes. The app it credits
|
||||
// is the one the framing page's origin resolves to.
|
||||
// which hears whether the user bought credit once the dialog closes.
|
||||
// -------------------------------------------------------------------------------------
|
||||
if ( action === 'top-up' && window.embedded_in_frame ) {
|
||||
let toppedUp;
|
||||
try {
|
||||
toppedUp = await showFramedTopUp(
|
||||
window,
|
||||
window.host_app_uid ? { appUid: window.host_app_uid } : {},
|
||||
window.tell_popup_frame,
|
||||
);
|
||||
toppedUp = await showFramedTopUp(window, window.tell_popup_frame);
|
||||
} catch (e) {
|
||||
// A dialog that failed to open leaves the page to open the top-up in a tab.
|
||||
console.error('top-up action failed', e);
|
||||
|
||||
@@ -104,25 +104,6 @@ class Lock {
|
||||
// (using defaultGUIOrigin breaks locally-hosted apps)
|
||||
const PROD_ORIGIN = 'https://puter.com';
|
||||
|
||||
// The desktops Puter itself serves. An app learns which one launched it from
|
||||
// the URL (`puter.origin`), and only one of these is believed: the GUI origin
|
||||
// is what every message from the desktop is checked against, so taking an
|
||||
// arbitrary one would let any page that frames an app drive it.
|
||||
const FIRST_PARTY_GUI_ORIGINS = new Set([
|
||||
'https://puter.com',
|
||||
'https://desktop.puter.com',
|
||||
]);
|
||||
|
||||
/** `value`'s origin when it is a first-party desktop, otherwise null. */
|
||||
const firstPartyGUIOrigin = (value) => {
|
||||
try {
|
||||
const { origin } = new URL(value);
|
||||
return FIRST_PARTY_GUI_ORIGINS.has(origin) ? origin : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// localStorage key for the auth token. The retired key below is never read —
|
||||
// only deleted, so a token from before the format change doesn't linger in a
|
||||
// visitor's browser (see `discardRetiredAuthToken_`).
|
||||
@@ -232,9 +213,6 @@ export class Puter {
|
||||
|
||||
#defaultAPIOrigin = 'https://api.puter.com';
|
||||
#defaultGUIOrigin = 'https://puter.com';
|
||||
// In app mode, the first-party desktop that launched this app, when it
|
||||
// isn't the build's default one.
|
||||
#launchGUIOrigin = null;
|
||||
|
||||
/** @returns {string} */
|
||||
get defaultAPIOrigin() {
|
||||
@@ -252,7 +230,6 @@ export class Puter {
|
||||
get defaultGUIOrigin() {
|
||||
return (
|
||||
globalThis.PUTER_ORIGIN ||
|
||||
this.#launchGUIOrigin ||
|
||||
globalThis.PUTER_ORIGIN_ENV ||
|
||||
this.#defaultGUIOrigin
|
||||
);
|
||||
@@ -657,15 +634,6 @@ export class Puter {
|
||||
this.APIOrigin = `https://api.${URLParams.get('puter.domain')}`;
|
||||
}
|
||||
|
||||
// An app launched by a first-party desktop other than puter.com
|
||||
// (e.g. desktop.puter.com) talks to that desktop: its messages come
|
||||
// from there, and replies meant only for it go there.
|
||||
if (this.env === 'app') {
|
||||
this.#launchGUIOrigin = firstPartyGUIOrigin(
|
||||
URLParams.get('puter.origin'),
|
||||
);
|
||||
}
|
||||
|
||||
// === START :: Logger ===
|
||||
|
||||
// Basic logger replacement (console-based)
|
||||
|
||||
@@ -240,88 +240,6 @@ describe('app-mode launch token', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GUI origin of an app', () => {
|
||||
const DESKTOP = 'https://desktop.puter.com';
|
||||
const launchedBy = (origin) =>
|
||||
bootApp(
|
||||
`${LAUNCH}${APP_TOKEN}&puter.origin=${encodeURIComponent(origin)}`,
|
||||
);
|
||||
|
||||
/** Sends `data` to the app as its parent at `origin` would. */
|
||||
const fromParent = async (origin, data) => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
origin,
|
||||
source: globalThis.parent,
|
||||
data,
|
||||
}),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
};
|
||||
|
||||
const closeAnswers = () =>
|
||||
globalThis.parent.postMessage.mock.calls
|
||||
.map(([message]) => message)
|
||||
.filter((message) => message?.original_msg_id === 'close-1');
|
||||
|
||||
it('is the first-party desktop that launched it', async () => {
|
||||
expect((await launchedBy(DESKTOP)).defaultGUIOrigin).toBe(DESKTOP);
|
||||
expect((await launchedBy('https://puter.com')).defaultGUIOrigin).toBe(
|
||||
'https://puter.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('stays puter.com for any other launcher', async () => {
|
||||
for (const origin of [
|
||||
'https://evil.example',
|
||||
'https://desktop.puter.com.evil.example',
|
||||
'https://x.desktop.puter.com',
|
||||
'http://desktop.puter.com',
|
||||
'not a url',
|
||||
]) {
|
||||
expect((await launchedBy(origin)).defaultGUIOrigin).toBe(
|
||||
'https://puter.com',
|
||||
);
|
||||
}
|
||||
expect((await bootApp(`${LAUNCH}${APP_TOKEN}`)).defaultGUIOrigin).toBe(
|
||||
'https://puter.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('yields to an explicit PUTER_ORIGIN', async () => {
|
||||
globalThis.PUTER_ORIGIN = 'https://self-hosted.test';
|
||||
try {
|
||||
expect((await launchedBy(DESKTOP)).defaultGUIOrigin).toBe(
|
||||
'https://self-hosted.test',
|
||||
);
|
||||
} finally {
|
||||
delete globalThis.PUTER_ORIGIN;
|
||||
}
|
||||
});
|
||||
|
||||
it('is not taken from the URL of a top-level page', async () => {
|
||||
const puter = await bootWeb(
|
||||
`?puter.app_instance_id=i&puter.origin=${encodeURIComponent(DESKTOP)}`,
|
||||
);
|
||||
expect(puter.env).not.toBe('app');
|
||||
expect(puter.defaultGUIOrigin).toBe('https://puter.com');
|
||||
});
|
||||
|
||||
it('answers windowWillClose from that desktop and no other', async () => {
|
||||
await launchedBy(DESKTOP);
|
||||
const willClose = { msg: 'windowWillClose', msg_id: 'close-1' };
|
||||
|
||||
await fromParent('https://evil.example', willClose);
|
||||
await fromParent('https://puter.com', willClose);
|
||||
expect(closeAnswers()).toEqual([]);
|
||||
|
||||
await fromParent(DESKTOP, willClose);
|
||||
expect(closeAnswers()).toEqual([
|
||||
expect.objectContaining({ msg: true, original_msg_id: 'close-1' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('token adoption on a top-level page', () => {
|
||||
it('takes a token from a window it opened, and from nothing else', async () => {
|
||||
const puter = await bootWeb('');
|
||||
|
||||
@@ -1889,7 +1889,7 @@ export class UIModule extends EventListener {
|
||||
onMessage: () => {},
|
||||
});
|
||||
if ( framed ) return framed;
|
||||
await this.#openUpgradeTab(topUpUrl(puter.defaultGUIOrigin, appIDOf(puter)), 'This website wants to open the Puter top-up page.');
|
||||
await this.#openUpgradeTab(topUpUrl(puter.defaultGUIOrigin), 'This website wants to open the Puter top-up page.');
|
||||
return { toppedUp: false };
|
||||
})();
|
||||
this.#topUpFrame = showing;
|
||||
|
||||
@@ -186,16 +186,6 @@ describe('ui.showTopUp_ on a signed-in first-party page', () => {
|
||||
});
|
||||
|
||||
describe('ui.showTopUp_ elsewhere on the web', () => {
|
||||
it('names the app the page runs as, so the dashboard can credit it', async () => {
|
||||
fakePuter({ guiOrigin: 'https://puter.com' });
|
||||
globalThis.puter.appID = 'app-0b8a6d4e-6c1f-4a52-9d3e-2f7b1c9e8a10';
|
||||
await makeUI().showTopUp_();
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://puter.com/dashboard/?topup=&topup_app=app-0b8a6d4e-6c1f-4a52-9d3e-2f7b1c9e8a10#usage',
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a page outside the Puter origin', { guiOrigin: 'https://puter.com' }],
|
||||
['a signed-out first-party page', { signedIn: false }],
|
||||
|
||||
@@ -32,13 +32,11 @@ export const upgradeUrl = ({ reason, appID, method } = {}) => {
|
||||
* The dashboard page that opens the top-up flow, for buying add-on credit.
|
||||
*
|
||||
* @param {string} [guiOrigin] The deployment whose dashboard it is; Puter's by default.
|
||||
* @param {string} [appID] The app whose page asked for the top-up.
|
||||
* @returns {string}
|
||||
*/
|
||||
export const topUpUrl = (guiOrigin, appID) => {
|
||||
export const topUpUrl = (guiOrigin) => {
|
||||
const url = new URL('/dashboard/', guiOrigin || UPGRADE_PAGE);
|
||||
url.searchParams.set('topup', '');
|
||||
if ( appID ) url.searchParams.set('topup_app', appID);
|
||||
url.hash = 'usage';
|
||||
return url.href;
|
||||
};
|
||||
|
||||
@@ -115,6 +115,16 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('ai.chat driver payloads', () => {
|
||||
it.each(['fast', 'standard'])('forwards Claude speed %s to the driver', async (speed) => {
|
||||
await ai.chat('hello', { model: 'claude-opus-4-8', speed });
|
||||
expect(lastBody().args.speed).toBe(speed);
|
||||
});
|
||||
|
||||
it('preserves serviceSpeed precedence alongside the native speed option', async () => {
|
||||
await ai.chat('hello', { speed: 'fast', serviceSpeed: 'standard' });
|
||||
expect(lastBody().args).toMatchObject({ speed: 'fast', serviceSpeed: 'standard' });
|
||||
});
|
||||
|
||||
it('chat(prompt) wraps the prompt in a single message', async () => {
|
||||
await ai.chat('hello');
|
||||
const body = lastBody();
|
||||
|
||||
@@ -21,6 +21,7 @@ const PARAMS_TO_PASS = [
|
||||
'provider',
|
||||
'fallback',
|
||||
'serviceSpeed',
|
||||
'speed',
|
||||
'image_config',
|
||||
'compaction',
|
||||
'context_management',
|
||||
|
||||
@@ -73,6 +73,8 @@
|
||||
* @property {boolean} [vision]
|
||||
* @property {string} [driver]
|
||||
* @property {string} [provider] The provider to route the request through.
|
||||
* @property {'fast' | 'standard'} [speed] Claude inference speed. Faster service may cost more.
|
||||
* An explicit serviceSpeed takes precedence.
|
||||
* @property {ServiceSpeed} [serviceSpeed] Preferred service speed. Falls back to
|
||||
* fast, then standard when the requested speed is unavailable. Faster service may cost more.
|
||||
* @property {boolean} [fallback] Set to `false` to fail the request when the first provider tried
|
||||
|
||||
@@ -97,6 +97,17 @@ const textOf = (result: {
|
||||
};
|
||||
|
||||
export default suite('ai', {
|
||||
'chat accepts native speed alongside serviceSpeed': async t => {
|
||||
useApiToken(t);
|
||||
for (const speed of ['fast', 'standard'] as const) {
|
||||
const result = await t.puter.ai.chat('Hello', {
|
||||
model: 'fake', speed, serviceSpeed: 'standard', normalize: true,
|
||||
});
|
||||
t.assert.equal(typeof result.message.content, 'string');
|
||||
t.assert.equal(result.usageDetails?.serviceSpeed, 'standard');
|
||||
}
|
||||
},
|
||||
|
||||
'service speed falls back on unsupported models without changing the normalized response': async t => {
|
||||
useApiToken(t);
|
||||
for (const serviceSpeed of ['standard', 'fast', 'ultrafast'] as const) {
|
||||
|
||||
Reference in new issue
Block a user