mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4aaae58ae | ||
|
|
b2eb8ed895 | ||
|
|
d1cbfb9fb1 | ||
|
|
ac44de819d | ||
|
|
4ad53899da | ||
|
|
c253b7e05e | ||
|
|
647995133e | ||
|
|
9d3a049988 |
No files matched your search
+5
-8
@@ -2,7 +2,7 @@
|
||||
|
||||
# The last matching rule takes precedence.
|
||||
|
||||
* @Salazareo
|
||||
- @Salazareo
|
||||
|
||||
# GUI
|
||||
|
||||
@@ -18,10 +18,7 @@
|
||||
# Backend AI
|
||||
|
||||
/src/backend/controllers/puterai/ @404oops @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/drivers/ai/ @404oops @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/util/aiCostFactor* @404oops @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/util/aiPrices* @404oops @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/util/aiProviderKey* @404oops @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/drivers/ai-\*/ @404oops @ProgrammerIn-wonderland @Salazareo
|
||||
|
||||
# Backend: AI, hosting, workers, networking, and WebDAV
|
||||
|
||||
@@ -32,7 +29,7 @@
|
||||
/src/backend/drivers/workers/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/services/localworker/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/stores/subdomain/ @ProgrammerIn-wonderland @Salazareo
|
||||
/extensions/workerSandbox* @ProgrammerIn-wonderland @Salazareo
|
||||
/extensions/workerSandbox\* @ProgrammerIn-wonderland @Salazareo
|
||||
/src/worker/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/worker-types/ @ProgrammerIn-wonderland @Salazareo
|
||||
/src/backend/controllers/peer/ @ProgrammerIn-wonderland @Salazareo
|
||||
@@ -45,7 +42,7 @@
|
||||
/src/backend/services/permission/ @jfcastro92 @Salazareo
|
||||
/src/backend/services/acl/ @jfcastro92 @Salazareo
|
||||
/src/backend/services/team/ @jfcastro92 @Salazareo
|
||||
/src/backend/services/apps/AppPermissionService* @jfcastro92 @Salazareo
|
||||
/src/backend/services/apps/AppPermissionService\* @jfcastro92 @Salazareo
|
||||
/src/backend/stores/share/ @jfcastro92 @Salazareo
|
||||
/src/backend/stores/permission/ @jfcastro92 @Salazareo
|
||||
/src/backend/stores/group/ @jfcastro92 @Salazareo
|
||||
@@ -62,7 +59,7 @@
|
||||
/src/puter-js/tests/api/suites/hosting.suite.ts @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/tests/api/suites/workers.suite.ts @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/tests/api/suites/net.suite.ts @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/src/modules/Peer* @ProgrammerIn-wonderland @Salazareo
|
||||
/src/puter-js/src/modules/Peer\* @ProgrammerIn-wonderland @Salazareo
|
||||
|
||||
# SDK: sharing and permissions
|
||||
|
||||
|
||||
@@ -31,9 +31,10 @@ jobs:
|
||||
run: npm ci
|
||||
|
||||
- name: Run AI provider integration tests
|
||||
# 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')
|
||||
# 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
|
||||
env:
|
||||
CI: 'true'
|
||||
PUTER_TEST_AI_CLAUDE_API_KEY: ${{ secrets.PUTER_TEST_AI_CLAUDE_API_KEY }}
|
||||
@@ -48,15 +49,6 @@ 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
|
||||
|
||||
@@ -9,7 +9,6 @@ Use these as the source of truth before exploring further:
|
||||
- [README.md](README.md) — project overview and quickstart.
|
||||
- [doc/architecture.md](doc/architecture.md) — backend layered stack (controllers → drivers → services → stores → clients), `PuterServer` wiring, `Context` (ALS), actors, and extensions.
|
||||
- [doc/contributing-apis.md](doc/contributing-apis.md) — adding and maintaining public APIs end to end (backend surface → puter.js → types → docs → tests). Follow it for any API work.
|
||||
- [doc/ai-drivers.md](doc/ai-drivers.md) — where AI driver code lives (interfaces, providers, protocols, shared), the import rules between those folders, and how to add a provider or an AI interface.
|
||||
- [doc/pagination.md](doc/pagination.md) — the one pagination convention for list APIs (limit/cursor/offset/includeTotal, envelope shape, cursor semantics).
|
||||
- [doc/alarms.md](doc/alarms.md) — raising alarms and picking a severity (what pages, what only gets recorded), plus the config that routes them.
|
||||
- [doc/self-hosting.md](doc/self-hosting.md) — running Puter outside hosted infra.
|
||||
@@ -71,7 +70,7 @@ Commit messages and PR descriptions never credit anyone or mention a bug report.
|
||||
- **Test new behavior.** Every new function, endpoint, driver method, or logic branch gets a test; every bug fix gets a regression test that fails before the fix. If something is genuinely hard to test, skip it but say so in the PR.
|
||||
- **Boy Scout Rule, proportional to the change.** In files you're already touching, fix the typo, the dead import, the missing test, the bit you had to read twice. Don't ride a refactor along with a bug fix.
|
||||
- **Understand what you commit.** AI assistance is fine; shipping code you couldn't have written, debugged, or defended in review is not.
|
||||
- **A limit change is not done until the docs change with it.** Every rate limit, concurrency cap, quota, and allowance in the code is published in [src/docs/src/rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) — an undisclosed limit is one developers discover as a service failure. If a PR moves any of these numbers, the same PR updates that page. The numbers live in `src/backend/controllers/fs/limits.ts` (filesystem), `src/backend/drivers/ai/shared/aiLimits.ts` (all AI drivers), per-driver `rateLimit`/`concurrent` configs, and `src/backend/data/subPolicies/` (free-tier allowances).
|
||||
- **A limit change is not done until the docs change with it.** Every rate limit, concurrency cap, quota, and allowance in the code is published in [src/docs/src/rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) — an undisclosed limit is one developers discover as a service failure. If a PR moves any of these numbers, the same PR updates that page. The numbers live in `src/backend/controllers/fs/limits.ts` (filesystem), `src/backend/drivers/util/aiLimits.ts` (all AI drivers), per-driver `rateLimit`/`concurrent` configs, and `src/backend/data/subPolicies/` (free-tier allowances).
|
||||
|
||||
---
|
||||
|
||||
@@ -98,10 +97,6 @@ Layer rules:
|
||||
- **Drivers and controllers stay thin.** Domain rules a second entry point needs (signup, app CRUD, worker deploy, AI routing/billing) live in a service, not in the first driver or controller that needed them.
|
||||
- **Prefer explicit arguments over [`Context`](src/backend/core/context.ts) (ALS).** Use it only for request-scoped values that would otherwise thread through many layers; today that's mostly `actor` and `req`.
|
||||
|
||||
### AI drivers
|
||||
|
||||
AI drivers live in [src/backend/drivers/ai/](src/backend/drivers/ai/): `interfaces/` holds the Puter-facing drivers, `providers/<vendor>/<interface>/` holds vendor code, `protocols/` holds wire protocols several vendors speak, and `shared/` holds helpers more than one interface uses. Follow [doc/ai-drivers.md](doc/ai-drivers.md) when adding or moving AI code.
|
||||
|
||||
### Extensions
|
||||
|
||||
[extensions/](extensions/) is for **non-crucial parts of the system**: Puter must still work with the extension removed. If core needs to call it, it belongs in core ([whoami](extensions/whoami.ts) is the cautionary example). Inside an extension, follow the same layered structure, unless it only needs a few route handlers, in which case the `extension.get/post/...` helpers are enough on their own. The `extension` API is in [doc/architecture.md](doc/architecture.md#extension-api).
|
||||
|
||||
@@ -62,6 +62,10 @@
|
||||
// direct socket peer. NEVER set to `true` in prod — it trusts every hop
|
||||
// and makes X-Forwarded-For forgeable.
|
||||
"trust_proxy": false,
|
||||
// Request header a TLS-terminating proxy in front of the server sets to
|
||||
// the client's TLS fingerprint (JA3/JA4). Only set it when that proxy
|
||||
// overwrites any value the client sends. Unset disables it.
|
||||
// "tlsFingerprintHeader": "x-tls-fingerprint",
|
||||
"no_browser_launch": false,
|
||||
|
||||
// ── Dev watcher (devWatcher extension) ──────────────────────────────
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
# AI Drivers
|
||||
|
||||
Where the AI driver code lives under [src/backend/drivers/ai/](../src/backend/drivers/ai/), what each folder owns, and how to add a provider or a new AI interface. The general layer rules are in [architecture.md](architecture.md) and [AGENTS.md](../AGENTS.md#backend). This page covers what is specific to AI.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
src/backend/drivers/ai/
|
||||
├── interfaces/ Puter-facing drivers, one folder per AI interface
|
||||
├── providers/ Vendor code, as providers/<vendor>/<interface>/
|
||||
├── protocols/ Wire protocols that several vendors speak
|
||||
└── shared/ Helpers that more than one interface uses
|
||||
```
|
||||
|
||||
| Folder | Owns | Example |
|
||||
| --- | --- | --- |
|
||||
| `interfaces/<interface>/` | The driver class, the interface's contracts in `types.ts`, its provider base class where one exists, and the helpers and tests that apply to every provider of that interface. | [interfaces/chat/ChatCompletionDriver.ts](../src/backend/drivers/ai/interfaces/chat/ChatCompletionDriver.ts) |
|
||||
| `providers/<vendor>/<interface>/` | One vendor's implementation of one interface: the adapter, its model catalog and prices, its fixtures, and its tests. | [providers/gemini/video/](../src/backend/drivers/ai/providers/gemini/video/) |
|
||||
| `providers/<vendor>/` | Helpers that several of that vendor's interfaces use. | [providers/gemini/geminiRest.ts](../src/backend/drivers/ai/providers/gemini/geminiRest.ts) |
|
||||
| `protocols/openaiCompatible/` | The OpenAI wire protocol: the base provider class, message and usage conversion, and parameter mapping for Chat Completions and Responses. | [protocols/openaiCompatible/OpenAICompatProvider.ts](../src/backend/drivers/ai/protocols/openaiCompatible/OpenAICompatProvider.ts) |
|
||||
| `shared/` | Helpers that more than one interface uses: limits, the provider registry, the remote catalog cache, job polling, streamed charging, and media input. | [shared/aiLimits.ts](../src/backend/drivers/ai/shared/aiLimits.ts) |
|
||||
|
||||
Helpers that non-AI code also uses stay in [src/backend/drivers/util/](../src/backend/drivers/util/): file input, data URIs, upstream error mapping, and stream piping.
|
||||
|
||||
AI code that a service also needs lives in [src/backend/util/](../src/backend/util/), because a service can't import from the driver layer. The realtime relay in `services/realtime/` bills through the same cost factor as the drivers, so these three files are there:
|
||||
|
||||
| File | Holds |
|
||||
| --- | --- |
|
||||
| [aiCostFactor.ts](../src/backend/util/aiCostFactor.ts) | `withAiCostFactor`, the metering view every AI driver and the realtime relay record usage through, and `withAiCreditHold`. |
|
||||
| [aiProviderKey.ts](../src/backend/util/aiProviderKey.ts) | `readProviderKey`, which reads an API key from `config.providers` blocks. |
|
||||
| [aiPrices.ts](../src/backend/util/aiPrices.ts) | Vendor prices that a driver and a service both bill. |
|
||||
|
||||
### Interfaces
|
||||
|
||||
| Folder | Driver | `driverInterface` | `driverName` | Key in `puterDrivers` |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `interfaces/chat/` | `ChatCompletionDriver` | `puter-chat-completion` | `ai-chat` | `aiChat` |
|
||||
| `interfaces/image/` | `ImageGenerationDriver` | `puter-image-generation` | `ai-image` | `aiImage` |
|
||||
| `interfaces/video/` | `VideoGenerationDriver` | `puter-video-generation` | `ai-video` | `aiVideo` |
|
||||
| `interfaces/textToSpeech/` | `TTSDriver` | `puter-tts` | `ai-tts` | `aiTts` |
|
||||
| `interfaces/speechToText/` | `SpeechToTextDriver` | `puter-speech2txt` | `ai-speech2txt` | `aiSpeech2Txt` |
|
||||
| `interfaces/speechToSpeech/` | `VoiceChangerDriver` | `puter-speech2speech` | `ai-speech2speech` | `aiSpeech2Speech` |
|
||||
| `interfaces/ocr/` | `OCRDriver` | `puter-ocr` | `ai-ocr` | `aiOcr` |
|
||||
|
||||
Folder names are not runtime identifiers. `driverInterface`, `driverName`, driver aliases, the keys under `providers` in config, provider ids, model ids, and usage keys are public contracts. Moving or renaming a file never changes them.
|
||||
|
||||
## Request flow
|
||||
|
||||
A call goes from the interface to a provider, and from there to a shared protocol when the vendor speaks one:
|
||||
|
||||
```text
|
||||
interfaces/chat/ChatCompletionDriver.ts
|
||||
validates the call, picks the model and provider, holds credits
|
||||
→ providers/deepseek/chat/DeepSeekProvider.ts
|
||||
DeepSeek's catalog, endpoint, and request differences
|
||||
→ protocols/openaiCompatible/OpenAICompatProvider.ts
|
||||
the OpenAI-compatible request, response, and stream handling
|
||||
```
|
||||
|
||||
A provider whose vendor has its own API, such as `ClaudeProvider` or `ElevenLabsTTSProvider`, implements the interface's contract directly and uses no protocol.
|
||||
|
||||
## Import rules
|
||||
|
||||
- `shared/` imports nothing from `interfaces/`, `providers/`, or `protocols/`.
|
||||
- Nothing in `services/`, `stores/`, or `clients/` imports from `drivers/ai/`. Code that a service and a driver both need goes in `src/backend/util/`.
|
||||
- `providers/` and `protocols/` import an interface's `types.ts` and helpers. They never import a driver class. Their tests can, to drive a provider through the real driver.
|
||||
- Only an interface's driver file imports providers, to register them. The interface's helpers don't.
|
||||
- An interface doesn't import another interface. When a second interface needs a helper, move the helper to `shared/`, or to `providers/<vendor>/` when it belongs to one vendor.
|
||||
- A vendor folder imports another vendor's folder only when it serves that vendor's models. `providers/azure/` reads the OpenAI and xAI catalogs for that reason.
|
||||
|
||||
## Where a file goes
|
||||
|
||||
| The code is | It goes in |
|
||||
| --- | --- |
|
||||
| Input validation, gates, routing, credit holds, or anything else every provider of the interface gets | `interfaces/<interface>/` |
|
||||
| A request or response contract that providers implement | `interfaces/<interface>/types.ts` |
|
||||
| A vendor's endpoint, request shape, catalog, prices, or quirks | `providers/<vendor>/<interface>/` |
|
||||
| A vendor's auth, REST plumbing, or constants that two of its interfaces use | `providers/<vendor>/` |
|
||||
| Behavior of a wire protocol that several vendors speak | `protocols/<protocol>/` |
|
||||
| A helper that two or more interfaces use and that belongs to no vendor | `shared/` |
|
||||
| A helper that non-AI drivers or controllers also use | `src/backend/drivers/util/` |
|
||||
| AI code that a service also uses | `src/backend/util/`, in a file named `ai<Something>.ts` |
|
||||
|
||||
Keep a helper next to its only consumer. Move it up when a second consumer appears, not before.
|
||||
|
||||
When one Puter option becomes a different request shape per vendor, the interface reads the option and each dialect maps it. The chat `compaction` option is read in `interfaces/chat/utils/compaction.js`, mapped for OpenAI by `toOpenAiContextManagement` in `protocols/openaiCompatible/openaiParams.ts`, and mapped for Anthropic by `toAnthropicContextManagement` in `providers/claude/chat/contextManagement.ts`. Tools work the same way: `normalize_tools_object` in the interface, then `toolsToWire` for OpenAI and `claudeToolPolicy` for Anthropic.
|
||||
|
||||
Tests sit next to the code they test. Fixtures go in a `fixtures/` folder next to the test that reads them.
|
||||
|
||||
## Adding a provider
|
||||
|
||||
1. Create `providers/<vendor>/<interface>/`. Use the vendor's existing folder if it has one, so all of a vendor's interfaces stay together.
|
||||
2. Implement the interface's contract from `interfaces/<interface>/types.ts`:
|
||||
|
||||
| Interface | Contract | Base class |
|
||||
| --- | --- | --- |
|
||||
| chat | `IChatProvider` | `OpenAICompatProvider` for an OpenAI-compatible vendor |
|
||||
| image | `IImageProvider` | none |
|
||||
| video | `IVideoProvider` | `VideoProvider` |
|
||||
| textToSpeech | `ITTSProvider` | none |
|
||||
| speechToText | `ISpeechToTextProvider` | `SpeechToTextProvider` |
|
||||
| ocr | `IOcrProvider` | none |
|
||||
|
||||
Speech to speech has one provider, so `VoiceChangerDriver` uses `ElevenLabsSpeechToSpeechProvider` directly. Add a contract to its `types.ts` when a second provider needs one.
|
||||
|
||||
3. Put the model catalog and prices in the same folder, as `models.ts` or `costs.ts`. A provider that loads its catalog from the vendor wraps the fetch in `cachedRemoteCatalog` from `shared/`.
|
||||
4. Register the provider in the interface's driver: in `#registerProviders()`, or in `onServerStart()` for OCR and speech to speech. A chat provider built from an API key and an optional base URL is one row in `KEYED_PROVIDERS` instead. Text to speech and speech to text also list the provider id and its aliases in `providerAliases.ts`.
|
||||
5. Read credentials from `config.providers[<key>]`. The key is part of the self-hosting config contract, so reuse the vendor's existing key when it has one.
|
||||
6. Add tests next to the provider. Mock the vendor SDK or `fetch` at the network boundary and drive the provider through the real driver where you can. A live test is a `*.integration.test.ts` file that skips itself, with `skipUnlessEnv` from [testEnv.ts](../src/backend/testEnv.ts), unless its `PUTER_TEST_AI_*` variable is set. Helpers for provider tests are in [shared/integrationTestUtil.ts](../src/backend/drivers/ai/shared/integrationTestUtil.ts).
|
||||
7. For a chat provider, add its static catalog to `CATALOGS` in [modelCatalogs.test.ts](../src/backend/drivers/ai/interfaces/chat/modelCatalogs.test.ts) and the provider to [providerConsistency.test.ts](../src/backend/drivers/ai/interfaces/chat/providerConsistency.test.ts). The catalog test fails when a `providers/*/chat/` file with "model" in its name exports a catalog that isn't listed.
|
||||
8. Add the provider's config key to the `providers` config of the interface's [fuzz suite](#fuzz-tests), and its id to the list of providers the suite expects.
|
||||
9. Update the published docs for the models and limits the provider adds, following [contributing-apis.md](contributing-apis.md).
|
||||
|
||||
### OpenAI-compatible vendors
|
||||
|
||||
A vendor that speaks the OpenAI Chat Completions protocol extends `OpenAICompatProvider` and overrides only what differs. It still gets its own `providers/<vendor>/chat/` folder.
|
||||
|
||||
Vendors share one constants-only folder only when at least six of them differ in nothing but base URL and model catalog. Each then becomes one data file under `providers/openaiCompatible/providers/`, read by a single implementation. No vendor qualifies today, because every subclass overrides request, catalog, or billing behavior.
|
||||
|
||||
## Fuzz tests
|
||||
|
||||
Two seeded suites send malformed input through the real drivers and providers. They run with the rest of the backend tests.
|
||||
|
||||
| Suite | Covers |
|
||||
| --- | --- |
|
||||
| [interfaces/chat/ChatCompletionDriver.fuzz.test.ts](../src/backend/drivers/ai/interfaces/chat/ChatCompletionDriver.fuzz.test.ts) | Chat requests with a few values changed, and valid requests against wrong statuses, bodies of the wrong shape, and broken event streams. |
|
||||
| [interfaces/mediaDrivers.fuzz.test.ts](../src/backend/drivers/ai/interfaces/mediaDrivers.fuzz.test.ts) | Image, video, speech and OCR requests with a few values changed. Every vendor refuses the request. |
|
||||
|
||||
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. 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
|
||||
```
|
||||
|
||||
When a suite finds a crash, validate the value where the request enters, in the driver or in `normalize_single_message`, so every provider is covered. Reject only what crashed: a value a provider ignored or refused before must still reach it. An option a driver reads as text, a number or a flag goes in the driver's `SCALAR_OPTIONS`, and `assertScalarOptions` from `shared/scalarOptions.ts` answers 400 for a value there that can't be converted to text. Add a unit test for the case next to the fix.
|
||||
|
||||
## Adding an interface
|
||||
|
||||
1. Create `interfaces/<interface>/` with the driver class and a `types.ts` for the contracts providers implement.
|
||||
2. Register the driver in [src/backend/drivers/index.ts](../src/backend/drivers/index.ts).
|
||||
3. Use `AI_RATE_LIMIT` and `AI_CONCURRENT` from `shared/aiLimits.ts`, and publish any new limit in [rate-limits-and-quotas.md](../src/docs/src/rate-limits-and-quotas.md).
|
||||
4. Meter through `withAiCostFactor` from `src/backend/util/aiCostFactor.ts`, and route between providers with `ProviderRegistry` from `shared/providerRegistry.ts`.
|
||||
5. List the options the providers read as text, a number or a flag in a `SCALAR_OPTIONS` array, and call `assertScalarOptions` from `shared/scalarOptions.ts` first in each method. Add the driver to [mediaDrivers.fuzz.test.ts](../src/backend/drivers/ai/interfaces/mediaDrivers.fuzz.test.ts).
|
||||
6. Follow [contributing-apis.md](contributing-apis.md) for the puter.js method, types, docs, and tests.
|
||||
@@ -42,19 +42,6 @@ block-beta
|
||||
|
||||
Extensions sit alongside this stack and can register into any layer; see [Extensions](#extensions).
|
||||
|
||||
### AI drivers
|
||||
|
||||
The AI drivers have their own layout under [src/backend/drivers/ai/](../src/backend/drivers/ai/):
|
||||
|
||||
| Folder | Holds |
|
||||
| --- | --- |
|
||||
| `interfaces/` | The Puter-facing drivers, one folder per AI interface (chat, image, video, text to speech, speech to text, speech to speech, OCR). |
|
||||
| `providers/` | Vendor code, as `providers/<vendor>/<interface>/`. |
|
||||
| `protocols/` | Wire protocols that several vendors speak, such as the OpenAI-compatible one. |
|
||||
| `shared/` | Helpers that more than one interface uses. |
|
||||
|
||||
A call goes from an interface's driver to a provider, and from there to a shared protocol when the vendor speaks one. [ai-drivers.md](ai-drivers.md) has the ownership rules, the import rules, and the steps for adding a provider.
|
||||
|
||||
## Entry point: `PuterServer`
|
||||
|
||||
`PuterServer` is the bootstrap. It:
|
||||
|
||||
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
|
||||
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
|
||||
|
||||
/** Highest schema version the migration table can reach. */
|
||||
const CURRENT_SCHEMA_VERSION = 99;
|
||||
const CURRENT_SCHEMA_VERSION = 101;
|
||||
|
||||
/**
|
||||
* These suites migrate real files on disk. Idle they finish in well under a
|
||||
@@ -735,7 +735,12 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
|
||||
'`recipient_email`) VALUES (?, ?, ?)',
|
||||
['share-invite', user.id, '[email protected]'],
|
||||
);
|
||||
// The full schema as it stood before 0103, so only 0103 reruns.
|
||||
// The full schema as it stood before 0103, so 0103 reruns; 0104
|
||||
// is undone so it can apply again after it.
|
||||
await before.write('DROP INDEX idx_user_signup_tls_fingerprint');
|
||||
await before.write(
|
||||
'ALTER TABLE `user` DROP COLUMN `signup_tls_fingerprint`',
|
||||
);
|
||||
await before.write('PRAGMA user_version = 98');
|
||||
before.onServerShutdown();
|
||||
|
||||
|
||||
@@ -138,6 +138,8 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[96, ['0101_user-scoped-handlers.sql']],
|
||||
[97, ['0102_notification-scope-id-index.sql']],
|
||||
[98, ['0103_share-entry-owner-backfill.sql']],
|
||||
[99, ['0104_signup-tls-fingerprint.sql']],
|
||||
[100, ['0105_sessions_kind_debug_app.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
-- 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/>.
|
||||
|
||||
-- `user`.`signup_tls_fingerprint`. See
|
||||
-- sqlite/0104_signup-tls-fingerprint.sql for the rationale. No per-file
|
||||
-- applied-state tracking, so the column and index are added only when missing
|
||||
-- and a replay does nothing.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_signup_tls_fingerprint;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_user_signup_tls_fingerprint()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user'
|
||||
AND COLUMN_NAME = 'signup_tls_fingerprint'
|
||||
) THEN
|
||||
ALTER TABLE `user`
|
||||
ADD COLUMN `signup_tls_fingerprint` varchar(64) DEFAULT NULL,
|
||||
ALGORITHM=INSTANT;
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user'
|
||||
AND INDEX_NAME = 'idx_user_signup_tls_fingerprint'
|
||||
) THEN
|
||||
ALTER TABLE `user` ADD INDEX `idx_user_signup_tls_fingerprint`
|
||||
(`signup_tls_fingerprint`), ALGORITHM=INPLACE, LOCK=NONE;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_user_signup_tls_fingerprint();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_signup_tls_fingerprint;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Add 'debug_app' to the `sessions.kind` ENUM: a short-lived app session an
|
||||
-- operator creates on a user's behalf. Mirrors SQLite migration 0105.
|
||||
--
|
||||
-- Idempotent: guarded against COLUMN_TYPE so re-running the directory is a
|
||||
-- no-op once 'debug_app' is in the ENUM.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_sessions_kind_debug_app;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_sessions_kind_debug_app()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions'
|
||||
AND COLUMN_NAME = 'kind'
|
||||
AND FIND_IN_SET('debug_app', REPLACE(REPLACE(REPLACE(COLUMN_TYPE, 'enum(', ''), ')', ''), '''', '')) > 0
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
MODIFY COLUMN `kind`
|
||||
ENUM('web', 'app', 'access_token', 'asset', 'worker', 'debug_app')
|
||||
NOT NULL DEFAULT 'web',
|
||||
ALGORITHM=INSTANT;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_sessions_kind_debug_app();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_sessions_kind_debug_app;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 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/>.
|
||||
|
||||
-- "user".signup_tls_fingerprint. Mirrors SQLite migration 0104. Idempotent
|
||||
-- via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS signup_tls_fingerprint varchar(64);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_signup_tls_fingerprint ON "user" (signup_tls_fingerprint);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Add 'debug_app' to the `sessions.kind` CHECK constraint: a short-lived app
|
||||
-- session an operator creates on a user's behalf. Mirrors SQLite migration
|
||||
-- 0105. Drop-then-add keeps it idempotent across directory replays.
|
||||
|
||||
ALTER TABLE sessions DROP CONSTRAINT IF EXISTS sessions_kind_check;
|
||||
ALTER TABLE sessions ADD CONSTRAINT sessions_kind_check
|
||||
CHECK (kind IN ('web', 'app', 'access_token', 'asset', 'worker', 'debug_app'));
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 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/>.
|
||||
|
||||
-- `user`.`signup_tls_fingerprint`: the TLS fingerprint of the request that
|
||||
-- created the account, when a fronting proxy supplies one. Indexed so the
|
||||
-- accounts created from one TLS stack can be listed.
|
||||
ALTER TABLE `user` ADD COLUMN `signup_tls_fingerprint` varchar(64) DEFAULT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_signup_tls_fingerprint ON `user` (`signup_tls_fingerprint`);
|
||||
@@ -0,0 +1,89 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Add 'debug_app' to the `kind` CHECK constraint on `sessions`. Mirrors the
|
||||
-- MySQL ENUM extension in mysql_mig_59.
|
||||
--
|
||||
-- SQLite cannot ALTER a CHECK constraint in place, so we follow the same
|
||||
-- table-rebuild as migration 0056: create `sessions_new` with the corrected
|
||||
-- constraint, copy rows, drop the old table, rename, then recreate every
|
||||
-- index that lived on the original.
|
||||
|
||||
CREATE TABLE `sessions_new` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"user_id" INTEGER NOT NULL,
|
||||
"uuid" TEXT NOT NULL,
|
||||
"meta" JSON DEFAULT NULL,
|
||||
"created_at" INTEGER DEFAULT 0,
|
||||
"last_activity" INTEGER DEFAULT 0,
|
||||
"kind" TEXT NOT NULL DEFAULT 'web'
|
||||
CHECK (`kind` IN ('web', 'app', 'access_token', 'asset', 'worker', 'debug_app')),
|
||||
"label" TEXT,
|
||||
"parent_session_id" TEXT,
|
||||
"last_ip" TEXT,
|
||||
"last_user_agent" TEXT,
|
||||
"revoked_at" INTEGER,
|
||||
"expires_at" INTEGER,
|
||||
"app_uid" TEXT,
|
||||
"legacy_token_uid" TEXT,
|
||||
"created_via" TEXT,
|
||||
"auth_id" TEXT,
|
||||
"access_token_uid" TEXT,
|
||||
FOREIGN KEY("user_id") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO `sessions_new` (
|
||||
`id`, `user_id`, `uuid`, `meta`, `created_at`, `last_activity`,
|
||||
`kind`, `label`, `parent_session_id`, `last_ip`, `last_user_agent`,
|
||||
`revoked_at`, `expires_at`, `app_uid`, `legacy_token_uid`,
|
||||
`created_via`, `auth_id`, `access_token_uid`
|
||||
)
|
||||
SELECT
|
||||
`id`, `user_id`, `uuid`, `meta`, `created_at`, `last_activity`,
|
||||
`kind`, `label`, `parent_session_id`, `last_ip`, `last_user_agent`,
|
||||
`revoked_at`, `expires_at`, `app_uid`, `legacy_token_uid`,
|
||||
`created_via`, `auth_id`, `access_token_uid`
|
||||
FROM `sessions`;
|
||||
|
||||
DROP TABLE `sessions`;
|
||||
|
||||
ALTER TABLE `sessions_new` RENAME TO `sessions`;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_sessions_user_revoked`
|
||||
ON `sessions` (`user_id`, `revoked_at`);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_sessions_parent`
|
||||
ON `sessions` (`parent_session_id`);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_app_active`
|
||||
ON `sessions` (`user_id`, `app_uid`)
|
||||
WHERE `kind` = 'app' AND `revoked_at` IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_legacy_token_active`
|
||||
ON `sessions` (`legacy_token_uid`)
|
||||
WHERE `legacy_token_uid` IS NOT NULL AND `revoked_at` IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_sessions_kind_user`
|
||||
ON `sessions` (`kind`, `user_id`);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_sessions_access_token_uid`
|
||||
ON `sessions` (`access_token_uid`)
|
||||
WHERE `access_token_uid` IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_worker_active`
|
||||
ON `sessions` (`user_id`, IFNULL(`app_uid`, ''), json_extract(`meta`, '$.worker_name'))
|
||||
WHERE `kind` = 'worker' AND `revoked_at` IS NULL;
|
||||
@@ -241,6 +241,8 @@ export type EventMap = {
|
||||
trail_id?: string;
|
||||
/** Device signal forwarded verbatim from the signup request body. */
|
||||
fingerprint?: string | null;
|
||||
/** TLS fingerprint of the signup request, when a proxy supplies one. */
|
||||
tls_fingerprint?: string | null;
|
||||
/**
|
||||
* Set by the abuse harness — require SMS phone verification
|
||||
* post-signup.
|
||||
|
||||
@@ -132,6 +132,28 @@ describe('PreludeClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards a JA4 TLS fingerprint and drops any other shape', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
|
||||
const client = makeClient('sk_test');
|
||||
const ja4 = 't13d1516h2_8daaf6152771_02713d6af862';
|
||||
|
||||
await client.createVerification('+14155550123', {
|
||||
tls_fingerprint: ja4,
|
||||
});
|
||||
await client.createVerification('+14155550123', {
|
||||
ip: '203.0.113.7',
|
||||
// A JA3 hash.
|
||||
tls_fingerprint: 'e7d705a3286e19ea42f587b344ee6865',
|
||||
});
|
||||
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body).signals).toEqual({
|
||||
ja4_fingerprint: ja4,
|
||||
});
|
||||
expect(JSON.parse(fetchMock.mock.calls[1][1].body).signals).toEqual({
|
||||
ip: '203.0.113.7',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the signals object entirely when none are supplied', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
@@ -25,6 +25,8 @@ const PRELUDE_API_BASE = 'https://api.prelude.dev/v2';
|
||||
const REQUEST_TIMEOUT_MS = 8000;
|
||||
/** OTP length — matches the 6-box code UI. Prelude allows 4–8. */
|
||||
const PRELUDE_CODE_SIZE = 6;
|
||||
const JA4_SHAPE =
|
||||
/^[tqd][0-9a-z]{2}[di]\d{4}[0-9a-z]{2}_[0-9a-f]{12}_[0-9a-f]{12}$/;
|
||||
/**
|
||||
* Channel Prelude prioritizes for delivery. RCS is far cheaper than SMS, so we
|
||||
* prefer it by default; Prelude routes to the next reachable channel (SMS) when
|
||||
@@ -134,6 +136,8 @@ export class PreludeClient extends PuterClient {
|
||||
ip?: string;
|
||||
device_id?: string;
|
||||
user_agent?: string;
|
||||
/** Forwarded only when it is a JA4 value. */
|
||||
tls_fingerprint?: string;
|
||||
dispatch_id?: string;
|
||||
} = {},
|
||||
): Promise<{
|
||||
@@ -168,6 +172,8 @@ export class PreludeClient extends PuterClient {
|
||||
if (signals.ip) sig.ip = signals.ip;
|
||||
if (signals.device_id) sig.device_id = signals.device_id;
|
||||
if (signals.user_agent) sig.user_agent = signals.user_agent;
|
||||
if (signals.tls_fingerprint && JA4_SHAPE.test(signals.tls_fingerprint))
|
||||
sig.ja4_fingerprint = signals.tls_fingerprint;
|
||||
if (Object.keys(sig).length > 0) body.signals = sig;
|
||||
// dispatch_id is a top-level field, not a member of `signals`.
|
||||
if (signals.dispatch_id) body.dispatch_id = signals.dispatch_id;
|
||||
|
||||
@@ -722,6 +722,36 @@ describe('AuthController.handleSignup', () => {
|
||||
expect(audit.ip_fwd).toBe(forged);
|
||||
});
|
||||
|
||||
it('records the request TLS fingerprint on the validate hook and the user row', async () => {
|
||||
const username = `s_${uniq()}`;
|
||||
const tlsFingerprint = 't13d1516h2_8daaf6152771_02713d6af862';
|
||||
let seenTlsFingerprint: unknown;
|
||||
|
||||
await withSignupValidateOverride(
|
||||
(event) => {
|
||||
seenTlsFingerprint = (event as { tls_fingerprint?: unknown })
|
||||
.tls_fingerprint;
|
||||
},
|
||||
async () => {
|
||||
await controller.handleSignup(
|
||||
{
|
||||
...makeReq({
|
||||
username,
|
||||
email: `${username}@test.local`,
|
||||
password: 'correct-horse-battery',
|
||||
}),
|
||||
tlsFingerprint,
|
||||
},
|
||||
makeRes(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
expect(seenTlsFingerprint).toBe(tlsFingerprint);
|
||||
const persisted = await server.stores.user.getByUsername(username);
|
||||
expect(persisted!.signup_tls_fingerprint).toBe(tlsFingerprint);
|
||||
});
|
||||
|
||||
it('forces phone verification on every signup when always_require_phone_verification is set', async () => {
|
||||
const cfg = (controller as { config: Record<string, unknown> }).config;
|
||||
const prev = cfg.always_require_phone_verification;
|
||||
|
||||
@@ -1510,6 +1510,7 @@ export class AuthController extends PuterController {
|
||||
ip,
|
||||
device_id: req.deviceFingerprint ?? undefined,
|
||||
user_agent: userAgent,
|
||||
tls_fingerprint: req.tlsFingerprint,
|
||||
dispatch_id: dispatchId,
|
||||
},
|
||||
);
|
||||
@@ -4486,6 +4487,34 @@ export class AuthController extends PuterController {
|
||||
res.json({ token: guiToken });
|
||||
}
|
||||
|
||||
@Post('/auth/first-party-session', {
|
||||
subdomain: 'api',
|
||||
rateLimit: SESSION_LIMIT,
|
||||
// Hands out a session, so only the GUI's own page script may ask, and
|
||||
// only as the user's sign-in itself.
|
||||
guiOriginOnly: true,
|
||||
requireUserActor: true,
|
||||
})
|
||||
async handleFirstPartySession(req: Request, res: Response): Promise<void> {
|
||||
const { origin } = req.body ?? {};
|
||||
if (typeof origin !== 'string' || !origin) {
|
||||
throw new HttpError(400, 'Missing `origin`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const token = await this.services.auth.createFirstPartySessionToken(
|
||||
req.actor!,
|
||||
origin,
|
||||
{
|
||||
ip: req.ip || req.socket?.remoteAddress,
|
||||
user_agent: req.headers?.['user-agent'],
|
||||
origin: req.headers?.origin,
|
||||
host: req.headers?.host,
|
||||
},
|
||||
);
|
||||
res.json({ token });
|
||||
}
|
||||
|
||||
@Get('/session/sync-cookie', {
|
||||
rateLimit: SESSION_LIMIT,
|
||||
// Installs the session cookie. Only page script on our own origin
|
||||
@@ -4510,6 +4539,14 @@ export class AuthController extends PuterController {
|
||||
res.status(400).end();
|
||||
return;
|
||||
}
|
||||
// A session handed to a first-party page never gets a cookie.
|
||||
if (req.actor.session.parentUid) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'This session cannot install a session cookie',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
// As in `/get-gui-token`: the actor is the user's row as auth read it.
|
||||
const sessionToken = this.services.auth.createSessionTokenForSession(
|
||||
req.actor.user as UserRow,
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
const PAGE = 'https://desk.first-party.test';
|
||||
// Seeded with godmode on.
|
||||
const DEV_CENTER = 'app-0b37f054-07d4-4627-8765-11bd23e889d4';
|
||||
|
||||
/**
|
||||
* One of the deployment's own pages, served apart from the GUI, signs in with a
|
||||
* `web` session of its own under the user's sign-in.
|
||||
*/
|
||||
describe('first-party page sessions', () => {
|
||||
let env: PuterTestEnv;
|
||||
let userId = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
first_party_session_origins: [`${PAGE}/`],
|
||||
} as IConfig);
|
||||
const row = await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
);
|
||||
userId = row!.id;
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const call = (
|
||||
method: string,
|
||||
path: string,
|
||||
token: string,
|
||||
body?: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
method,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
...headers,
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
/** A sign-in of its own, so revoking it leaves the shared one up. */
|
||||
const signIn = async () => {
|
||||
const user = await env.server.stores.user.getById(userId);
|
||||
const { gui_token, session } =
|
||||
await env.server.services.auth.createSessionToken(user!);
|
||||
return { token: gui_token, uuid: session.uuid as string };
|
||||
};
|
||||
|
||||
const mint = async (token: string, origin = PAGE) => {
|
||||
const res = await call('POST', '/auth/first-party-session', token, {
|
||||
origin,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as { token: string }).token;
|
||||
};
|
||||
|
||||
const actorOf = async (token: string) =>
|
||||
(await env.server.services.auth.authenticate(token)).actor;
|
||||
|
||||
const sessions = async (token: string) => {
|
||||
const res = await call('GET', '/auth/list-sessions', token);
|
||||
expect(res.status).toBe(200);
|
||||
return (await res.json()) as Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
it('hands the page a session of its own under the sign-in', async () => {
|
||||
const root = await signIn();
|
||||
const token = await mint(root.token);
|
||||
|
||||
const actor = await actorOf(token);
|
||||
expect(actor?.app ?? null).toBeNull();
|
||||
expect(actor?.accessToken ?? null).toBeNull();
|
||||
expect(actor?.session).toMatchObject({
|
||||
kind: 'web',
|
||||
parentUid: root.uuid,
|
||||
});
|
||||
expect(actor?.session?.uid).not.toBe(root.uuid);
|
||||
|
||||
const row = (await sessions(root.token)).find(
|
||||
(s) => s.uuid === actor!.session!.uid,
|
||||
);
|
||||
expect(row).toMatchObject({
|
||||
kind: 'web',
|
||||
parent_session_id: root.uuid,
|
||||
first_party_origin: PAGE,
|
||||
current: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('reuses the session on a second ask', async () => {
|
||||
const root = await signIn();
|
||||
const first = await actorOf(await mint(root.token));
|
||||
const second = await actorOf(await mint(root.token));
|
||||
expect(second?.session?.uid).toBe(first?.session?.uid);
|
||||
});
|
||||
|
||||
it('acts as the account: launches apps, godmode ones included', async () => {
|
||||
const root = await signIn();
|
||||
const token = await mint(root.token);
|
||||
|
||||
const res = await call('POST', '/auth/get-user-app-token', token, {
|
||||
app_uid: DEV_CENTER,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({ godmode: true });
|
||||
});
|
||||
|
||||
it('refuses an origin that is not listed', async () => {
|
||||
const root = await signIn();
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/first-party-session',
|
||||
root.token,
|
||||
{
|
||||
origin: 'https://elsewhere.test',
|
||||
},
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('refuses a page on another origin, the listed page included', async () => {
|
||||
const root = await signIn();
|
||||
for (const origin of [PAGE, 'https://elsewhere.test']) {
|
||||
const res = await call(
|
||||
'POST',
|
||||
'/auth/first-party-session',
|
||||
root.token,
|
||||
{ origin: PAGE },
|
||||
{ origin },
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses app tokens, access tokens and a first-party session', async () => {
|
||||
const root = await signIn();
|
||||
const page = await mint(root.token);
|
||||
const app = (await (
|
||||
await call('POST', '/auth/get-user-app-token', root.token, {
|
||||
origin: 'https://some-app.test',
|
||||
})
|
||||
).json()) as { token: string };
|
||||
const godmode = (await (
|
||||
await call('POST', '/auth/get-user-app-token', root.token, {
|
||||
app_uid: DEV_CENTER,
|
||||
})
|
||||
).json()) as { token: string };
|
||||
|
||||
for (const token of [page, app.token, godmode.token]) {
|
||||
const res = await call('POST', '/auth/first-party-session', token, {
|
||||
origin: PAGE,
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
it('never gets a session cookie', async () => {
|
||||
const root = await signIn();
|
||||
const page = await mint(root.token);
|
||||
|
||||
const syncCookie = (token: string) =>
|
||||
fetch(new URL('/session/sync-cookie', env.origin), {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect((await syncCookie(root.token)).status).toBe(204);
|
||||
|
||||
const res = await syncCookie(page);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.headers.get('set-cookie')).toBeNull();
|
||||
});
|
||||
|
||||
it('ends with the sign-in it was handed out under', async () => {
|
||||
const root = await signIn();
|
||||
const page = await mint(root.token);
|
||||
expect(await actorOf(page)).toBeDefined();
|
||||
|
||||
await env.server.services.auth.revokeSession(root.uuid);
|
||||
const result = await env.server.services.auth.authenticate(page);
|
||||
expect(result.actor).toBeUndefined();
|
||||
expect(result.reauth?.reason).toBe('session_revoked');
|
||||
});
|
||||
|
||||
it('takes the tokens minted under it along when its sign-in ends', async () => {
|
||||
const root = await signIn();
|
||||
const page = await mint(root.token);
|
||||
const godmode = (await (
|
||||
await call('POST', '/auth/get-user-app-token', page, {
|
||||
app_uid: DEV_CENTER,
|
||||
})
|
||||
).json()) as { token: string };
|
||||
const scoped = (await (
|
||||
await call('POST', '/auth/create-access-token', godmode.token, {
|
||||
permissions: ['service:foo:ii:read'],
|
||||
expiresIn: '7d',
|
||||
})
|
||||
).json()) as { token: string };
|
||||
expect(await actorOf(scoped.token)).toBeDefined();
|
||||
|
||||
await env.server.services.auth.revokeSession(root.uuid);
|
||||
for (const token of [godmode.token, scoped.token]) {
|
||||
expect(
|
||||
(await env.server.services.auth.authenticate(token)).actor,
|
||||
).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('is revoked on its own without ending the sign-in', async () => {
|
||||
const root = await signIn();
|
||||
const page = await mint(root.token);
|
||||
const pageUuid = (await actorOf(page))!.session!.uid;
|
||||
|
||||
await env.server.services.auth.revokeSession(pageUuid);
|
||||
expect(
|
||||
(await env.server.services.auth.authenticate(page)).actor,
|
||||
).toBeUndefined();
|
||||
expect(await actorOf(root.token)).toBeDefined();
|
||||
});
|
||||
|
||||
it('keeps the whole current sign-in when revoking all others', async () => {
|
||||
const root = await signIn();
|
||||
const page = await mint(root.token);
|
||||
const other = await signIn();
|
||||
const otherPage = await mint(other.token);
|
||||
|
||||
// Asked from the page: its sign-in stays, and so does the page.
|
||||
await env.server.services.auth.revokeAllSessions(
|
||||
(await actorOf(page))!,
|
||||
);
|
||||
expect(await actorOf(page)).toBeDefined();
|
||||
expect(await actorOf(root.token)).toBeDefined();
|
||||
expect(
|
||||
(await env.server.services.auth.authenticate(other.token)).actor,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(await env.server.services.auth.authenticate(otherPage)).actor,
|
||||
).toBeUndefined();
|
||||
|
||||
// Asked from the sign-in: the page under it stays too.
|
||||
await env.server.services.auth.revokeAllSessions(
|
||||
(await actorOf(root.token))!,
|
||||
);
|
||||
expect(await actorOf(page)).toBeDefined();
|
||||
});
|
||||
|
||||
it('is not counted as a sign-in', async () => {
|
||||
const root = await signIn();
|
||||
const before = await env.server.stores.session.listSignIns(userId, {
|
||||
limit: 100,
|
||||
});
|
||||
await mint(root.token);
|
||||
const after = await env.server.stores.session.listSignIns(userId, {
|
||||
limit: 100,
|
||||
});
|
||||
expect(after.length).toBe(before.length);
|
||||
});
|
||||
});
|
||||
@@ -324,7 +324,7 @@ describe('DriverController — concurrent acquire/release', () => {
|
||||
const driver = {
|
||||
...makeSyntheticDriver(),
|
||||
ping: async () => {
|
||||
Context.get('keepConcurrencySlot')?.(
|
||||
Context.get('keepDriverSlot')?.(
|
||||
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 '../../core/http/callSlot.js';
|
||||
import { CallSlot } from './callSlot.js';
|
||||
|
||||
type DriverInstance = WithLifecycle & Record<string, unknown>;
|
||||
|
||||
@@ -446,7 +446,7 @@ export class DriverController extends PuterController {
|
||||
});
|
||||
}
|
||||
slot.attach(handle, res);
|
||||
Context.set('keepConcurrencySlot', (work) => slot.keep(work));
|
||||
Context.set('keepDriverSlot', (work) => slot.keep(work));
|
||||
}
|
||||
|
||||
// Stash the requested driver name in Context so multi-provider
|
||||
|
||||
File renamed without changes.
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { Response } from 'express';
|
||||
import { onResponseClose } from '../../core/http/onResponseClose.js';
|
||||
|
||||
// Under the concurrency store's one-hour orphan TTL.
|
||||
const SLOT_RENEW_MS = 10 * 60 * 1000;
|
||||
@@ -28,9 +29,9 @@ interface SlotHandle {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export class CallSlot {
|
||||
#handle?: SlotHandle;
|
||||
@@ -46,7 +47,7 @@ export class CallSlot {
|
||||
this.#maybeRelease();
|
||||
};
|
||||
res.once('finish', responded);
|
||||
res.once('close', responded);
|
||||
onResponseClose(res, responded);
|
||||
if (res.destroyed || res.writableFinished) responded();
|
||||
if (!this.#released && this.#work.size > 0) this.#startRenewing();
|
||||
}
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
} from 'vitest';
|
||||
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import type { ChatCompletionDriver } from '../../drivers/ai/interfaces/chat/ChatCompletionDriver.js';
|
||||
import type { ChatCompletionDriver } from '../../drivers/ai-chat/ChatCompletionDriver.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { inRequestScope, setupTestServer } from '../../testUtil.js';
|
||||
import { PuterAIController } from './PuterAIController.js';
|
||||
|
||||
@@ -49,8 +49,8 @@ import type { RouteOptions } from '../../core/http/index.js';
|
||||
import {
|
||||
COUNT_TOKENS,
|
||||
type ChatCompletionDriver,
|
||||
} from '../../drivers/ai/interfaces/chat/ChatCompletionDriver.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../../drivers/ai/shared/aiLimits.js';
|
||||
} from '../../drivers/ai-chat/ChatCompletionDriver.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../../drivers/util/aiLimits.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { inRequestScope, setupTestServer } from '../../testUtil.js';
|
||||
import { PuterAIController } from './PuterAIController.js';
|
||||
|
||||
@@ -30,28 +30,25 @@ import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import {
|
||||
COUNT_TOKENS,
|
||||
type ChatCompletionDriver,
|
||||
} from '../../drivers/ai/interfaces/chat/ChatCompletionDriver.js';
|
||||
} from '../../drivers/ai-chat/ChatCompletionDriver.js';
|
||||
import type {
|
||||
IChatCompleteResult,
|
||||
IChatMessageResult,
|
||||
IChatModel,
|
||||
ICompleteArguments,
|
||||
UsageDetails,
|
||||
} from '../../drivers/ai/interfaces/chat/types.js';
|
||||
} from '../../drivers/ai-chat/types.js';
|
||||
import {
|
||||
outputFormatFromResponseFormat,
|
||||
rejectStatefulResponsesFields,
|
||||
toolChoiceFromWire,
|
||||
} from '../../drivers/ai/protocols/openaiCompatible/openaiParams.js';
|
||||
} from '../../drivers/ai-chat/utils/openaiParams.js';
|
||||
import {
|
||||
promoteStopForToolCalls,
|
||||
toFinishReason,
|
||||
} from '../../drivers/ai/interfaces/chat/utils/stopReason.js';
|
||||
} from '../../drivers/ai-chat/utils/stopReason.js';
|
||||
import { isDriverStreamResult } from '../../drivers/meta.js';
|
||||
import {
|
||||
AI_CONCURRENT,
|
||||
AI_RATE_LIMIT,
|
||||
} from '../../drivers/ai/shared/aiLimits.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../../drivers/util/aiLimits.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { parseAnthropicRequest, toAnthropicMessage } from './anthropicWire.js';
|
||||
import { pipeNdjsonStream } from './ndjsonStream.js';
|
||||
@@ -1304,18 +1301,13 @@ export class PuterAIController extends PuterController {
|
||||
return driver;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** A completion that stops, and stops billing, when the caller hangs up. */
|
||||
#complete(
|
||||
res: Response,
|
||||
args: ICompleteArguments,
|
||||
): Promise<IChatCompleteResult> {
|
||||
Context.set('abortSignal', abortOnDisconnect(res));
|
||||
const completion = this.#driver().complete(args);
|
||||
Context.get('keepConcurrencySlot')?.(completion);
|
||||
return completion;
|
||||
return this.#driver().complete(args);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { IChatMessageResult } from '../../drivers/ai/interfaces/chat/types.js';
|
||||
import type { IChatMessageResult } from '../../drivers/ai-chat/types.js';
|
||||
import {
|
||||
anthropicUsage,
|
||||
parseAnthropicRequest,
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
import type { IncomingHttpHeaders } from 'node:http';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { sanitizeCacheControl } from '../../drivers/ai/providers/claude/chat/anthropicPolicy.js';
|
||||
import { sanitizeCacheControl } from '../../drivers/ai-chat/providers/claude/anthropicPolicy.js';
|
||||
import type {
|
||||
ICompleteArguments,
|
||||
IChatMessageResult,
|
||||
@@ -42,9 +42,9 @@ import type {
|
||||
ThinkingConfig,
|
||||
ToolChoice,
|
||||
UsageDetails,
|
||||
} from '../../drivers/ai/interfaces/chat/types.js';
|
||||
import { needsOpenAICoercion } from '../../drivers/ai/interfaces/chat/utils/normalizeToOpenAI.js';
|
||||
import { fromFinishReason } from '../../drivers/ai/interfaces/chat/utils/stopReason.js';
|
||||
} from '../../drivers/ai-chat/types.js';
|
||||
import { needsOpenAICoercion } from '../../drivers/ai-chat/utils/normalizeToOpenAI.js';
|
||||
import { fromFinishReason } from '../../drivers/ai-chat/utils/stopReason.js';
|
||||
|
||||
const toStringOrEmpty = (v: unknown): string =>
|
||||
typeof v === 'string' ? v : '';
|
||||
|
||||
@@ -20,14 +20,15 @@
|
||||
/**
|
||||
* SSE transport (`startSse`) plus the Anthropic `/v1/messages` streaming writer
|
||||
* (`AnthropicSseWriter`), which turns the driver's NDJSON chunk protocol
|
||||
* (`drivers/ai/interfaces/chat/utils/Streaming.js`) into the exact Anthropic
|
||||
* event sequence: `message_start` → `content_block_start/delta/stop`* →
|
||||
* (`drivers/ai-chat/utils/Streaming.js`) into the exact Anthropic event
|
||||
* sequence: `message_start` → `content_block_start/delta/stop`* →
|
||||
* `message_delta` → `message_stop`.
|
||||
*/
|
||||
|
||||
import type { Response } from 'express';
|
||||
import type { UsageDetails } from '../../drivers/ai/interfaces/chat/types.js';
|
||||
import { promoteStopForToolCalls } from '../../drivers/ai/interfaces/chat/utils/stopReason.js';
|
||||
import { onResponseClose } from '../../core/http/onResponseClose.js';
|
||||
import type { UsageDetails } from '../../drivers/ai-chat/types.js';
|
||||
import { promoteStopForToolCalls } from '../../drivers/ai-chat/utils/stopReason.js';
|
||||
import { anthropicUsage } from './anthropicWire.js';
|
||||
|
||||
// -- SSE transport ------------------------------------------------------------
|
||||
@@ -79,7 +80,7 @@ export const startSse = (res: Response, opts: { ping?: boolean } = {}): Sse => {
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
res.once('close', () => {
|
||||
onResponseClose(res, () => {
|
||||
ended = true;
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* 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 { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
RESPONSE_CLOSE_LISTENER_BUDGET,
|
||||
setupPuterTestEnv,
|
||||
TEST_USER_CREDENTIALS,
|
||||
trackResponseCloseListeners,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
|
||||
/** File downloads over WebDAV: the bytes, ranges, and either end failing. */
|
||||
describe('WebDAV GET over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const dav = (path: string, init: RequestInit = {}) => {
|
||||
const port = new URL(env.apiOrigin).port;
|
||||
const auth = Buffer.from(
|
||||
`${TEST_USER_CREDENTIALS.username}:${TEST_USER_CREDENTIALS.password}`,
|
||||
).toString('base64');
|
||||
return fetch(`http://dav.puter.localhost:${port}${encodeURI(path)}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`,
|
||||
// Uncompressed, so the bytes on the wire are the stored ones.
|
||||
'Accept-Encoding': 'identity',
|
||||
...(init.headers as Record<string, string> | undefined),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const writeFile = async (name: string, content: Buffer) => {
|
||||
const { username } = env.users.user;
|
||||
const user = await env.server.stores.user.getByUsername(username);
|
||||
const path = `/${username}/Documents/${name}`;
|
||||
const { fsEntry } = await env.server.services.fs.write(user!.id, {
|
||||
fileMetadata: {
|
||||
path,
|
||||
size: content.byteLength,
|
||||
contentType: 'application/octet-stream',
|
||||
},
|
||||
fileContent: content,
|
||||
});
|
||||
return { path, uuid: fsEntry.uuid };
|
||||
};
|
||||
|
||||
it('returns the stored bytes, whole or as a range', async () => {
|
||||
const content = randomBytes(128 * 1024 + 3);
|
||||
const file = await writeFile('dav-get.bin', content);
|
||||
|
||||
const whole = await dav(file.path);
|
||||
expect(whole.status).toBe(200);
|
||||
expect(whole.headers.get('content-length')).toBe(
|
||||
String(content.byteLength),
|
||||
);
|
||||
expect(Buffer.from(await whole.arrayBuffer())).toEqual(content);
|
||||
|
||||
const range = await dav(file.path, {
|
||||
headers: { Range: 'bytes=10-19' },
|
||||
});
|
||||
expect(range.status).toBe(206);
|
||||
expect(range.headers.get('content-range')).toBe(
|
||||
`bytes 10-19/${content.byteLength}`,
|
||||
);
|
||||
expect(Buffer.from(await range.arrayBuffer())).toEqual(
|
||||
content.subarray(10, 20),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Storage hands over one chunk of `file` and then goes quiet, as a large
|
||||
* object does while the client is still reading.
|
||||
*/
|
||||
const stallStorage = (file: { uuid: string }) => {
|
||||
const s3 = env.server.stores.s3Object;
|
||||
const client = env.server.clients.s3.get(s3.resolveRegion(null));
|
||||
const send = client.send.bind(client);
|
||||
const bodies: Readable[] = [];
|
||||
const spy = vi.spyOn(client, 'send').mockImplementation(((
|
||||
command: unknown,
|
||||
) => {
|
||||
if (
|
||||
!(command instanceof GetObjectCommand) ||
|
||||
command.input.Key !== file.uuid
|
||||
) {
|
||||
return send(command as never);
|
||||
}
|
||||
let pushed = false;
|
||||
const body = new Readable({
|
||||
read() {
|
||||
if (!pushed) {
|
||||
pushed = true;
|
||||
this.push(Buffer.alloc(16 * 1024, 1));
|
||||
}
|
||||
},
|
||||
});
|
||||
bodies.push(body);
|
||||
return Promise.resolve({
|
||||
Body: body,
|
||||
ContentLength: 1024 * 1024,
|
||||
ContentType: 'application/octet-stream',
|
||||
});
|
||||
}) as never);
|
||||
return { bodies, restore: () => spy.mockRestore() };
|
||||
};
|
||||
|
||||
const expectFullDownload = async (path: string) => {
|
||||
const response = await dav(path);
|
||||
expect(response.status).toBe(200);
|
||||
expect((await response.arrayBuffer()).byteLength).toBe(1024 * 1024);
|
||||
};
|
||||
|
||||
it('releases the storage stream when the client aborts', async () => {
|
||||
const file = await writeFile(
|
||||
'dav-abort.bin',
|
||||
Buffer.alloc(1024 * 1024, 1),
|
||||
);
|
||||
const storage = stallStorage(file);
|
||||
try {
|
||||
const abort = new AbortController();
|
||||
const response = await dav(file.path, { signal: abort.signal });
|
||||
expect(response.status).toBe(200);
|
||||
await response.body!.getReader().read();
|
||||
abort.abort();
|
||||
|
||||
expect(storage.bodies).toHaveLength(1);
|
||||
await vi.waitFor(() =>
|
||||
expect(storage.bodies[0]!.destroyed).toBe(true),
|
||||
);
|
||||
} finally {
|
||||
storage.restore();
|
||||
}
|
||||
|
||||
await expectFullDownload(file.path);
|
||||
});
|
||||
|
||||
it('cuts the download off when storage fails mid-stream', async () => {
|
||||
const file = await writeFile(
|
||||
'dav-reset.bin',
|
||||
Buffer.alloc(1024 * 1024, 1),
|
||||
);
|
||||
const storage = stallStorage(file);
|
||||
try {
|
||||
const response = await dav(file.path);
|
||||
expect(response.status).toBe(200);
|
||||
const reader = response.body!.getReader();
|
||||
await reader.read();
|
||||
storage.bodies[0]!.destroy(new Error('upstream reset'));
|
||||
|
||||
const outcome = await (async () => {
|
||||
try {
|
||||
while (!(await reader.read()).done);
|
||||
return 'ended';
|
||||
} catch {
|
||||
return 'cut off';
|
||||
}
|
||||
})();
|
||||
expect(outcome).toBe('cut off');
|
||||
} finally {
|
||||
storage.restore();
|
||||
}
|
||||
|
||||
await expectFullDownload(file.path);
|
||||
});
|
||||
|
||||
it('stays within the close listener budget per response', async () => {
|
||||
const file = await writeFile('dav-listeners.bin', randomBytes(4096));
|
||||
const tracker = trackResponseCloseListeners();
|
||||
try {
|
||||
const response = await dav(file.path);
|
||||
expect(response.status).toBe(200);
|
||||
await response.arrayBuffer();
|
||||
} finally {
|
||||
tracker.restore();
|
||||
}
|
||||
const peak = tracker.peak(
|
||||
(req) => req.method === 'GET' && req.url === file.path,
|
||||
);
|
||||
expect(peak).toBeGreaterThan(0);
|
||||
expect(peak).toBeLessThanOrEqual(RESPONSE_CLOSE_LISTENER_BUDGET);
|
||||
});
|
||||
});
|
||||
@@ -1365,6 +1365,7 @@ describe('WebDAVController verbs', () => {
|
||||
callback();
|
||||
},
|
||||
});
|
||||
sink.on('error', () => {});
|
||||
sink.on('finish', () => {
|
||||
captured.ended = true;
|
||||
});
|
||||
|
||||
@@ -21,7 +21,6 @@ import type { Team2faLookup } from '../../core/http/middleware/gates.js';
|
||||
import { compare as bcryptCompare } from 'bcrypt';
|
||||
import type { Request, Response } from 'express';
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { pipeToResponse } from '../../core/http/pipeToResponse.js';
|
||||
@@ -551,12 +550,7 @@ export class WebDAVController extends PuterController {
|
||||
'Content-Length': String(result.contentLength ?? 0),
|
||||
});
|
||||
}
|
||||
try {
|
||||
await pipeline(result.body, res);
|
||||
} catch {
|
||||
// Client disconnect or upstream stream error: pipeline already
|
||||
// tore down both ends, so nothing is left dangling to crash on.
|
||||
}
|
||||
await pipeToResponse(result.body, res);
|
||||
}
|
||||
|
||||
// -- PROPFIND ----------------------------------------------------
|
||||
|
||||
@@ -76,8 +76,14 @@ export interface Actor {
|
||||
/**
|
||||
* Set when authenticated by a session token or an app-under-user token
|
||||
* carrying one. `kind` mirrors the session row (`web`, `app`, `worker`).
|
||||
* `parentUid` is the sign-in a `web` session was handed out under, set only
|
||||
* on a first-party page's session.
|
||||
*/
|
||||
session?: { uid: string; kind?: string | null } | null;
|
||||
session?: {
|
||||
uid: string;
|
||||
kind?: string | null;
|
||||
parentUid?: string | null;
|
||||
} | null;
|
||||
/**
|
||||
* Events handler runs behind this actor's writes; set only from an events
|
||||
* handler token or a token one minted. Provenance, not authority, so
|
||||
|
||||
@@ -69,11 +69,11 @@ export interface KnownContextFields {
|
||||
*/
|
||||
abortSignal: AbortSignal;
|
||||
/**
|
||||
* 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.
|
||||
* Keeps the call's concurrency slot until `work` settles (set by
|
||||
* DriverController when the method is capped), for work that outlives the
|
||||
* response.
|
||||
*/
|
||||
keepConcurrencySlot: (work: Promise<unknown>) => void;
|
||||
keepDriverSlot: (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,
|
||||
keepConcurrencySlot: true,
|
||||
keepDriverSlot: true,
|
||||
upstreamSignal: true,
|
||||
strictUpstreamErrors: true,
|
||||
platformWrite: true,
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import type { Response } from 'express';
|
||||
import { HttpError } from './HttpError.js';
|
||||
import { onResponseClose } from './onResponseClose.js';
|
||||
|
||||
/**
|
||||
* A signal that fires when the caller hangs up before its response is done, so
|
||||
@@ -38,6 +39,6 @@ export const abortOnDisconnect = (res: Response): AbortSignal => {
|
||||
}
|
||||
};
|
||||
if (res.destroyed) onClose();
|
||||
else res.once('close', onClose);
|
||||
else onResponseClose(res, onClose);
|
||||
return abort.signal;
|
||||
};
|
||||
@@ -104,6 +104,13 @@ declare global {
|
||||
*/
|
||||
deviceFingerprint?: string;
|
||||
|
||||
/**
|
||||
* The client's TLS fingerprint, read from the header named by
|
||||
* `tlsFingerprintHeader`. Shared by every client on the same TLS
|
||||
* stack, so it identifies software, not a person.
|
||||
*/
|
||||
tlsFingerprint?: string;
|
||||
|
||||
/**
|
||||
* Parsed cookies, populated by the global `cookie-parser`
|
||||
* middleware.
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '../../../services/metering/costs.js';
|
||||
import type { UsageInput } from '../../../services/metering/types';
|
||||
import '../expressAugmentation';
|
||||
import { onResponseClose } from '../onResponseClose';
|
||||
|
||||
/**
|
||||
* Subset of the metering service this middleware needs. Metering is optional
|
||||
@@ -195,7 +196,7 @@ export const createEgressMeteringMiddleware = (
|
||||
// 'close' rather than 'finish': it fires for a response that completed
|
||||
// AND for one whose connection died mid-stream, and an aborted download
|
||||
// still sent whatever it sent.
|
||||
res.once('close', () => {
|
||||
onResponseClose(res, () => {
|
||||
try {
|
||||
if (!isMeteredHost(req)) return;
|
||||
const actor = resolveEgressActor(req);
|
||||
|
||||
@@ -23,8 +23,11 @@ import { createFingerprintMiddleware } from './fingerprint';
|
||||
|
||||
const VALID_FP = 'dev-fingerprint-abc123';
|
||||
|
||||
const run = (reqPartial: Partial<Request> & Record<string, unknown>) => {
|
||||
const middleware = createFingerprintMiddleware();
|
||||
const run = (
|
||||
reqPartial: Partial<Request> & Record<string, unknown>,
|
||||
opts?: Parameters<typeof createFingerprintMiddleware>[0],
|
||||
) => {
|
||||
const middleware = createFingerprintMiddleware(opts);
|
||||
const req = {
|
||||
headers: {},
|
||||
...reqPartial,
|
||||
@@ -90,4 +93,21 @@ describe('createFingerprintMiddleware', () => {
|
||||
const { req } = run({ ip: '203.0.113.1' });
|
||||
expect(req.deviceFingerprint).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads the TLS fingerprint from the configured header only', () => {
|
||||
const ja4 = 't13d1516h2_8daaf6152771_02713d6af862';
|
||||
const headers = { 'x-tls-fp': ja4 };
|
||||
|
||||
expect(run({ headers }).req.tlsFingerprint).toBeUndefined();
|
||||
expect(
|
||||
run({ headers }, { tlsFingerprintHeader: 'X-TLS-FP' }).req
|
||||
.tlsFingerprint,
|
||||
).toBe(ja4);
|
||||
expect(
|
||||
run(
|
||||
{ headers: { 'x-tls-fp': 'not a fingerprint!' } },
|
||||
{ tlsFingerprintHeader: 'x-tls-fp' },
|
||||
).req.tlsFingerprint,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,20 @@ const DEVICE_FINGERPRINT_SHAPE = /^[A-Za-z0-9._-]{8,128}$/;
|
||||
/** Header the GUI may send the device fingerprint on for non-signup requests. */
|
||||
const DEVICE_FINGERPRINT_HEADER = 'x-puter-device-fingerprint';
|
||||
|
||||
/** JA3 / JA4-style values; anything else is dropped. */
|
||||
const TLS_FINGERPRINT_SHAPE = /^[A-Za-z0-9_]{10,64}$/;
|
||||
|
||||
function readTlsFingerprint(
|
||||
req: { headers?: Record<string, unknown> },
|
||||
header: string | undefined,
|
||||
): string | undefined {
|
||||
if (!header) return undefined;
|
||||
const value = req.headers?.[header];
|
||||
return typeof value === 'string' && TLS_FINGERPRINT_SHAPE.test(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readDeviceFingerprint(req: {
|
||||
body?: unknown;
|
||||
headers?: Record<string, unknown>;
|
||||
@@ -65,15 +79,22 @@ function readDeviceFingerprint(req: {
|
||||
* and spoofable, but stable per real device across IP rotation. The rate
|
||||
* limiter's 'fingerprint' strategy appends it to the network hash so each
|
||||
* device behind a shared network gets its own bucket.
|
||||
* - `req.tlsFingerprint` — set only when `tlsFingerprintHeader` is configured and
|
||||
* the request carries a well-shaped value in that header. It names the
|
||||
* client's TLS stack, so every client on the same build shares it.
|
||||
*
|
||||
* Install AFTER the body parsers (so the body fingerprint is readable) and
|
||||
* before `requestContext` (so the snapshot into ALS already carries them).
|
||||
* Never rejects — a missing/invalid device fingerprint is simply absent.
|
||||
*/
|
||||
export const createFingerprintMiddleware = (): RequestHandler => {
|
||||
export const createFingerprintMiddleware = (
|
||||
opts: { tlsFingerprintHeader?: string } = {},
|
||||
): RequestHandler => {
|
||||
const tlsHeader = opts.tlsFingerprintHeader?.toLowerCase();
|
||||
return (req, _res, next) => {
|
||||
req.networkFingerprint = computeNetworkFingerprint(req);
|
||||
req.deviceFingerprint = readDeviceFingerprint(req);
|
||||
req.tlsFingerprint = readTlsFingerprint(req, tlsHeader);
|
||||
next();
|
||||
};
|
||||
};
|
||||
@@ -20,6 +20,7 @@
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { actorUid } from '../../actor';
|
||||
import { HttpError } from '../HttpError';
|
||||
import { onResponseClose } from '../onResponseClose';
|
||||
import { createJsonBodyParser } from './jsonBody';
|
||||
|
||||
/** The global JSON parser's limit, which applies before auth. */
|
||||
@@ -152,7 +153,7 @@ export const createLargeJsonBodyParser = ({
|
||||
}
|
||||
inFlightBytes += declared;
|
||||
inFlightByUser.set(user, userBytes + declared);
|
||||
res.once('close', () => {
|
||||
onResponseClose(res, () => {
|
||||
inFlightBytes -= declared;
|
||||
const left = (inFlightByUser.get(user) ?? 0) - declared;
|
||||
if (left > 0) inFlightByUser.set(user, left);
|
||||
|
||||
@@ -26,7 +26,7 @@ import type { IConfig } from '../../../types';
|
||||
* Browsers send none of those variations, but config values do — a hand-edited
|
||||
* `origin` with a trailing slash would otherwise reject every real request.
|
||||
*/
|
||||
const normalizeOrigin = (raw: string | undefined): string =>
|
||||
export const normalizeOrigin = (raw: string | undefined): string =>
|
||||
(raw ?? '').trim().replace(/\/+$/, '').toLowerCase();
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,9 +20,8 @@
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -414,8 +413,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 and the driver controller
|
||||
// both hold the slot in a `CallSlot`.
|
||||
// across a request boundary. The route middleware hooks `res.finish` /
|
||||
// `res.close`; the driver helper wraps the invocation in `try/finally`.
|
||||
|
||||
// Orphan TTL safety nets — used when the process dies between acquire
|
||||
// and release. Memory backend doesn't need one (the process is also
|
||||
@@ -1139,9 +1138,8 @@ async function resolveSubscriptionLimit(req, opts) {
|
||||
* bySubscription: { user_free: 2, unlimited: 50 } } } { concurrent: { limit:
|
||||
* 10, backend: 'redis', scope: 'expensive-op' } }
|
||||
*
|
||||
* 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
|
||||
* On accept, schedules release on `res.finish` / `res.close` so even aborted
|
||||
* requests give their slot back. On reject, 429 with the same
|
||||
* `too_many_requests` legacyCode as the rate gate (clients already handle that
|
||||
* branch). Fails open on backend error.
|
||||
*/
|
||||
@@ -1167,11 +1165,13 @@ export function concurrencyGate(opts) {
|
||||
return;
|
||||
}
|
||||
|
||||
const held = new CallSlot();
|
||||
held.attach(guardSlot(slot, '[concurrent]'), res);
|
||||
if (Context.current()) {
|
||||
Context.set('keepConcurrencySlot', (work) => held.keep(work));
|
||||
}
|
||||
// `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();
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ 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 {
|
||||
@@ -978,27 +977,6 @@ 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' }));
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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 { EventEmitter } from 'node:events';
|
||||
import { createServer, get, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { onResponseClose } from './onResponseClose.js';
|
||||
|
||||
let server: Server | undefined;
|
||||
afterEach(async () => {
|
||||
server?.closeAllConnections();
|
||||
await new Promise((resolve) => server?.close(resolve) ?? resolve(null));
|
||||
server = undefined;
|
||||
});
|
||||
|
||||
describe('onResponseClose', () => {
|
||||
it('shares one close listener and runs every callback once, in order', () => {
|
||||
const res = new EventEmitter();
|
||||
const calls: string[] = [];
|
||||
onResponseClose(res, () => calls.push('a'));
|
||||
onResponseClose(res, () => calls.push('b'));
|
||||
onResponseClose(res, () => calls.push('c'));
|
||||
expect(res.listenerCount('close')).toBe(1);
|
||||
|
||||
res.emit('close');
|
||||
res.emit('close');
|
||||
expect(calls).toEqual(['a', 'b', 'c']);
|
||||
expect(res.listenerCount('close')).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps responses apart', () => {
|
||||
const first = new EventEmitter();
|
||||
const second = new EventEmitter();
|
||||
const onFirst = vi.fn();
|
||||
const onSecond = vi.fn();
|
||||
onResponseClose(first, onFirst);
|
||||
onResponseClose(second, onSecond);
|
||||
|
||||
first.emit('close');
|
||||
expect(onFirst).toHaveBeenCalledOnce();
|
||||
expect(onSecond).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs the rest when one callback throws, and still surfaces the error', async () => {
|
||||
const res = new EventEmitter();
|
||||
const after = vi.fn();
|
||||
const failure = new Error('callback broke');
|
||||
const uncaught = vi.fn();
|
||||
const listeners = process.listeners('uncaughtException');
|
||||
process.removeAllListeners('uncaughtException');
|
||||
process.on('uncaughtException', uncaught);
|
||||
try {
|
||||
onResponseClose(res, () => {
|
||||
throw failure;
|
||||
});
|
||||
onResponseClose(res, after);
|
||||
res.emit('close');
|
||||
expect(after).toHaveBeenCalledOnce();
|
||||
await vi.waitFor(() =>
|
||||
expect(uncaught).toHaveBeenCalledWith(
|
||||
failure,
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
process.off('uncaughtException', uncaught);
|
||||
for (const listener of listeners)
|
||||
process.on('uncaughtException', listener);
|
||||
}
|
||||
});
|
||||
|
||||
it('fires when a client hangs up on a real response', async () => {
|
||||
const closed = new Promise<boolean>((resolve) => {
|
||||
server = createServer((_req, res) => {
|
||||
onResponseClose(res, () => resolve(res.writableFinished));
|
||||
res.write('partial');
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server!.listen(0, resolve));
|
||||
const { port } = server!.address() as AddressInfo;
|
||||
const req = get(`http://127.0.0.1:${port}/`, (res) => {
|
||||
res.once('data', () => req.destroy());
|
||||
});
|
||||
req.on('error', () => {});
|
||||
|
||||
expect(await closed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/** Only what's used here, so test doubles and express's Response both fit. */
|
||||
interface ClosingResponse {
|
||||
once(event: 'close', listener: () => void): unknown;
|
||||
}
|
||||
|
||||
const callbacksByResponse = new WeakMap<ClosingResponse, Array<() => void>>();
|
||||
|
||||
/**
|
||||
* Runs `callback` once when the response closes. Every callback on a response
|
||||
* shares one `close` listener, since Node warns past ten and request tracing
|
||||
* adds its own. Callbacks run in the order added; one that throws doesn't stop
|
||||
* the rest.
|
||||
*/
|
||||
export const onResponseClose = (
|
||||
res: ClosingResponse,
|
||||
callback: () => void,
|
||||
): void => {
|
||||
const callbacks = callbacksByResponse.get(res);
|
||||
if (callbacks) {
|
||||
callbacks.push(callback);
|
||||
return;
|
||||
}
|
||||
const pending = [callback];
|
||||
callbacksByResponse.set(res, pending);
|
||||
res.once('close', () => {
|
||||
for (const run of pending.splice(0)) {
|
||||
try {
|
||||
run();
|
||||
} catch (e) {
|
||||
// Surfaces as an uncaught error, as it would from a listener
|
||||
// of its own.
|
||||
queueMicrotask(() => {
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
RESPONSE_CLOSE_LISTENER_BUDGET,
|
||||
setupPuterTestEnv,
|
||||
trackResponseCloseListeners,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
|
||||
/**
|
||||
* The routes that hold a response open the longest, where middleware, gates and
|
||||
* the handler each want to hear when it closes.
|
||||
*/
|
||||
describe('close listeners per response over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
let filePath = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
meteringEnforcement: { subscriptions: false },
|
||||
} as never);
|
||||
const { username } = env.users.user;
|
||||
const user = await env.server.stores.user.getByUsername(username);
|
||||
filePath = `/${username}/Documents/close-listeners.txt`;
|
||||
await env.server.services.fs.write(user!.id, {
|
||||
fileMetadata: {
|
||||
path: filePath,
|
||||
size: 4096,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: Buffer.alloc(4096, 97),
|
||||
});
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const streamedChat = {
|
||||
model: 'fake',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
max_tokens: 16,
|
||||
};
|
||||
const post = (path: string, token: string, body: unknown) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Origin: env.apiOrigin,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const routes: Array<[string, string, () => Promise<Response>]> = [
|
||||
[
|
||||
'a streamed driver call',
|
||||
'/drivers/call',
|
||||
() =>
|
||||
post('/drivers/call', env.users.user.token, {
|
||||
interface: 'puter-chat-completion',
|
||||
service: 'ai-chat',
|
||||
method: 'complete',
|
||||
args: streamedChat,
|
||||
}),
|
||||
],
|
||||
[
|
||||
'an Anthropic-style streamed completion',
|
||||
'/puterai/anthropic/v1/messages',
|
||||
() =>
|
||||
post(
|
||||
'/puterai/anthropic/v1/messages',
|
||||
env.users.user.apiToken,
|
||||
streamedChat,
|
||||
),
|
||||
],
|
||||
[
|
||||
'an OpenAI-style streamed completion',
|
||||
'/puterai/openai/v1/chat/completions',
|
||||
() =>
|
||||
post(
|
||||
'/puterai/openai/v1/chat/completions',
|
||||
env.users.user.apiToken,
|
||||
streamedChat,
|
||||
),
|
||||
],
|
||||
[
|
||||
'a file read',
|
||||
'/read',
|
||||
() => {
|
||||
const url = new URL('/read', env.apiOrigin);
|
||||
url.searchParams.set('file', filePath);
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.users.user.token}`,
|
||||
},
|
||||
});
|
||||
},
|
||||
],
|
||||
[
|
||||
'a v2 file read',
|
||||
'/fs/read',
|
||||
() => {
|
||||
const url = new URL('/fs/read', env.apiOrigin);
|
||||
url.searchParams.set('path', filePath);
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.users.user.token}`,
|
||||
},
|
||||
});
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
it.each(routes)('%s stays within the budget', async (_name, path, call) => {
|
||||
const tracker = trackResponseCloseListeners();
|
||||
try {
|
||||
const response = await call();
|
||||
expect(response.status).toBe(200);
|
||||
await response.arrayBuffer();
|
||||
} finally {
|
||||
tracker.restore();
|
||||
}
|
||||
const peak = tracker.peak((req) => req.url?.split('?')[0] === path);
|
||||
expect(peak).toBeGreaterThan(0);
|
||||
expect(peak).toBeLessThanOrEqual(RESPONSE_CLOSE_LISTENER_BUDGET);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import type { Request, RequestHandler } from 'express';
|
||||
import { actorUid } from '../actor';
|
||||
import type { EventClient } from '../../clients/event/EventClient';
|
||||
import type { LifecycleRejectStatus } from '../../clients/event/types';
|
||||
import { onResponseClose } from './onResponseClose';
|
||||
import type { RouteMethod } from './types';
|
||||
|
||||
type RoutePathValue = string | RegExp | Array<string | RegExp> | undefined;
|
||||
@@ -179,7 +180,7 @@ export const createRouteLifecycleMiddleware = (
|
||||
);
|
||||
};
|
||||
res.once('finish', () => settle(false));
|
||||
res.once('close', () => settle(!res.writableFinished));
|
||||
onResponseClose(res, () => settle(!res.writableFinished));
|
||||
next();
|
||||
};
|
||||
};
|
||||
@@ -263,10 +263,10 @@ export interface RouteOptions {
|
||||
rateLimit?: RouteRateLimit | RouteRateLimit[];
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* 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`.
|
||||
*/
|
||||
concurrent?: {
|
||||
limit: number;
|
||||
|
||||
+2
-13
@@ -19,9 +19,9 @@
|
||||
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../../../testUtil.js';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
import { clearUnhealthyRoutes } from './utils/providerHealth.js';
|
||||
import { makeActor } from '../../../../core/actor.js';
|
||||
import { makeActor } from '../../core/actor.js';
|
||||
|
||||
const policyMessage =
|
||||
'Policy Violation: this user has been blocked for a previous policy violation. Learn more: https://vendor.test/private';
|
||||
@@ -129,17 +129,6 @@ 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();
|
||||
+2
-2
@@ -19,8 +19,8 @@
|
||||
|
||||
import { createServer, type Server, type ServerResponse } from 'node:http';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { makeActor, type Actor } from '../../../../core/actor.js';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../../../testUtil.js';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
||||
|
||||
const routes = [
|
||||
'/drivers/call',
|
||||
+15
-52
@@ -42,22 +42,22 @@ import {
|
||||
vi,
|
||||
} from 'vitest';
|
||||
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../shared/integrationTestUtil.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { withTestActor } from '../integrationTestUtil.js';
|
||||
import { ChatCompletionDriver } from './ChatCompletionDriver.js';
|
||||
import { AzureChatProvider } from '../../providers/azure/chat/AzureChatProvider.js';
|
||||
import { FakeChatProvider } from '../../providers/fake/chat/FakeChatProvider.js';
|
||||
import { InfronProvider } from '../../providers/infron/chat/InfronProvider.js';
|
||||
import { NeuralwattProvider } from '../../providers/neuralwatt/chat/NeuralwattProvider.js';
|
||||
import { OpenAiChatProvider } from '../../providers/openai/chat/OpenAiChatCompletionsProvider.js';
|
||||
import { OpenRouterProvider } from '../../providers/openrouter/chat/OpenRouterProvider.js';
|
||||
import { TogetherAIProvider } from '../../providers/together/chat/TogetherAIProvider.js';
|
||||
import { XAI_CHAT_MODELS } from '../../providers/xai/chat/catalog.js';
|
||||
import { XAIProvider } from '../../providers/xai/chat/XAIProvider.js';
|
||||
import { handle_completion_output } from '../../protocols/openaiCompatible/OpenAIUtil.js';
|
||||
import { AzureChatProvider } from './providers/azure/AzureChatProvider.js';
|
||||
import { FakeChatProvider } from './providers/FakeChatProvider.js';
|
||||
import { InfronProvider } from './providers/infron/InfronProvider.js';
|
||||
import { NeuralwattProvider } from './providers/neuralwatt/NeuralwattProvider.js';
|
||||
import { OpenAiChatProvider } from './providers/openai/OpenAiChatCompletionsProvider.js';
|
||||
import { OpenRouterProvider } from './providers/openrouter/OpenRouterProvider.js';
|
||||
import { TogetherAIProvider } from './providers/together/TogetherAIProvider.js';
|
||||
import { XAI_CHAT_MODELS } from './providers/xai/catalog.js';
|
||||
import { XAIProvider } from './providers/xai/XAIProvider.js';
|
||||
import { handle_completion_output } from './utils/OpenAIUtil.js';
|
||||
import { clearUnhealthyRoutes, isRouteUnhealthy } from './utils/providerHealth.js';
|
||||
|
||||
let server: PuterServer;
|
||||
@@ -1231,41 +1231,4 @@ 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();
|
||||
});
|
||||
});
|
||||
+5
-5
@@ -38,11 +38,11 @@ import {
|
||||
vi,
|
||||
} from 'vitest';
|
||||
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../shared/integrationTestUtil.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { withTestActor } from '../integrationTestUtil.js';
|
||||
import { ChatCompletionDriver } from './ChatCompletionDriver.js';
|
||||
import { FakeChatProvider } from '../../providers/fake/chat/FakeChatProvider.js';
|
||||
import { FakeChatProvider } from './providers/FakeChatProvider.js';
|
||||
import type { IChatModel, ICompleteArguments } from './types.js';
|
||||
|
||||
// The puter_path resolver reads the user's filesystem; stub it and assert on
|
||||
@@ -51,7 +51,7 @@ const { processPuterPathUploadsMock } = vi.hoisted(() => ({
|
||||
processPuterPathUploadsMock: vi.fn(async (_messages: unknown) => {}),
|
||||
}));
|
||||
|
||||
vi.mock('../../providers/openai/chat/fileUpload.js', () => ({
|
||||
vi.mock('./providers/openai/fileUpload.js', () => ({
|
||||
processPuterPathUploads: processPuterPathUploadsMock,
|
||||
MAX_FILE_SIZE: 5 * 1_000_000,
|
||||
}));
|
||||
+6
-6
@@ -38,14 +38,14 @@ import {
|
||||
vi,
|
||||
} from 'vitest';
|
||||
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import type { UsageInput } from '../../../../services/metering/types.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../shared/integrationTestUtil.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import type { UsageInput } from '../../services/metering/types.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { withTestActor } from '../integrationTestUtil.js';
|
||||
import { ChatCompletionDriver } from './ChatCompletionDriver.js';
|
||||
import { clearUnhealthyRoutes, isRouteUnhealthy } from './utils/providerHealth.js';
|
||||
import { FakeChatProvider } from '../../providers/fake/chat/FakeChatProvider.js';
|
||||
import { FakeChatProvider } from './providers/FakeChatProvider.js';
|
||||
import type { IChatCompleteResult } from './types.js';
|
||||
import { SDK_TIMEOUT_MS } from './utils/sdkClient.js';
|
||||
import type { AIChatStream } from './utils/Streaming.js';
|
||||
+9
-9
@@ -44,21 +44,21 @@ import type { Request } from 'express';
|
||||
import {
|
||||
renderAnthropicError,
|
||||
renderOpenAIError,
|
||||
} from '../../../../controllers/puterai/wireErrors.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../shared/integrationTestUtil.js';
|
||||
} from '../../controllers/puterai/wireErrors.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { withTestActor } from '../integrationTestUtil.js';
|
||||
import { ChatCompletionDriver } from './ChatCompletionDriver.js';
|
||||
import { OPEN_AI_MODELS } from '../../providers/openai/chat/models.js';
|
||||
import { OPEN_AI_MODELS } from './providers/openai/models.js';
|
||||
import {
|
||||
clearUnhealthyRoutes,
|
||||
isRouteUnhealthy,
|
||||
markRouteUnhealthy,
|
||||
} from './utils/providerHealth.js';
|
||||
import { InfronProvider } from '../../providers/infron/chat/InfronProvider.js';
|
||||
import { UpstreamDeadlineError } from '../../../util/upstreamErrors.js';
|
||||
import { InfronProvider } from './providers/infron/InfronProvider.js';
|
||||
import { UpstreamDeadlineError } from '../util/upstreamErrors.js';
|
||||
|
||||
// -- OpenAI SDK mock ------------------------------------------------
|
||||
// Gemini reaches Google through `new openai.OpenAI()` (default export) and
|
||||
+10
-148
@@ -44,17 +44,17 @@ import {
|
||||
vi,
|
||||
} from 'vitest';
|
||||
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../shared/integrationTestUtil.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { withTestActor } from '../integrationTestUtil.js';
|
||||
import { COUNT_TOKENS, ChatCompletionDriver } from './ChatCompletionDriver.js';
|
||||
import { ClaudeProvider } from '../../providers/claude/chat/ClaudeProvider.js';
|
||||
import { FakeChatProvider } from '../../providers/fake/chat/FakeChatProvider.js';
|
||||
import { OllamaChatProvider } from '../../providers/ollama/chat/OllamaProvider.js';
|
||||
import { XAI_CHAT_MODELS } from '../../providers/xai/chat/catalog.js';
|
||||
import { XAIProvider } from '../../providers/xai/chat/XAIProvider.js';
|
||||
import { ClaudeProvider } from './providers/claude/ClaudeProvider.js';
|
||||
import { FakeChatProvider } from './providers/FakeChatProvider.js';
|
||||
import { OllamaChatProvider } from './providers/ollama/OllamaProvider.js';
|
||||
import { XAI_CHAT_MODELS } from './providers/xai/catalog.js';
|
||||
import { XAIProvider } from './providers/xai/XAIProvider.js';
|
||||
import type {
|
||||
IChatCompleteResult,
|
||||
IChatProvider,
|
||||
@@ -207,50 +207,6 @@ 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();
|
||||
@@ -2440,97 +2396,3 @@ describe('ChatCompletionDriver service speed', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('ChatCompletionDriver.complete option shapes', () => {
|
||||
const hi = [{ role: 'user', content: 'hi' }];
|
||||
|
||||
it.each(['model', 'provider', 'service_tier'])(
|
||||
'rejects a value that cannot be text as `%s`',
|
||||
async (name) => {
|
||||
const args = { model: 'fake', messages: hi, [name]: { toString: null } };
|
||||
await expect(
|
||||
withTestActor(() => driver.complete(args as never)),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
message: `\`${name}\` is not a usable value`,
|
||||
});
|
||||
await expect(
|
||||
withTestActor(() => driver[COUNT_TOKENS](args as never)),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
},
|
||||
);
|
||||
|
||||
it('routes by the model when `provider` is an object, as it did before', async () => {
|
||||
const completeSpy = vi.spyOn(FakeChatProvider.prototype, 'complete');
|
||||
await withTestActor(() =>
|
||||
driver.complete({
|
||||
model: 'fake',
|
||||
messages: hi,
|
||||
provider: { order: ['Anthropic'] },
|
||||
} as never),
|
||||
);
|
||||
expect(completeSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([false, 0, '', null])(
|
||||
'treats `tools: %j` as no tools when counting tokens',
|
||||
async (tools) => {
|
||||
const countTokens = vi.fn(async (_args: object) => 1);
|
||||
const fake = FakeChatProvider.prototype as {
|
||||
countTokens?: typeof countTokens;
|
||||
};
|
||||
fake.countTokens = countTokens;
|
||||
try {
|
||||
await withTestActor(() =>
|
||||
driver[COUNT_TOKENS]({
|
||||
model: 'fake',
|
||||
messages: hi,
|
||||
tools,
|
||||
} as never),
|
||||
);
|
||||
} finally {
|
||||
delete fake.countTokens;
|
||||
}
|
||||
expect(countTokens.mock.calls[0]![0]).not.toHaveProperty('tools');
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, 0, '', null])(
|
||||
'treats `tools: %j` as no tools',
|
||||
async (tools) => {
|
||||
const completeSpy = vi.spyOn(FakeChatProvider.prototype, 'complete');
|
||||
await withTestActor(() =>
|
||||
driver.complete({ model: 'fake', messages: hi, tools } as never),
|
||||
);
|
||||
expect(completeSpy.mock.calls[0]![0]).not.toHaveProperty('tools');
|
||||
},
|
||||
);
|
||||
|
||||
it('reports zero tokens in the cost event for a usage count that is not a number', async () => {
|
||||
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
|
||||
{
|
||||
id: 'priced',
|
||||
aliases: [],
|
||||
costs_currency: 'usd-cents',
|
||||
costs: { input_tokens: 1000, output_tokens: 2000 },
|
||||
max_tokens: 8192,
|
||||
},
|
||||
]);
|
||||
const d = await makeDriver();
|
||||
const costEvents = captureEvent('ai.prompt.cost-calculated');
|
||||
vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce({
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
|
||||
usage: { input_tokens: 10, output_tokens: { toString: null } },
|
||||
finish_reason: 'stop',
|
||||
} as never);
|
||||
|
||||
await withTestActor(() => d.complete({ model: 'priced', messages: hi }));
|
||||
|
||||
expect(costEvents[0]![1]).toMatchObject({
|
||||
input_tokens: 10,
|
||||
output_tokens: 0,
|
||||
total_ucents: 10 * 1000,
|
||||
});
|
||||
});
|
||||
});
|
||||
+58
-108
@@ -19,56 +19,56 @@
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { EventMap } from '../../../../clients/event/types.js';
|
||||
import type { Actor } from '../../../../core/actor.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import { HttpError, isHttpError } from '../../../../core/http/HttpError.js';
|
||||
import { insufficientCreditsError } from '../../../../services/metering/enforcement.js';
|
||||
import { isFreeSubscription } from '../../../../services/metering/consts.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type { CreditHold } from '../../../../services/metering/types.js';
|
||||
import { NO_CREDIT_HOLD } from '../../../../services/metering/types.js';
|
||||
import type { DriverStreamResult } from '../../../meta.js';
|
||||
import { PuterDriver } from '../../../types.js';
|
||||
import { EventMap } from '../../clients/event/types.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError, isHttpError } from '../../core/http/HttpError.js';
|
||||
import { insufficientCreditsError } from '../../services/metering/enforcement.js';
|
||||
import { isFreeSubscription } from '../../services/metering/consts.js';
|
||||
import type { MeteringService } from '../../services/metering/MeteringService.js';
|
||||
import type { CreditHold } from '../../services/metering/types.js';
|
||||
import { NO_CREDIT_HOLD } from '../../services/metering/types.js';
|
||||
import type { DriverStreamResult } from '../meta.js';
|
||||
import { PuterDriver } from '../types.js';
|
||||
import {
|
||||
type AiMeteringService,
|
||||
factoredTotal,
|
||||
requestAiCosts,
|
||||
withAiCostFactor,
|
||||
} from '../../../../util/aiCostFactor.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../../shared/aiLimits.js';
|
||||
} from '../util/aiCostFactor.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
|
||||
import {
|
||||
isCreditExhaustion as isUpstreamCreditExhaustion,
|
||||
isUpstreamConnectionError,
|
||||
isUpstreamTimeoutError,
|
||||
sanitizeUpstreamMessage,
|
||||
UpstreamDeadlineError,
|
||||
} from '../../../util/upstreamErrors.js';
|
||||
import { AlibabaProvider } from '../../providers/alibaba/chat/AlibabaProvider.js';
|
||||
import { AzureChatProvider } from '../../providers/azure/chat/AzureChatProvider.js';
|
||||
import { AzureResponsesProvider } from '../../providers/azure/chat/AzureResponsesProvider.js';
|
||||
import { BytePlusProvider } from '../../providers/byteplus/chat/BytePlusProvider.js';
|
||||
import { ClaudeProvider } from '../../providers/claude/chat/ClaudeProvider.js';
|
||||
import { DeepSeekProvider } from '../../providers/deepseek/chat/DeepSeekProvider.js';
|
||||
import { FakeChatProvider } from '../../providers/fake/chat/FakeChatProvider.js';
|
||||
import { GeminiChatProvider } from '../../providers/gemini/chat/GeminiChatProvider.js';
|
||||
import { GroqAIProvider } from '../../providers/groq/chat/GroqAIProvider.js';
|
||||
import { HoonifyProvider } from '../../providers/hoonify/chat/HoonifyProvider.js';
|
||||
import { InfronProvider } from '../../providers/infron/chat/InfronProvider.js';
|
||||
import { MetaProvider } from '../../providers/meta/chat/MetaProvider.js';
|
||||
import { MiniMaxProvider } from '../../providers/minimax/chat/MiniMaxProvider.js';
|
||||
import { MistralAIProvider } from '../../providers/mistral/chat/MistralAiProvider.js';
|
||||
import { MoonshotProvider } from '../../providers/moonshot/chat/MoonshotProvider.js';
|
||||
import { NeuralwattProvider } from '../../providers/neuralwatt/chat/NeuralwattProvider.js';
|
||||
import { OllamaChatProvider } from '../../providers/ollama/chat/OllamaProvider.js';
|
||||
import type { ChatProviderConfig } from '../../protocols/openaiCompatible/OpenAICompatProvider.js';
|
||||
import { processPuterPathUploads } from '../../providers/openai/chat/fileUpload.js';
|
||||
import { OpenAiChatProvider } from '../../providers/openai/chat/OpenAiChatCompletionsProvider.js';
|
||||
import { OpenAiResponsesChatProvider } from '../../providers/openai/chat/OpenAiChatResponsesProvider.js';
|
||||
import { OpenRouterProvider } from '../../providers/openrouter/chat/OpenRouterProvider.js';
|
||||
import { TogetherAIProvider } from '../../providers/together/chat/TogetherAIProvider.js';
|
||||
import { XAIProvider } from '../../providers/xai/chat/XAIProvider.js';
|
||||
import { ZAIProvider } from '../../providers/zai/chat/ZAIProvider.js';
|
||||
} from '../util/upstreamErrors.js';
|
||||
import { AlibabaProvider } from './providers/alibaba/AlibabaProvider.js';
|
||||
import { AzureChatProvider } from './providers/azure/AzureChatProvider.js';
|
||||
import { AzureResponsesProvider } from './providers/azure/AzureResponsesProvider.js';
|
||||
import { BytePlusProvider } from './providers/byteplus/BytePlusProvider.js';
|
||||
import { ClaudeProvider } from './providers/claude/ClaudeProvider.js';
|
||||
import { DeepSeekProvider } from './providers/deepseek/DeepSeekProvider.js';
|
||||
import { FakeChatProvider } from './providers/FakeChatProvider.js';
|
||||
import { GeminiChatProvider } from './providers/gemini/GeminiChatProvider.js';
|
||||
import { GroqAIProvider } from './providers/groq/GroqAIProvider.js';
|
||||
import { HoonifyProvider } from './providers/hoonify/HoonifyProvider.js';
|
||||
import { InfronProvider } from './providers/infron/InfronProvider.js';
|
||||
import { MetaProvider } from './providers/meta/MetaProvider.js';
|
||||
import { MiniMaxProvider } from './providers/minimax/MiniMaxProvider.js';
|
||||
import { MistralAIProvider } from './providers/mistral/MistralAiProvider.js';
|
||||
import { MoonshotProvider } from './providers/moonshot/MoonshotProvider.js';
|
||||
import { NeuralwattProvider } from './providers/neuralwatt/NeuralwattProvider.js';
|
||||
import { OllamaChatProvider } from './providers/ollama/OllamaProvider.js';
|
||||
import type { ChatProviderConfig } from './providers/OpenAICompatProvider.js';
|
||||
import { processPuterPathUploads } from './providers/openai/fileUpload.js';
|
||||
import { OpenAiChatProvider } from './providers/openai/OpenAiChatCompletionsProvider.js';
|
||||
import { OpenAiResponsesChatProvider } from './providers/openai/OpenAiChatResponsesProvider.js';
|
||||
import { OpenRouterProvider } from './providers/openrouter/OpenRouterProvider.js';
|
||||
import { TogetherAIProvider } from './providers/together/TogetherAIProvider.js';
|
||||
import { XAIProvider } from './providers/xai/XAIProvider.js';
|
||||
import { ZAIProvider } from './providers/zai/ZAIProvider.js';
|
||||
import type {
|
||||
IChatCompleteResult,
|
||||
IChatMessageResult,
|
||||
@@ -103,13 +103,9 @@ import {
|
||||
normalizeResultToOpenAI,
|
||||
shouldPresentAsOpenAI,
|
||||
} from './utils/normalizeToOpenAI.js';
|
||||
import { isToolChoice, toolChoiceFromWire } from './utils/openaiParams.js';
|
||||
import {
|
||||
isToolChoice,
|
||||
toolChoiceFromWire,
|
||||
} from '../../protocols/openaiCompatible/openaiParams.js';
|
||||
import { assertScalarOptions } from '../../shared/scalarOptions.js';
|
||||
import { catalogPrice } from '../../shared/catalogPrice.js';
|
||||
import {
|
||||
catalogPrice,
|
||||
costKeys,
|
||||
costsForRequest,
|
||||
isFreeModel,
|
||||
@@ -125,7 +121,7 @@ import {
|
||||
TRANSIENT_UNHEALTHY_TTL_SEC,
|
||||
UNHEALTHY_TTL_SEC,
|
||||
} from './utils/providerHealth.js';
|
||||
import { CATALOG_TTL_MS } from '../../shared/cachedRemoteCatalog.js';
|
||||
import { CATALOG_TTL_MS } from './utils/cachedRemoteCatalog.js';
|
||||
import { SDK_TIMEOUT_MS } from './utils/sdkClient.js';
|
||||
import { fromFinishReason } from './utils/stopReason.js';
|
||||
import { AIChatStream } from './utils/Streaming.js';
|
||||
@@ -182,9 +178,6 @@ export const COUNT_TOKENS = Symbol('aiChat.countTokens');
|
||||
*/
|
||||
const HOLD_RENEW_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
// Options that reach error messages and request headers as text.
|
||||
const SCALAR_OPTIONS = ['model', 'provider', 'service_tier'];
|
||||
|
||||
/**
|
||||
* A moderation refusal is a completion that was produced and charged, then
|
||||
* withheld — not a route failure. Retrying it on a fallback provider would bill
|
||||
@@ -592,7 +585,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
readonly driverName = 'ai-chat';
|
||||
readonly isDefault = true;
|
||||
|
||||
// Shared AI policy — see `drivers/ai/shared/aiLimits.ts` for the tier table.
|
||||
// Shared AI policy — see `drivers/util/aiLimits.ts` for the tier table.
|
||||
readonly rateLimit = AI_RATE_LIMIT;
|
||||
readonly concurrent = AI_CONCURRENT;
|
||||
|
||||
@@ -697,7 +690,6 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
{ legacyCode: 'invalid_service_speed' },
|
||||
);
|
||||
}
|
||||
assertScalarOptions(args, SCALAR_OPTIONS);
|
||||
const abortSignal = Context.get('abortSignal');
|
||||
abortSignal?.throwIfAborted();
|
||||
const actor = Context.get('actor');
|
||||
@@ -752,9 +744,6 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
args.messages = normalizeMediaParts(normalize_messages(args.messages));
|
||||
if (args.tools) {
|
||||
normalize_tools_object(args.tools);
|
||||
} else {
|
||||
// `tools: enabled && list` leaves `false` here; it means no tools.
|
||||
delete args.tools;
|
||||
}
|
||||
// A raw `/drivers/call` caller still sends the OpenAI wire form
|
||||
// ('auto'/'none'/'required', {type:'function', function:{name}}) —
|
||||
@@ -1308,17 +1297,8 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
}, HOLD_RENEW_INTERVAL_MS);
|
||||
renewHold.unref?.();
|
||||
|
||||
// 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.
|
||||
// Fire-and-forget — the stream writes happen async while the
|
||||
// response is being piped to the client.
|
||||
(async () => {
|
||||
try {
|
||||
await init({ chatStream });
|
||||
@@ -1343,7 +1323,6 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
}
|
||||
passthrough.end();
|
||||
} finally {
|
||||
upstreamDone();
|
||||
clearInterval(renewHold);
|
||||
clearTimeout(detachedLimit);
|
||||
abortSignal?.removeEventListener('abort', detachStream);
|
||||
@@ -1511,7 +1490,6 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
async [COUNT_TOKENS](
|
||||
args: ICompleteArguments,
|
||||
): Promise<{ input_tokens: number }> {
|
||||
assertScalarOptions(args, SCALAR_OPTIONS);
|
||||
let intendedProvider = args.provider || '';
|
||||
if (!args.model && !intendedProvider) {
|
||||
intendedProvider = 'azure-openai';
|
||||
@@ -1537,9 +1515,6 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
args.messages = normalizeMediaParts(normalize_messages(args.messages));
|
||||
if (args.tools) {
|
||||
normalize_tools_object(args.tools);
|
||||
} else {
|
||||
// `tools: enabled && list` leaves `false` here; it means no tools.
|
||||
delete args.tools;
|
||||
}
|
||||
|
||||
const provider = this.#providers[model.provider!];
|
||||
@@ -1873,20 +1848,20 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
|
||||
const cost = usageCost(usage, model, usageCosts, pricingTime);
|
||||
const { inputKey, outputKey } = costKeys(model);
|
||||
// The vendor reports usage, and pricing skips a count that is not a
|
||||
// number; so does this.
|
||||
const count = (value: unknown) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||
const inputTokens = cost
|
||||
? count(
|
||||
usage[inputKey] ?? usage.prompt_tokens ?? usage.input_tokens,
|
||||
? Number(
|
||||
usage[inputKey] ??
|
||||
usage.prompt_tokens ??
|
||||
usage.input_tokens ??
|
||||
0,
|
||||
)
|
||||
: 0;
|
||||
const outputTokens = cost
|
||||
? count(
|
||||
? Number(
|
||||
usage[outputKey] ??
|
||||
usage.completion_tokens ??
|
||||
usage.output_tokens,
|
||||
usage.output_tokens ??
|
||||
0,
|
||||
)
|
||||
: 0;
|
||||
const inputMicroCents = cost?.inputMicroCents ?? 0;
|
||||
@@ -2086,39 +2061,14 @@ 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.
|
||||
// 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 aliases =
|
||||
entry.puterId &&
|
||||
!(entry.aliases ?? []).includes(entry.puterId)
|
||||
? [...(entry.aliases ?? []), entry.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
|
||||
+3
-3
@@ -19,9 +19,9 @@
|
||||
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Context } from '../../../../../core/context.js';
|
||||
import { withTestActor } from '../../../shared/integrationTestUtil.js';
|
||||
import { AIChatStream } from '../../../interfaces/chat/utils/Streaming.js';
|
||||
import { Context } from '../../../core/context.js';
|
||||
import { withTestActor } from '../../integrationTestUtil.js';
|
||||
import { AIChatStream } from '../utils/Streaming.js';
|
||||
import { FakeChatProvider } from './FakeChatProvider.js';
|
||||
|
||||
describe('fake chat streaming lifecycle', () => {
|
||||
+3
-3
@@ -19,15 +19,15 @@
|
||||
|
||||
import dedent from 'dedent';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { Context } from '../../../../../core/context.js';
|
||||
import { Context } from '../../../core/context.js';
|
||||
import { LoremIpsum } from 'lorem-ipsum';
|
||||
import { AIChatStream } from '../../../interfaces/chat/utils/Streaming.js';
|
||||
import { AIChatStream } from '../utils/Streaming.js';
|
||||
import {
|
||||
IChatModel,
|
||||
IChatProvider,
|
||||
ICompleteArguments,
|
||||
PuterMessage,
|
||||
} from '../../../interfaces/chat/types.js';
|
||||
} from '../types.js';
|
||||
|
||||
export class FakeChatProvider implements IChatProvider {
|
||||
checkModeration(_text: string) {
|
||||
+2
-2
@@ -18,12 +18,12 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type { MeteringService } from '../../../services/metering/MeteringService.js';
|
||||
import type {
|
||||
IChatMessageResult,
|
||||
IChatModel,
|
||||
ICompleteArguments,
|
||||
} from '../../interfaces/chat/types.js';
|
||||
} from '../types.js';
|
||||
import {
|
||||
type OpenAICompatOptions,
|
||||
OpenAICompatProvider,
|
||||
+18
-17
@@ -18,10 +18,10 @@
|
||||
*/
|
||||
|
||||
import type { CompletionUsage } from 'openai/resources/completions.mjs';
|
||||
import type { Actor } from '../../../../core/actor.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type { Actor } from '../../../core/actor.js';
|
||||
import { Context } from '../../../core/context.js';
|
||||
import { HttpError } from '../../../core/http/HttpError.js';
|
||||
import type { MeteringService } from '../../../services/metering/MeteringService.js';
|
||||
import type {
|
||||
IChatCompleteResult,
|
||||
IChatModel,
|
||||
@@ -29,27 +29,26 @@ import type {
|
||||
ICompleteArguments,
|
||||
PuterMessage,
|
||||
UsageDetails,
|
||||
} from '../../interfaces/chat/types.js';
|
||||
} from '../types.js';
|
||||
import {
|
||||
contextLengthRetryParams,
|
||||
isContextLengthError,
|
||||
} from './contextLimit.js';
|
||||
import { inlineHttpImageUrls } from './inlineImages.js';
|
||||
import { modelSupportsVision } from '../../interfaces/chat/utils/mediaParts.js';
|
||||
} from '../utils/contextLimit.js';
|
||||
import { make_openai_tools } from '../utils/FunctionCalling.js';
|
||||
import { inlineHttpImageUrls } from '../utils/inlineImages.js';
|
||||
import { modelSupportsVision } from '../utils/mediaParts.js';
|
||||
import {
|
||||
meterChatUsage,
|
||||
type MeterChatUsageOptions,
|
||||
} from '../../interfaces/chat/utils/meterChatUsage.js';
|
||||
import { servedServiceSpeed } from '../../interfaces/chat/utils/serviceSpeed.js';
|
||||
import { costKeys } from '../../interfaces/chat/utils/pricing.js';
|
||||
import { catalogPrice } from '../../shared/catalogPrice.js';
|
||||
import { normalizeModelKey } from '../../interfaces/chat/utils/modelRouting.js';
|
||||
import * as OpenAIUtil from './OpenAIUtil.js';
|
||||
} from '../utils/meterChatUsage.js';
|
||||
import { servedServiceSpeed } from '../utils/serviceSpeed.js';
|
||||
import { catalogPrice, costKeys } from '../utils/pricing.js';
|
||||
import { normalizeModelKey } from '../utils/modelRouting.js';
|
||||
import * as OpenAIUtil from '../utils/OpenAIUtil.js';
|
||||
import {
|
||||
openAICompatParams,
|
||||
type OpenAIDialect,
|
||||
toolsToWire,
|
||||
} from './openaiParams.js';
|
||||
} from '../utils/openaiParams.js';
|
||||
|
||||
/** What the driver hands every keyed provider. */
|
||||
export interface ChatProviderConfig {
|
||||
@@ -201,7 +200,9 @@ export class OpenAICompatProvider implements IChatProvider {
|
||||
model,
|
||||
);
|
||||
|
||||
const tools = args.tools ? toolsToWire(args.tools, 'chat') : undefined;
|
||||
const tools = args.tools
|
||||
? make_openai_tools(args.tools, { dialect: 'chat' })
|
||||
: undefined;
|
||||
const maxTokens = args.max_tokens ?? options.defaultMaxTokens;
|
||||
const streamUsage = options.streamUsage ?? true;
|
||||
const shared: Record<string, unknown> = {
|
||||
+3
-2
@@ -28,9 +28,10 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
makeMeteringStub,
|
||||
optionalEnv,
|
||||
skipUnlessEnv,
|
||||
withTestActor,
|
||||
} from '../../../shared/integrationTestUtil.js';
|
||||
import { optionalEnv, skipUnlessEnv } from '../../../../../testEnv.js';
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { AlibabaProvider } from './AlibabaProvider.js';
|
||||
|
||||
const ENV_VAR = 'PUTER_TEST_AI_ALIBABA_API_KEY';
|
||||
+8
-8
@@ -41,19 +41,19 @@ import {
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import { SYSTEM_ACTOR } from '../../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../../server.js';
|
||||
import { setupTestServer } from '../../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../shared/integrationTestUtil.js';
|
||||
import { AIChatStream } from '../../../interfaces/chat/utils/Streaming.js';
|
||||
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { mapAlibabaCatalogModel, type AlibabaApiModel } from './catalog.js';
|
||||
import page1 from './fixtures/page1.test.json';
|
||||
import page2 from './fixtures/page2.test.json';
|
||||
import { CATALOG_TTL_MS } from '../../../shared/cachedRemoteCatalog.js';
|
||||
import { CATALOG_TTL_MS } from '../../utils/cachedRemoteCatalog.js';
|
||||
import { ALIBABA_MODELS } from './models.js';
|
||||
import { AlibabaProvider } from './AlibabaProvider.js';
|
||||
import { modelLookupNames } from '../../../interfaces/chat/utils/modelRouting.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
|
||||
// ── OpenAI SDK mock ─────────────────────────────────────────────────
|
||||
|
||||
+5
-5
@@ -18,15 +18,15 @@
|
||||
*/
|
||||
|
||||
import { OpenAI } from 'openai';
|
||||
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
|
||||
import type { IChatModel } from '../../../interfaces/chat/types.js';
|
||||
import { cachedRemoteCatalog } from '../../../shared/cachedRemoteCatalog.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type { IChatModel } from '../../types.js';
|
||||
import { cachedRemoteCatalog } from '../../utils/cachedRemoteCatalog.js';
|
||||
import { fetchAlibabaCatalog } from './catalog.js';
|
||||
import { sdkClientOptions } from '../../../interfaces/chat/utils/sdkClient.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import {
|
||||
type ChatProviderConfig,
|
||||
OpenAICompatProvider,
|
||||
} from '../../../protocols/openaiCompatible/OpenAICompatProvider.js';
|
||||
} from '../OpenAICompatProvider.js';
|
||||
import { ALIBABA_MODELS } from './models.js';
|
||||
|
||||
export class AlibabaProvider extends OpenAICompatProvider {
|
||||
+2
-2
@@ -18,8 +18,8 @@
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import type { IChatModel } from '../../../interfaces/chat/types.js';
|
||||
import { catalogPrice } from '../../../shared/catalogPrice.js';
|
||||
import type { IChatModel } from '../../types.js';
|
||||
import { catalogPrice } from '../../utils/pricing.js';
|
||||
import { ALIBABA_MODELS } from './models.js';
|
||||
|
||||
export interface AlibabaApiModel {
|
||||
File renamed without changes.
File renamed without changes.
+1
-1
@@ -17,7 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { IChatModel } from '../../../interfaces/chat/types.js';
|
||||
import type { IChatModel } from '../../types.js';
|
||||
|
||||
// Hardcoded from https://models.dev/api.json
|
||||
export const ALIBABA_MODELS: IChatModel[] = [
|
||||
+3
-2
@@ -36,9 +36,10 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
makeMeteringStub,
|
||||
optionalEnv,
|
||||
skipUnlessEnv,
|
||||
withTestActor,
|
||||
} from '../../../shared/integrationTestUtil.js';
|
||||
import { optionalEnv, skipUnlessEnv } from '../../../../../testEnv.js';
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { AzureChatProvider } from './AzureChatProvider.js';
|
||||
|
||||
const KEY_ENV = 'PUTER_TEST_AI_AZURE_OPENAI_API_KEY';
|
||||
+7
-7
@@ -46,17 +46,17 @@ import {
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import { SYSTEM_ACTOR, makeActor } from '../../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../../server.js';
|
||||
import { setupTestServer } from '../../../../../testUtil.js';
|
||||
import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import {
|
||||
expectedIdentifierFields,
|
||||
makeActorMatrix,
|
||||
sentIdentifierFields,
|
||||
withTestActor,
|
||||
} from '../../../shared/integrationTestUtil.js';
|
||||
import { AIChatStream } from '../../../interfaces/chat/utils/Streaming.js';
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { AzureChatProvider } from './AzureChatProvider.js';
|
||||
import { AZURE_MODELS } from './models.js';
|
||||
|
||||
@@ -85,7 +85,7 @@ const { inlineHttpImageUrlsMock } = vi.hoisted(() => ({
|
||||
inlineHttpImageUrlsMock: vi.fn(async (_messages: unknown) => {}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../protocols/openaiCompatible/inlineImages.js', () => ({
|
||||
vi.mock('../../utils/inlineImages.js', () => ({
|
||||
inlineHttpImageUrls: inlineHttpImageUrlsMock,
|
||||
}));
|
||||
|
||||
+19
-17
@@ -23,33 +23,33 @@ import { ChatCompletionCreateParams } from 'openai/resources/index.js';
|
||||
import {
|
||||
serviceSpeedBillingModel,
|
||||
servedServiceSpeed,
|
||||
} from '../../../interfaces/chat/utils/serviceSpeed.js';
|
||||
import { Context } from '../../../../../core/context.js';
|
||||
import type { FSService } from '../../../../../services/fs/FSService.js';
|
||||
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
|
||||
import type { FSEntryStore } from '../../../../../stores/fs/FSEntryStore.js';
|
||||
import type { S3ObjectStore } from '../../../../../stores/fs/S3ObjectStore.js';
|
||||
} from '../../utils/serviceSpeed.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import type { FSService } from '../../../../services/fs/FSService.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type { FSEntryStore } from '../../../../stores/fs/FSEntryStore.js';
|
||||
import type { S3ObjectStore } from '../../../../stores/fs/S3ObjectStore.js';
|
||||
import type {
|
||||
IChatModel,
|
||||
IChatProvider,
|
||||
ICompleteArguments,
|
||||
} from '../../../interfaces/chat/types.js';
|
||||
} from '../../types.js';
|
||||
import {
|
||||
messagesHaveCompaction,
|
||||
wantsCompaction,
|
||||
} from '../../../interfaces/chat/utils/compaction.js';
|
||||
import * as OpenAiUtil from '../../../protocols/openaiCompatible/OpenAIUtil.js';
|
||||
} from '../../utils/compaction.js';
|
||||
import { make_openai_tools } from '../../utils/FunctionCalling.js';
|
||||
import * as OpenAiUtil from '../../utils/OpenAIUtil.js';
|
||||
import {
|
||||
clampReasoningEffort,
|
||||
openAICompatParams,
|
||||
toolsToWire,
|
||||
} from '../../../protocols/openaiCompatible/openaiParams.js';
|
||||
import { buildCostsOverride } from '../../../interfaces/chat/utils/pricing.js';
|
||||
import { inlineHttpImageUrls } from '../../../protocols/openaiCompatible/inlineImages.js';
|
||||
import { processPuterPathUploads } from '../../openai/chat/fileUpload.js';
|
||||
} from '../../utils/openaiParams.js';
|
||||
import { buildCostsOverride } from '../../utils/pricing.js';
|
||||
import { inlineHttpImageUrls } from '../../utils/inlineImages.js';
|
||||
import { processPuterPathUploads } from '../openai/fileUpload.js';
|
||||
import { AZURE_MODELS } from './models.js';
|
||||
import { modelLookupNames } from '../../../interfaces/chat/utils/modelRouting.js';
|
||||
import { upstreamUserIdentifier } from '../../../shared/upstreamIdentifier.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
|
||||
|
||||
const isWebSearchTool = (tool: Record<string, unknown>): boolean =>
|
||||
tool.type === 'web_search' ||
|
||||
@@ -256,7 +256,9 @@ export class AzureChatProvider implements IChatProvider {
|
||||
// that's missing. We normalise it here so the token count code works.
|
||||
messages = await OpenAiUtil.process_input_messages(messages);
|
||||
|
||||
const mappedTools = tools ? toolsToWire(tools, 'chat') : undefined;
|
||||
const mappedTools = tools
|
||||
? make_openai_tools(tools, { dialect: 'chat' })
|
||||
: undefined;
|
||||
|
||||
const requestedReasoningEffort = reasoning_effort ?? reasoning?.effort;
|
||||
const requestedVerbosity = verbosity ?? text?.verbosity;
|
||||
+3
-2
@@ -34,9 +34,10 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
makeMeteringStub,
|
||||
optionalEnv,
|
||||
skipUnlessEnv,
|
||||
withTestActor,
|
||||
} from '../../../shared/integrationTestUtil.js';
|
||||
import { optionalEnv, skipUnlessEnv } from '../../../../../testEnv.js';
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { AzureResponsesProvider } from './AzureResponsesProvider.js';
|
||||
|
||||
const KEY_ENV = 'PUTER_TEST_AI_AZURE_OPENAI_API_KEY';
|
||||
+6
-6
@@ -40,17 +40,17 @@ import {
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import { SYSTEM_ACTOR, makeActor } from '../../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../../server.js';
|
||||
import { setupTestServer } from '../../../../../testUtil.js';
|
||||
import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import {
|
||||
expectedIdentifierFields,
|
||||
makeActorMatrix,
|
||||
sentIdentifierFields,
|
||||
withTestActor,
|
||||
} from '../../../shared/integrationTestUtil.js';
|
||||
import { AIChatStream } from '../../../interfaces/chat/utils/Streaming.js';
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { AzureResponsesProvider } from './AzureResponsesProvider.js';
|
||||
import { AZURE_MODELS } from './models.js';
|
||||
|
||||
+19
-17
@@ -22,33 +22,33 @@ import { ResponseCreateParams } from 'openai/resources/responses/responses.mjs';
|
||||
import {
|
||||
serviceSpeedBillingModel,
|
||||
servedServiceSpeed,
|
||||
} from '../../../interfaces/chat/utils/serviceSpeed.js';
|
||||
import { Context } from '../../../../../core/context.js';
|
||||
import type { FSService } from '../../../../../services/fs/FSService.js';
|
||||
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
|
||||
import type { FSEntryStore } from '../../../../../stores/fs/FSEntryStore.js';
|
||||
import type { S3ObjectStore } from '../../../../../stores/fs/S3ObjectStore.js';
|
||||
} from '../../utils/serviceSpeed.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import type { FSService } from '../../../../services/fs/FSService.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type { FSEntryStore } from '../../../../stores/fs/FSEntryStore.js';
|
||||
import type { S3ObjectStore } from '../../../../stores/fs/S3ObjectStore.js';
|
||||
import type {
|
||||
IChatModel,
|
||||
IChatProvider,
|
||||
ICompleteArguments,
|
||||
} from '../../../interfaces/chat/types.js';
|
||||
import * as OpenAiUtil from '../../../protocols/openaiCompatible/OpenAIUtil.js';
|
||||
} from '../../types.js';
|
||||
import { toOpenAiContextManagement } from '../../utils/compaction.js';
|
||||
import { make_openai_tools } from '../../utils/FunctionCalling.js';
|
||||
import * as OpenAiUtil from '../../utils/OpenAIUtil.js';
|
||||
import {
|
||||
clampReasoningEffort,
|
||||
openAICompatParams,
|
||||
promptCacheRetentionToWire,
|
||||
rejectStatefulResponsesFields,
|
||||
toolsToWire,
|
||||
toOpenAiContextManagement,
|
||||
} from '../../../protocols/openaiCompatible/openaiParams.js';
|
||||
import { buildCostsOverride } from '../../../interfaces/chat/utils/pricing.js';
|
||||
import { responseSamplingParams } from '../../../protocols/openaiCompatible/responseSampling.js';
|
||||
import { processPuterPathUploads } from '../../openai/chat/fileUpload.js';
|
||||
} from '../../utils/openaiParams.js';
|
||||
import { buildCostsOverride } from '../../utils/pricing.js';
|
||||
import { responseSamplingParams } from '../../utils/responseSampling.js';
|
||||
import { processPuterPathUploads } from '../openai/fileUpload.js';
|
||||
import { AZURE_MODELS } from './models.js';
|
||||
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
|
||||
import { modelLookupNames } from '../../../interfaces/chat/utils/modelRouting.js';
|
||||
import { upstreamUserIdentifier } from '../../../shared/upstreamIdentifier.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
|
||||
|
||||
const ANTHROPIC_WEB_SEARCH_TYPE = (type: unknown): boolean =>
|
||||
type === 'web_search_20250305' ||
|
||||
@@ -245,7 +245,9 @@ export class AzureResponsesProvider implements IChatProvider {
|
||||
}
|
||||
});
|
||||
}
|
||||
const mappedTools = tools ? toolsToWire(tools, 'responses') : undefined;
|
||||
const mappedTools = tools
|
||||
? make_openai_tools(tools, { dialect: 'responses' })
|
||||
: undefined;
|
||||
|
||||
// Strip Anthropic-only shape a fallback-replayed message can carry
|
||||
// before the existing in-place coercion mutates only this pass's copy.
|
||||
+3
-3
@@ -25,9 +25,9 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { IChatModel } from '../../../interfaces/chat/types.js';
|
||||
import { OPEN_AI_MODELS } from '../../openai/chat/models.js';
|
||||
import { XAI_MODELS } from '../../xai/chat/models.js';
|
||||
import type { IChatModel } from '../../types.js';
|
||||
import { OPEN_AI_MODELS } from '../openai/models.js';
|
||||
import { XAI_MODELS } from '../xai/models.js';
|
||||
import { AZURE_MODELS } from './models.js';
|
||||
|
||||
// Deployments of models the direct providers have retired: nothing to mirror.
|
||||
+7
-7
@@ -17,9 +17,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { IChatModel } from '../../../interfaces/chat/types.js';
|
||||
import { OPEN_AI_MODELS } from '../../openai/chat/models.js';
|
||||
import { XAI_MODELS } from '../../xai/chat/models.js';
|
||||
import type { IChatModel } from '../../types.js';
|
||||
import { OPEN_AI_MODELS } from '../openai/models.js';
|
||||
import { XAI_MODELS } from '../xai/models.js';
|
||||
|
||||
/**
|
||||
* Azure shares its source model's list prices, context tiers, and limits.
|
||||
@@ -54,10 +54,10 @@ const mirror = (
|
||||
// own provider folder rather than sharing the OpenAI list.
|
||||
//
|
||||
// IMPORTANT: we bill users at the public list price of the equivalent OpenAI /
|
||||
// xAI model, which `mirror` copies from `../../openai/chat/models.ts` and
|
||||
// `../../xai/chat/models.ts`. Azure is subsidised for us, so our actual spend
|
||||
// is lower — but billing at the standard model price is the whole reason we
|
||||
// route through Azure. Do NOT replace these with Azure's own rates.
|
||||
// xAI model, which `mirror` copies from `../openai/models.ts` and
|
||||
// `../xai/models.ts`. Azure is subsidised for us, so our actual spend is lower
|
||||
// — but billing at the standard model price is the whole reason we route
|
||||
// through Azure. Do NOT replace these with Azure's own rates.
|
||||
//
|
||||
// `id` is the Azure deployment name and is what we send upstream.
|
||||
export const AZURE_MODELS: IChatModel[] = [
|
||||
+3
-2
@@ -32,9 +32,10 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
makeMeteringStub,
|
||||
optionalEnv,
|
||||
skipUnlessEnv,
|
||||
withTestActor,
|
||||
} from '../../../shared/integrationTestUtil.js';
|
||||
import { optionalEnv, skipUnlessEnv } from '../../../../../testEnv.js';
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { BytePlusProvider } from './BytePlusProvider.js';
|
||||
|
||||
const ENV_VAR = 'PUTER_TEST_AI_BYTEPLUS_API_KEY';
|
||||
+7
-7
@@ -43,15 +43,15 @@ import {
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import { SYSTEM_ACTOR } from '../../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../../server.js';
|
||||
import { setupTestServer } from '../../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../shared/integrationTestUtil.js';
|
||||
import { AIChatStream } from '../../../interfaces/chat/utils/Streaming.js';
|
||||
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { BytePlusProvider } from './BytePlusProvider.js';
|
||||
import { BYTEPLUS_MODELS } from './models.js';
|
||||
import { modelLookupNames } from '../../../interfaces/chat/utils/modelRouting.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
|
||||
// -- OpenAI SDK mock -------------------------------------------------
|
||||
//
|
||||
+4
-4
@@ -18,13 +18,13 @@
|
||||
*/
|
||||
|
||||
import { OpenAI } from 'openai';
|
||||
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
|
||||
import type { ICompleteArguments } from '../../../interfaces/chat/types.js';
|
||||
import { sdkClientOptions } from '../../../interfaces/chat/utils/sdkClient.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type { ICompleteArguments } from '../../types.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import {
|
||||
type ChatProviderConfig,
|
||||
OpenAICompatProvider,
|
||||
} from '../../../protocols/openaiCompatible/OpenAICompatProvider.js';
|
||||
} from '../OpenAICompatProvider.js';
|
||||
import { BYTEPLUS_MODELS } from './models.js';
|
||||
|
||||
type BytePlusCustomParams = {
|
||||
+2
-2
@@ -17,8 +17,8 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { IChatModel } from '../../../interfaces/chat/types.js';
|
||||
import { usdPerMToken } from '../../../interfaces/chat/utils/pricing.js';
|
||||
import type { IChatModel } from '../../types.js';
|
||||
import { usdPerMToken } from '../../utils/pricing.js';
|
||||
|
||||
const K = 1_000;
|
||||
|
||||
+5
-4
@@ -36,14 +36,15 @@
|
||||
|
||||
import { Writable } from 'node:stream';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import {
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
makeMeteringStub,
|
||||
optionalEnv,
|
||||
skipUnlessEnv,
|
||||
withTestActor,
|
||||
} from '../../../shared/integrationTestUtil.js';
|
||||
import { optionalEnv, skipUnlessEnv } from '../../../../../testEnv.js';
|
||||
import { AIChatStream } from '../../../interfaces/chat/utils/Streaming.js';
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { ClaudeProvider } from './ClaudeProvider.js';
|
||||
|
||||
const ENV_VAR = 'PUTER_TEST_AI_CLAUDE_API_KEY';
|
||||
+11
-30
@@ -46,20 +46,20 @@ import {
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import type { Actor } from '../../../../../core/actor.js';
|
||||
import { Context } from '../../../../../core/context.js';
|
||||
import { SYSTEM_ACTOR } from '../../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../../server.js';
|
||||
import { setupTestServer } from '../../../../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../../../../util/userProvisioning.js';
|
||||
import { withTestActor } from '../../../shared/integrationTestUtil.js';
|
||||
import { normalize_messages } from '../../../interfaces/chat/utils/Messages.js';
|
||||
import { AIChatStream } from '../../../interfaces/chat/utils/Streaming.js';
|
||||
import type { Actor } from '../../../../core/actor.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../../../util/userProvisioning.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import { normalize_messages } from '../../utils/Messages.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { FILES_API_BETA } from './fileUpload.js';
|
||||
import { CLAUDE_MODELS } from './models.js';
|
||||
import { ClaudeProvider } from './ClaudeProvider.js';
|
||||
import { modelLookupNames } from '../../../interfaces/chat/utils/modelRouting.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
|
||||
// ── Anthropic SDK mock ──────────────────────────────────────────────
|
||||
|
||||
@@ -387,25 +387,6 @@ describe('ClaudeProvider.complete request shape', () => {
|
||||
expect(messagesCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['context-management-2025-06-27', { 0: 'x' }, [42, null]])(
|
||||
'drops `anthropicBetas` %j, which is not a list of names',
|
||||
async (anthropicBetas) => {
|
||||
const { provider } = makeProvider();
|
||||
messagesCreateMock.mockResolvedValueOnce(baseResponse);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
anthropicBetas,
|
||||
} as never),
|
||||
);
|
||||
|
||||
const [args] = messagesCreateMock.mock.calls[0]!;
|
||||
expect(args.betas).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it('never forwards mcp_servers — sdkParams is an explicit field list', async () => {
|
||||
const { provider } = makeProvider();
|
||||
messagesCreateMock.mockResolvedValueOnce(baseResponse);
|
||||
+19
-27
@@ -19,37 +19,36 @@
|
||||
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import type { Message } from '@anthropic-ai/sdk/resources';
|
||||
import { Context } from '../../../../../core/context.js';
|
||||
import type { FSService } from '../../../../../services/fs/FSService.js';
|
||||
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
|
||||
import type { FSEntryStore } from '../../../../../stores/fs/FSEntryStore.js';
|
||||
import type { S3ObjectStore } from '../../../../../stores/fs/S3ObjectStore.js';
|
||||
import { upstreamUserIdentifier } from '../../../shared/upstreamIdentifier.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import type { FSService } from '../../../../services/fs/FSService.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type { FSEntryStore } from '../../../../stores/fs/FSEntryStore.js';
|
||||
import type { S3ObjectStore } from '../../../../stores/fs/S3ObjectStore.js';
|
||||
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
|
||||
import type {
|
||||
IChatModel,
|
||||
IChatProvider,
|
||||
ICompleteArguments,
|
||||
IChatCompleteResult,
|
||||
UsageDetails,
|
||||
} from '../../../interfaces/chat/types.js';
|
||||
import { messagesHaveCompaction } from '../../../interfaces/chat/utils/compaction.js';
|
||||
import { toAnthropicContextManagement } from './contextManagement.js';
|
||||
} from '../../types.js';
|
||||
import {
|
||||
messagesHaveCompaction,
|
||||
toAnthropicContextManagement,
|
||||
} from '../../utils/compaction.js';
|
||||
import {
|
||||
mediaUrlOf,
|
||||
parseDataUri,
|
||||
unsupportedMediaTextPart,
|
||||
} from '../../../interfaces/chat/utils/mediaParts.js';
|
||||
import {
|
||||
isToolChoice,
|
||||
toolChoiceFromWire,
|
||||
} from '../../../protocols/openaiCompatible/openaiParams.js';
|
||||
import { buildCostsOverride } from '../../../interfaces/chat/utils/pricing.js';
|
||||
import { serviceSpeedCosts } from '../../../interfaces/chat/utils/serviceSpeed.js';
|
||||
} from '../../utils/mediaParts.js';
|
||||
import { isToolChoice, toolChoiceFromWire } from '../../utils/openaiParams.js';
|
||||
import { buildCostsOverride } from '../../utils/pricing.js';
|
||||
import { serviceSpeedCosts } from '../../utils/serviceSpeed.js';
|
||||
import type {
|
||||
AIChatStream,
|
||||
AIChatTextStream,
|
||||
AIChatToolUseStream,
|
||||
} from '../../../interfaces/chat/utils/Streaming.js';
|
||||
} from '../../utils/Streaming.js';
|
||||
import {
|
||||
allowlistedFromHeader,
|
||||
applySafeguardsPolicy,
|
||||
@@ -71,10 +70,7 @@ import {
|
||||
} from './anthropicPolicy.js';
|
||||
import { FILES_API_BETA, processPuterPathUploads } from './fileUpload.js';
|
||||
import { CLAUDE_MODELS } from './models.js';
|
||||
import {
|
||||
sdkClientOptions,
|
||||
sdkRetryOptions,
|
||||
} from '../../../interfaces/chat/utils/sdkClient.js';
|
||||
import { sdkClientOptions, sdkRetryOptions } from '../../utils/sdkClient.js';
|
||||
|
||||
/**
|
||||
* Canonical media part → Anthropic block: `url` source for links, `base64`
|
||||
@@ -629,13 +625,9 @@ export class ClaudeProvider implements IChatProvider {
|
||||
const fast =
|
||||
args.speed === 'fast' &&
|
||||
modelUsed.serviceSpeedRates?.fast !== undefined;
|
||||
// The Anthropic route passes the header's names; anything else is dropped.
|
||||
const headerBetas = Array.isArray(args.anthropicBetas)
|
||||
? args.anthropicBetas.filter((beta) => typeof beta === 'string')
|
||||
: undefined;
|
||||
const safeguardsPolicy = applySafeguardsPolicy(
|
||||
args.safeguards,
|
||||
headerBetas,
|
||||
args.anthropicBetas,
|
||||
);
|
||||
|
||||
// -- metadata -----------------------------------------------------
|
||||
@@ -721,7 +713,7 @@ export class ClaudeProvider implements IChatProvider {
|
||||
};
|
||||
const betas = combineBetas(
|
||||
deriveBetas(features),
|
||||
allowlistedFromHeader(headerBetas, features),
|
||||
allowlistedFromHeader(args.anthropicBetas, features),
|
||||
safeguardsPolicy ? [safeguardsPolicy.beta] : [],
|
||||
);
|
||||
const usesBeta = betas.length > 0;
|
||||
+4
-38
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { IChatModel } from '../../../interfaces/chat/types.js';
|
||||
import type { IChatModel } from '../../types.js';
|
||||
import {
|
||||
allowlistedFromHeader,
|
||||
applySafeguardsPolicy,
|
||||
@@ -283,45 +283,16 @@ describe('claudeToolPolicy', () => {
|
||||
|
||||
it('throws 400 for a function tool with no function property', () => {
|
||||
expect(() => claudeToolPolicy([{ type: 'function' }], models)).toThrowError(
|
||||
expect.objectContaining({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
}),
|
||||
expect.objectContaining({ statusCode: 400 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws 400 for a non-object tool entry', () => {
|
||||
expect(() => claudeToolPolicy([null], models)).toThrowError(
|
||||
expect.objectContaining({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
}),
|
||||
expect.objectContaining({ statusCode: 400 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws 400 for a bare string tool entry', () => {
|
||||
// A TypeError here has no status, so the retry loop reads it as a
|
||||
// provider failure and marks the route unhealthy for every caller.
|
||||
expect(() => claudeToolPolicy(['lookup'], models)).toThrowError(
|
||||
expect.objectContaining({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns no tools for an empty list', () => {
|
||||
expect(claudeToolPolicy([], models).tools).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults max_uses on the OpenAI web_search form', () => {
|
||||
expect(
|
||||
claudeToolPolicy([{ type: 'web_search' }], models).tools,
|
||||
).toEqual([
|
||||
{ type: 'web_search_20250305', name: 'web_search', max_uses: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps the OpenAI web_search form and clamps max_uses', () => {
|
||||
const r = claudeToolPolicy([{ type: 'web_search', max_uses: 999 }], models);
|
||||
expect(r.tools).toEqual([
|
||||
@@ -380,12 +351,7 @@ describe('claudeToolPolicy', () => {
|
||||
it('rejects an advisor tool naming a model outside the catalog', () => {
|
||||
expect(() =>
|
||||
claudeToolPolicy([{ type: 'advisor_20260301', model: 'not-a-model' }], models),
|
||||
).toThrowError(
|
||||
expect.objectContaining({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
}),
|
||||
);
|
||||
).toThrowError(expect.objectContaining({ statusCode: 400 }));
|
||||
});
|
||||
|
||||
it('passes client-executed tools through verbatim', () => {
|
||||
+3
-9
@@ -26,15 +26,9 @@
|
||||
* rules.
|
||||
*/
|
||||
|
||||
import { HttpError } from '../../../../../core/http/HttpError.js';
|
||||
import {
|
||||
AI_ADVISOR,
|
||||
AI_WEB_SEARCH_MAX_USES,
|
||||
} from '../../../shared/aiLimits.js';
|
||||
import type {
|
||||
CacheControl,
|
||||
IChatModel,
|
||||
} from '../../../interfaces/chat/types.js';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import { AI_ADVISOR, AI_WEB_SEARCH_MAX_USES } from '../../../util/aiLimits.js';
|
||||
import type { CacheControl, IChatModel } from '../../types.js';
|
||||
import { FILES_API_BETA } from './fileUpload.js';
|
||||
|
||||
// -- cache_control ------------------------------------------------------
|
||||
+5
-5
@@ -29,11 +29,11 @@
|
||||
import type Anthropic from '@anthropic-ai/sdk';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import type { Actor } from '../../../../../core/actor.js';
|
||||
import { runWithContext } from '../../../../../core/context.js';
|
||||
import { PuterServer } from '../../../../../server.js';
|
||||
import { setupTestServer } from '../../../../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../../../../util/userProvisioning.js';
|
||||
import type { Actor } from '../../../../core/actor.js';
|
||||
import { runWithContext } from '../../../../core/context.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../../../util/userProvisioning.js';
|
||||
import { FILES_API_BETA, processPuterPathUploads } from './fileUpload.js';
|
||||
|
||||
const CLAUDE_MAX_FILE_SIZE = 30 * 1_000_000;
|
||||
+5
-5
@@ -18,11 +18,11 @@
|
||||
*/
|
||||
|
||||
import Anthropic, { toFile } from '@anthropic-ai/sdk';
|
||||
import type { Actor } from '../../../../../core/actor.js';
|
||||
import type { FSService } from '../../../../../services/fs/FSService.js';
|
||||
import type { FSEntryStore } from '../../../../../stores/fs/FSEntryStore.js';
|
||||
import type { S3ObjectStore } from '../../../../../stores/fs/S3ObjectStore.js';
|
||||
import { loadFileInput } from '../../../../util/fileInput.js';
|
||||
import type { Actor } from '../../../../core/actor.js';
|
||||
import type { FSService } from '../../../../services/fs/FSService.js';
|
||||
import type { FSEntryStore } from '../../../../stores/fs/FSEntryStore.js';
|
||||
import type { S3ObjectStore } from '../../../../stores/fs/S3ObjectStore.js';
|
||||
import { loadFileInput } from '../../../util/fileInput.js';
|
||||
|
||||
export const FILES_API_BETA = 'files-api-2025-04-14';
|
||||
// Claude's documented per-file cap is 500MB, but pulling huge objects
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { IChatModel } from '../../../interfaces/chat/types.js';
|
||||
import type { IChatModel } from '../../types.js';
|
||||
|
||||
// Sources: https://models.dev/api.json and https://platform.claude.com/docs/en/about-claude/pricing
|
||||
export const CLAUDE_MODELS: IChatModel[] = [
|
||||
+3
-2
@@ -28,9 +28,10 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
makeMeteringStub,
|
||||
optionalEnv,
|
||||
skipUnlessEnv,
|
||||
withTestActor,
|
||||
} from '../../../shared/integrationTestUtil.js';
|
||||
import { optionalEnv, skipUnlessEnv } from '../../../../../testEnv.js';
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { DeepSeekProvider } from './DeepSeekProvider.js';
|
||||
|
||||
const ENV_VAR = 'PUTER_TEST_AI_DEEPSEEK_API_KEY';
|
||||
+8
-8
@@ -42,16 +42,16 @@ import {
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import { SYSTEM_ACTOR } from '../../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../../server.js';
|
||||
import { setupTestServer } from '../../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../shared/integrationTestUtil.js';
|
||||
import { costsForRequest, usageCost } from '../../../interfaces/chat/utils/pricing.js';
|
||||
import { AIChatStream } from '../../../interfaces/chat/utils/Streaming.js';
|
||||
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import { costsForRequest, usageCost } from '../../utils/pricing.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { DEEPSEEK_MODELS } from './models.js';
|
||||
import { DeepSeekProvider } from './DeepSeekProvider.js';
|
||||
import { modelLookupNames } from '../../../interfaces/chat/utils/modelRouting.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
|
||||
// ── OpenAI SDK mock ─────────────────────────────────────────────────
|
||||
|
||||
+4
-4
@@ -19,13 +19,13 @@
|
||||
|
||||
import dedent from 'dedent';
|
||||
import { OpenAI } from 'openai';
|
||||
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
|
||||
import type { PuterMessage } from '../../../interfaces/chat/types.js';
|
||||
import { sdkClientOptions } from '../../../interfaces/chat/utils/sdkClient.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type { PuterMessage } from '../../types.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import {
|
||||
type ChatProviderConfig,
|
||||
OpenAICompatProvider,
|
||||
} from '../../../protocols/openaiCompatible/OpenAICompatProvider.js';
|
||||
} from '../OpenAICompatProvider.js';
|
||||
import { DEEPSEEK_MODELS } from './models.js';
|
||||
|
||||
// Function calling currently loops unless the tool result is restated as a
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { IChatModel } from '../../../interfaces/chat/types.js';
|
||||
import type { IChatModel } from '../../types.js';
|
||||
|
||||
// Peak rates from https://api-docs.deepseek.com/quick_start/pricing. Direct
|
||||
// DeepSeek always bills at these rates, including off-peak hours, weekends and
|
||||
+8
-39
@@ -26,21 +26,22 @@
|
||||
|
||||
import { Writable } from 'node:stream';
|
||||
import axios from 'axios';
|
||||
import { setupPuterTestEnv } from '../../../../../testUtil.js';
|
||||
import { setupPuterTestEnv } from '../../../../testUtil.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
makeMeteringStub,
|
||||
optionalEnv,
|
||||
skipUnlessEnv,
|
||||
withTestActor,
|
||||
} from '../../../shared/integrationTestUtil.js';
|
||||
import { optionalEnv, skipUnlessEnv } from '../../../../../testEnv.js';
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import type {
|
||||
ICompleteArguments,
|
||||
IChatMessageResult,
|
||||
UsageDetails,
|
||||
} from '../../../interfaces/chat/types.js';
|
||||
import { normalize_messages } from '../../../interfaces/chat/utils/Messages.js';
|
||||
import { AIChatStream } from '../../../interfaces/chat/utils/Streaming.js';
|
||||
} from '../../types.js';
|
||||
import { normalize_messages } from '../../utils/Messages.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { GeminiChatProvider } from './GeminiChatProvider.js';
|
||||
|
||||
const ENV_VAR = 'PUTER_TEST_AI_GEMINI_API_KEY';
|
||||
@@ -113,6 +114,7 @@ describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
expect(typeof text === 'string' && text.length > 0).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
},
|
||||
);
|
||||
|
||||
@@ -376,36 +378,3 @@ 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);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
+11
-143
@@ -36,23 +36,23 @@ import {
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import { Context } from '../../../../../core/context.js';
|
||||
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../../server.js';
|
||||
import { setupTestServer } from '../../../../../testUtil.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import {
|
||||
readStreamResult,
|
||||
withTestActor,
|
||||
} from '../../../shared/integrationTestUtil.js';
|
||||
import { ChatCompletionDriver } from '../../../interfaces/chat/ChatCompletionDriver.js';
|
||||
import type { ICompleteArguments, UsageDetails } from '../../../interfaces/chat/types.js';
|
||||
import { normalize_messages } from '../../../interfaces/chat/utils/Messages.js';
|
||||
import { trackedOutputTokens } from '../../../interfaces/chat/utils/pricing.js';
|
||||
import { AIChatStream } from '../../../interfaces/chat/utils/Streaming.js';
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { ChatCompletionDriver } from '../../ChatCompletionDriver.js';
|
||||
import type { ICompleteArguments, UsageDetails } from '../../types.js';
|
||||
import { normalize_messages } from '../../utils/Messages.js';
|
||||
import { trackedOutputTokens } from '../../utils/pricing.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import {
|
||||
isUpstreamTimeoutError,
|
||||
UpstreamDeadlineError,
|
||||
} from '../../../../util/upstreamErrors.js';
|
||||
} from '../../../util/upstreamErrors.js';
|
||||
import { GeminiChatProvider } from './GeminiChatProvider.js';
|
||||
import { geminiNativeClient } from './geminiNative.js';
|
||||
import { GEMINI_MODELS } from './models.js';
|
||||
@@ -114,8 +114,6 @@ 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',
|
||||
@@ -529,20 +527,6 @@ describe('gemini-4-argon text completion', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('gemini-4-argon request without a turn', () => {
|
||||
it.each([
|
||||
['no messages', []],
|
||||
['only a system message', [{ role: 'system', content: 'Be brief.' }]],
|
||||
])('rejects %s with 400 before calling Gemini', async (_label, messages) => {
|
||||
await expect(complete({ messages } as never)).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
message: '`messages` must include a user or assistant message',
|
||||
});
|
||||
expect(generateContent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('gemini-4-argon unsupported content', () => {
|
||||
const AUDIO = {
|
||||
type: 'input_audio',
|
||||
@@ -1618,119 +1602,3 @@ 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);
|
||||
});
|
||||
});
|
||||
+11
-11
@@ -43,17 +43,17 @@ import {
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import { SYSTEM_ACTOR } from '../../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../../server.js';
|
||||
import { setupTestServer } from '../../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../shared/integrationTestUtil.js';
|
||||
import { normalize_messages } from '../../../interfaces/chat/utils/Messages.js';
|
||||
import { AIChatStream } from '../../../interfaces/chat/utils/Streaming.js';
|
||||
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import { normalize_messages } from '../../utils/Messages.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { GEMINI_MODELS } from './models.js';
|
||||
import { GeminiChatProvider } from './GeminiChatProvider.js';
|
||||
import { modelLookupNames } from '../../../interfaces/chat/utils/modelRouting.js';
|
||||
import { costsForRequest, listedCosts } from '../../../interfaces/chat/utils/pricing.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { costsForRequest, listedCosts } from '../../utils/pricing.js';
|
||||
|
||||
// ── OpenAI SDK mock ─────────────────────────────────────────────────
|
||||
//
|
||||
@@ -82,7 +82,7 @@ const { inlineHttpImageUrlsMock } = vi.hoisted(() => ({
|
||||
inlineHttpImageUrlsMock: vi.fn(async (_messages: unknown) => {}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../protocols/openaiCompatible/inlineImages.js', () => ({
|
||||
vi.mock('../../utils/inlineImages.js', () => ({
|
||||
inlineHttpImageUrls: inlineHttpImageUrlsMock,
|
||||
}));
|
||||
|
||||
@@ -191,7 +191,7 @@ describe('GeminiChatProvider model catalog', () => {
|
||||
// happily on a doubled id, and a doubled id is not hypothetical here --
|
||||
// gemini-3.7-flash was once declared twice with two different cache
|
||||
// prices. Catalog-wide uniqueness is enforced for every provider in
|
||||
// interfaces/chat/modelCatalogs.test.ts; this checks the other end, that the
|
||||
// providers/modelCatalogs.test.ts; this checks the other end, that the
|
||||
// provider actually routes through the deduplicating helper rather than
|
||||
// flattening the catalog itself.
|
||||
it('list() emits every id exactly once', async () => {
|
||||
+16
-12
@@ -24,36 +24,40 @@ import {
|
||||
GoogleGenAI,
|
||||
} from '@google/genai';
|
||||
import { OpenAI } from 'openai';
|
||||
import { HttpError } from '../../../../../core/http/HttpError.js';
|
||||
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type {
|
||||
IChatModel,
|
||||
ICompleteArguments,
|
||||
PuterMessage,
|
||||
UsageDetails,
|
||||
} from '../../../interfaces/chat/types.js';
|
||||
import { toolChoiceToWire } from '../../../protocols/openaiCompatible/openaiParams.js';
|
||||
import { sdkClientOptions } from '../../../interfaces/chat/utils/sdkClient.js';
|
||||
} from '../../types.js';
|
||||
import { toolChoiceToWire } from '../../utils/openaiParams.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import {
|
||||
type ChatCompletionsClient,
|
||||
type ChatProviderConfig,
|
||||
OpenAICompatProvider,
|
||||
type UsageSource,
|
||||
} from '../../../protocols/openaiCompatible/OpenAICompatProvider.js';
|
||||
import { usesGoogleGeminiEndpoint } from '../geminiRest.js';
|
||||
} from '../OpenAICompatProvider.js';
|
||||
import { usesGoogleGeminiEndpoint } from '../../../util/geminiRest.js';
|
||||
import { geminiNativeClient } from './geminiNative.js';
|
||||
import { GEMINI_MODELS, GEMINI_NATIVE_MODELS } from './models.js';
|
||||
import {
|
||||
GEMINI_MODELS,
|
||||
GEMINI_NATIVE_MODELS,
|
||||
GEMINI_ARGON_NON_STREAM_MAX_TOKENS,
|
||||
} from './models.js';
|
||||
|
||||
type GroundingContent = { grounding_metadata?: unknown };
|
||||
|
||||
/** Some native models have a lower output cap when the response isn't streamed. */
|
||||
/** Unary Argon uses a lower output cap than streaming. */
|
||||
const nativeOutputCap = (
|
||||
args: ICompleteArguments,
|
||||
model: IChatModel,
|
||||
): number | undefined =>
|
||||
args.stream
|
||||
? undefined
|
||||
: GEMINI_NATIVE_MODELS.get(model.id)?.unaryMaxTokens;
|
||||
model.id === 'gemini-4-argon' && !args.stream
|
||||
? GEMINI_ARGON_NON_STREAM_MAX_TOKENS
|
||||
: undefined;
|
||||
|
||||
const COMPAT_BASE_URL =
|
||||
'https://generativelanguage.googleapis.com/v1beta/openai/';
|
||||
+7
-14
@@ -31,15 +31,15 @@ import type {
|
||||
Part,
|
||||
ThinkingLevel,
|
||||
} from '@google/genai';
|
||||
import { HttpError } from '../../../../../core/http/HttpError.js';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import {
|
||||
parseDataUri,
|
||||
unsupportedMediaTextPart,
|
||||
} from '../../../interfaces/chat/utils/mediaParts.js';
|
||||
import { SDK_TIMEOUT_MS } from '../../../interfaces/chat/utils/sdkClient.js';
|
||||
import { promoteStopForToolCalls } from '../../../interfaces/chat/utils/stopReason.js';
|
||||
import { UpstreamDeadlineError } from '../../../../util/upstreamErrors.js';
|
||||
import type { ChatCompletionsClient } from '../../../protocols/openaiCompatible/OpenAICompatProvider.js';
|
||||
} from '../../utils/mediaParts.js';
|
||||
import { SDK_TIMEOUT_MS } from '../../utils/sdkClient.js';
|
||||
import { promoteStopForToolCalls } from '../../utils/stopReason.js';
|
||||
import { UpstreamDeadlineError } from '../../../util/upstreamErrors.js';
|
||||
import type { ChatCompletionsClient } from '../OpenAICompatProvider.js';
|
||||
|
||||
type ChatContentPart =
|
||||
string | { type?: string; text?: string; image_url?: { url?: string } };
|
||||
@@ -78,7 +78,7 @@ interface ChatParams {
|
||||
tool_choice?: ChatToolChoice;
|
||||
max_completion_tokens?: number;
|
||||
temperature?: number;
|
||||
reasoning_effort?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
reasoning_effort?: 'minimal' | 'low' | 'medium' | 'high';
|
||||
stream?: boolean;
|
||||
service_tier?: string;
|
||||
}
|
||||
@@ -233,13 +233,6 @@ const toGenerateContentRequest = (
|
||||
signal: AbortSignal | undefined,
|
||||
) => {
|
||||
const { contents, systemInstruction } = toGeminiContents(params.messages);
|
||||
if (!contents.length) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'`messages` must include a user or assistant message',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
// Only caller-defined functions; Google's built-in tools aren't offered.
|
||||
const functions = (params.tools ?? []).flatMap((tool) =>
|
||||
tool.type === 'function' && tool.function ? [tool.function] : [],
|
||||
+9
-71
@@ -22,15 +22,13 @@ import type {
|
||||
ModelCost,
|
||||
ModelPricingOverride,
|
||||
ReasoningEffort,
|
||||
} from '../../../interfaces/chat/types.js';
|
||||
} from '../../types.js';
|
||||
|
||||
/** What a natively served model accepts beyond the shared request fields. */
|
||||
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 = {
|
||||
@@ -38,25 +36,16 @@ 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-hydrogen',
|
||||
'gemini-4-argon',
|
||||
{
|
||||
reasoningEfforts: new Set(['low', 'medium', 'high', 'xhigh']),
|
||||
reasoningEfforts: new Set(['low', 'medium', 'high']),
|
||||
temperature: false,
|
||||
},
|
||||
],
|
||||
@@ -99,7 +88,7 @@ const FLASH_LIST_PRICING = listPricingFrom(
|
||||
FLASH_LAUNCH_COSTS,
|
||||
);
|
||||
|
||||
const GEMINI_4_LAUNCH_COSTS: ModelCost = {
|
||||
const ARGON_LAUNCH_COSTS: ModelCost = {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200,
|
||||
completion_tokens: 1000,
|
||||
@@ -130,6 +119,9 @@ 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',
|
||||
@@ -151,66 +143,12 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.8 },
|
||||
costs: GEMINI_4_LAUNCH_COSTS,
|
||||
costs: ARGON_LAUNCH_COSTS,
|
||||
pricingOverrides: listPricingFrom(
|
||||
'2026-12-01T00:00:00Z',
|
||||
GEMINI_4_LAUNCH_COSTS,
|
||||
ARGON_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',
|
||||
+3
-2
@@ -29,9 +29,10 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
makeMeteringStub,
|
||||
optionalEnv,
|
||||
skipUnlessEnv,
|
||||
withTestActor,
|
||||
} from '../../../shared/integrationTestUtil.js';
|
||||
import { optionalEnv, skipUnlessEnv } from '../../../../../testEnv.js';
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { GroqAIProvider } from './GroqAIProvider.js';
|
||||
|
||||
const ENV_VAR = 'PUTER_TEST_AI_GROQ_API_KEY';
|
||||
+8
-8
@@ -29,7 +29,7 @@
|
||||
* exercises the real Groq endpoint.
|
||||
*/
|
||||
|
||||
import { Context } from '../../../../../core/context.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import { Writable } from 'node:stream';
|
||||
import {
|
||||
afterAll,
|
||||
@@ -43,15 +43,15 @@ import {
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import { SYSTEM_ACTOR } from '../../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../../server.js';
|
||||
import { setupTestServer } from '../../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../shared/integrationTestUtil.js';
|
||||
import { AIChatStream } from '../../../interfaces/chat/utils/Streaming.js';
|
||||
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { GROQ_MODELS } from './models.js';
|
||||
import { GroqAIProvider } from './GroqAIProvider.js';
|
||||
import { modelLookupNames } from '../../../interfaces/chat/utils/modelRouting.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
|
||||
// ── Groq SDK mock ───────────────────────────────────────────────────
|
||||
|
||||
Loaded 100 of 434 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user