diff --git a/.github/workflows/service-llm-gateway-service.yml b/.github/workflows/service-llm-gateway-service.yml new file mode 100644 index 0000000..0f84bc0 --- /dev/null +++ b/.github/workflows/service-llm-gateway-service.yml @@ -0,0 +1,110 @@ +name: llm-gateway-service + +on: + push: + branches: ["master"] + paths: + - "services/llm-gateway-service/**" + pull_request: + branches: ["master"] + paths: + - "services/llm-gateway-service/**" + +defaults: + run: + working-directory: services/llm-gateway-service + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22.x" + cache: "npm" + cache-dependency-path: services/llm-gateway-service/package-lock.json + - name: Install dependencies + run: npm ci + - name: Lint + run: npm run lint + + test: + name: Test + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22.x" + cache: "npm" + cache-dependency-path: services/llm-gateway-service/package-lock.json + - name: Install dependencies + run: npm ci + - name: Type-check + run: npm run build + - name: Run tests + run: npm run test:coverage + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: services/llm-gateway-service/coverage/lcov.info + flags: llm-gateway-service + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + if: always() + + generate-openapi: + name: Generate OpenAPI Spec + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22.x" + cache: "npm" + cache-dependency-path: services/llm-gateway-service/package-lock.json + - name: Install dependencies + run: npm ci + - name: Generate OpenAPI spec + run: npm run generate:openapi + - name: Upload OpenAPI spec + uses: actions/upload-artifact@v4 + with: + name: openapi-spec-llm-gateway-service + path: services/llm-gateway-service/llm-gateway-service.openapi.json + + build: + name: Build & Push Docker Image + runs-on: ubuntu-latest + needs: generate-openapi + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/llm-gateway-service + tags: | + type=sha,prefix=sha- + type=raw,value=latest + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: services/llm-gateway-service + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index fc5d130..8b37ec4 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,7 @@ test/results/ coverage/ -.vscode \ No newline at end of file +.vscode + +# kilo +.kilo \ No newline at end of file diff --git a/README.md b/README.md index a404572..b8781da 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![jsonpath-mapper-service CI](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-jsonpath-mapper-service.yml/badge.svg)](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-jsonpath-mapper-service.yml) [![sql-assessment-service CI](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-sql-assessment-service.yml/badge.svg)](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-sql-assessment-service.yml) [![fermentaladin-service CI](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-fermentaladin-service.yml/badge.svg)](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-fermentaladin-service.yml) +[![llm-gateway-service CI](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-llm-gateway-service.yml/badge.svg)](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-llm-gateway-service.yml) ## Services @@ -14,6 +15,7 @@ | [jsonpath-mapper-service](services/jsonpath-mapper-service/README.md) | TypeScript | [![CI](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-jsonpath-mapper-service.yml/badge.svg)](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-jsonpath-mapper-service.yml) | [![codecov](https://codecov.io/gh/HTW-ALADIN/ALADIN-Services/graph/badge.svg?flag=jsonpath-mapper-service)](https://codecov.io/gh/HTW-ALADIN/ALADIN-Services?flags[0]=jsonpath-mapper-service) | JSONPath Mapper — JSON-to-JSON transformation utility exposed as a Fastify HTTP API | | [sql-assessment-service](services/sql-assessment-service/README.md) | TypeScript | [![CI](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-sql-assessment-service.yml/badge.svg)](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-sql-assessment-service.yml) | [![codecov](https://codecov.io/gh/HTW-ALADIN/ALADIN-Services/graph/badge.svg?flag=sql-assessment-service)](https://codecov.io/gh/HTW-ALADIN/ALADIN-Services?flags[0]=sql-assessment-service) | SQL Assessment — schema analysis, SQL task generation, and query grading | | [fermentaladin-service](services/fermentaladin-service/README.md) | Python | [![CI](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-fermentaladin-service.yml/badge.svg)](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-fermentaladin-service.yml) | — | Bioreactor fermentation simulation — ODE-based multi-phase kinetic model exposed as a FastAPI HTTP service | +| [llm-gateway-service](services/llm-gateway-service/README.md) | TypeScript | [![CI](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-llm-gateway-service.yml/badge.svg)](https://github.com/HTW-ALADIN/ALADIN-Services/actions/workflows/service-llm-gateway-service.yml) | [![codecov](https://codecov.io/gh/HTW-ALADIN/ALADIN-Services/graph/badge.svg?flag=llm-gateway-service)](https://codecov.io/gh/HTW-ALADIN/ALADIN-Services?flags[0]=llm-gateway-service) | HTTP/CLI wrapper around a self-hosted LLM Gateway instance, using the Vercel AI SDK | ## For Developers: diff --git a/services/llm-gateway-service/.c8rc.json b/services/llm-gateway-service/.c8rc.json new file mode 100644 index 0000000..5267460 --- /dev/null +++ b/services/llm-gateway-service/.c8rc.json @@ -0,0 +1,11 @@ +{ + "reporter": ["text", "html", "lcov"], + "include": ["src/**/*.ts"], + "exclude": ["test/**", "scripts/**", "src/api/index.ts", "src/cli/index.ts"], + "all": true, + "check-coverage": true, + "lines": 75, + "functions": 75, + "branches": 60, + "statements": 75 +} diff --git a/services/llm-gateway-service/.dockerignore b/services/llm-gateway-service/.dockerignore new file mode 100644 index 0000000..0371271 --- /dev/null +++ b/services/llm-gateway-service/.dockerignore @@ -0,0 +1,9 @@ +node_modules +dist +coverage +.nyc_output +.git +.DS_Store +.env +*.openapi.json +*.openapi.yaml diff --git a/services/llm-gateway-service/.gitignore b/services/llm-gateway-service/.gitignore new file mode 100644 index 0000000..d35098b --- /dev/null +++ b/services/llm-gateway-service/.gitignore @@ -0,0 +1,7 @@ +/node_modules +/dist +/coverage +/.nyc_output +.env +*.openapi.json +*.openapi.yaml diff --git a/services/llm-gateway-service/.mocharc.json b/services/llm-gateway-service/.mocharc.json new file mode 100644 index 0000000..e648ee1 --- /dev/null +++ b/services/llm-gateway-service/.mocharc.json @@ -0,0 +1,5 @@ +{ + "node-option": ["import=tsx"], + "spec": "test/**/*.test.ts", + "timeout": 10000 +} diff --git a/services/llm-gateway-service/.prettierrc b/services/llm-gateway-service/.prettierrc new file mode 100644 index 0000000..1c42f15 --- /dev/null +++ b/services/llm-gateway-service/.prettierrc @@ -0,0 +1,5 @@ +{ + "trailingComma": "es5", + "singleQuote": true, + "useTabs": true +} diff --git a/services/llm-gateway-service/Dockerfile b/services/llm-gateway-service/Dockerfile new file mode 100644 index 0000000..7644f94 --- /dev/null +++ b/services/llm-gateway-service/Dockerfile @@ -0,0 +1,39 @@ +# ── Stage 1: deps ──────────────────────────────────────────────────────────── +# Installs all dependencies (dev deps needed to run tsx at runtime, since the +# service is served directly from TypeScript source, matching the repo's +# other Node/TypeScript services). +FROM node:22-slim AS deps + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +# ── Stage 2: runtime ────────────────────────────────────────────────────────── +FROM node:22-slim + +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY package.json ./ +COPY tsconfig.json ./ +COPY src/ ./src/ + +# ── Environment defaults ─────────────────────────────────────────────────────── +# LLM_GATEWAY_BASE_URL / LLM_GATEWAY_API_KEY must be supplied at runtime and +# point at a self-hosted LLM Gateway instance's OpenAI-compatible API. +ENV NODE_ENV=production \ + PORT=8002 \ + HOST=0.0.0.0 + +# Run as the non-root `node` user baked into the base image rather than root. +RUN chown -R node:node /app +USER node + +# ── Port ─────────────────────────────────────────────────────────────────────── +EXPOSE 8002 + +# Default entrypoint runs the HTTP API. Override the command to invoke the +# CLI instead, e.g.: +# docker run --rm node_modules/.bin/tsx src/cli/index.ts generate - < request.json +CMD ["node_modules/.bin/tsx", "src/api/index.ts"] diff --git a/services/llm-gateway-service/Makefile b/services/llm-gateway-service/Makefile new file mode 100644 index 0000000..e910a4d --- /dev/null +++ b/services/llm-gateway-service/Makefile @@ -0,0 +1,30 @@ +IMAGE_NAME := llm-gateway-service +OPENAPI_OUT := llm-gateway-service.openapi.json +PORT ?= 8002 + +.PHONY: prep build test lint start clean docker-build generate-openapi + +prep: + npm ci + +build: + npm run build + +test: + npm run test:coverage + +lint: + npm run lint + npx prettier --check . + +start: + PORT=$(PORT) npm run start:api + +clean: + rm -rf dist/ coverage/ node_modules/ $(OPENAPI_OUT) + +docker-build: + docker build -t $(IMAGE_NAME) . + +generate-openapi: + npm run generate:openapi diff --git a/services/llm-gateway-service/README.md b/services/llm-gateway-service/README.md new file mode 100644 index 0000000..7bd795e --- /dev/null +++ b/services/llm-gateway-service/README.md @@ -0,0 +1,195 @@ +# LLM Gateway Service + +HTTP and CLI wrapper around a **self-hosted [LLM Gateway](https://docs.llmgateway.io/)** instance, built with the +[Vercel AI SDK](https://sdk.vercel.ai/) and [`@llmgateway/ai-sdk-provider`](https://www.npmjs.com/package/@llmgateway/ai-sdk-provider). + +This service replaces the former `openai-wrapper-service`. Instead of talking directly to the OpenAI SDK, it routes +text generation and embeddings through LLM Gateway, which can in turn route to OpenAI, Anthropic, Google, and other +providers (including custom OpenAI-compatible endpoints) from one place. + +- Non-streaming text generation (chat, system instructions, tool/function-call definitions, vision input) +- Embeddings +- A Vercel AI SDK-shaped request/response format (the format used by the frontend this service backs), behind a + pluggable adapter so other formats (OpenAI, Anthropic) can be added later — see `src/core/format/` +- A CLI that mirrors the API and is the primary way this service is invoked from workflows (JSON in, JSON out) +- A generated OpenAPI specification + +## Code Layout + +```text +src/config.ts # Environment-driven configuration +src/core/types.ts # Format-agnostic request/response model +src/core/gateway-client.ts # Vercel AI SDK + LLM Gateway client (incl. custom-provider bypass) +src/core/provider-registry.ts # Best-effort custom provider registration with the gateway +src/core/format/vercel.ts # Vercel AI SDK wire-format adapter +src/api/ # Fastify app, routes, and TypeBox schemas +src/cli/ # Commander-based CLI (generate, register-provider, health) +scripts/generate-openapi.ts # OpenAPI export utility +``` + +## Custom providers: registration vs. one-shot calls + +LLM Gateway lets you attach any OpenAI-compatible endpoint as a **custom provider** +([docs](https://docs.llmgateway.io/features/custom-providers)), addressed as `{providerName}/{model}`. Normally this +is configured once via the gateway's dashboard. + +That doesn't fit a workflow that needs to **deploy this service, point it at a brand-new custom provider, and call +it — all in one automated step** — because: + +- LLM Gateway's custom-provider registration is dashboard/session-auth oriented. +- Its only documented bearer-token API ("Master Keys", see [docs](https://docs.llmgateway.io/features/master-keys)) + is **Enterprise-only**, and even then only covers projects, gateway API keys, and IAM rules — **not** custom + provider/BYOK registration. + +So this service supports two paths: + +1. **One-shot inline override (recommended for workflows):** pass `customProvider: { baseUrl, apiKey }` on + `POST /generate` / `POST /embeddings` (or the `generate` CLI command). The request is sent directly to that + OpenAI-compatible endpoint, **bypassing the gateway entirely** for that call. No prior registration needed — + this is what makes "deploy + configure + call" possible in a single step. The response is marked + `viaGatewayBypass: true` since gateway-side cost tracking/analytics don't apply to it. +2. **Best-effort persistent registration:** `POST /providers` / the `register-provider` CLI command attempts to + register the provider with the gateway via an admin API, if `LLM_GATEWAY_ADMIN_BASE_URL` and + `LLM_GATEWAY_ADMIN_TOKEN` are configured (e.g. an Enterprise master key or an internal admin endpoint). Without + that configuration it returns `{"registered": false, "mode": "skipped"}` rather than failing, since the inline + override above is the reliable path. Use this when you want the gateway itself to track cost/usage for a + provider that will be reused across many requests. + +## Configuration + +| Variable | Default | Description | +| ---------------------------- | -------------------------- | --------------------------------------------------------------------------- | +| `LLM_GATEWAY_BASE_URL` | `http://localhost:4001/v1` | Base URL of the self-hosted LLM Gateway's OpenAI-compatible API | +| `LLM_GATEWAY_API_KEY` | unset | Bearer token for inference requests against the gateway | +| `LLM_GATEWAY_ADMIN_BASE_URL` | unset | Base URL of the gateway's admin/management API (best-effort provider setup) | +| `LLM_GATEWAY_ADMIN_TOKEN` | unset | Bearer token for the admin API (e.g. an Enterprise master key) | +| `LLM_GATEWAY_TIMEOUT_MS` | `60000` | Timeout applied to gateway and custom-provider HTTP calls | +| `PORT` | `8002` | HTTP port used by `make start` and the Docker container | +| `HOST` | `0.0.0.0` | HTTP host to bind | + +The model and provider are supplied per request/CLI call, since available models depend on how the gateway (or a +custom provider) is configured. `LLM_GATEWAY_API_KEY` is only required for requests that route through the gateway; +it is intentionally **not** validated at startup (so `/health` still works without it), but any `/generate` or +`/embeddings` call that isn't using a `customProvider` override will fail fast with a clear error if it's unset, +rather than defaulting to an unauthenticated call. + +## Security disclaimer: authentication is NOT provided by this service + +**This service has no built-in authentication or authorization on any of its HTTP endpoints** (`/generate`, +`/embeddings`, `/providers`). Anyone who can reach the service over the network can invoke it, including its +`customProvider` bypass — which makes the service issue outbound HTTP requests, with a caller-supplied API key, to +a caller-supplied URL. + +**Securing access to this service is a deployment-time obligation delegated to whoever runs/embeds it** — the +consuming system, workflow orchestrator, or platform team deploying this service is responsible for placing it +behind an appropriate auth boundary before it is reachable from anything other than a fully trusted network. This +service does not, and is not intended to, provide that boundary itself. Suitable options include (non-exhaustive): + +- Only binding/exposing the service on a private network segment reachable exclusively by the trusted workflow + system that calls it (e.g. a Kubernetes-internal ClusterIP, a private VPC, or a sidecar-only network namespace). +- Fronting it with an API gateway, reverse proxy, or service mesh that enforces authentication (API keys, mTLS, + OAuth2/OIDC, network policies) before requests reach this service. +- Wrapping it in an authenticating sidecar/proxy if the deployment platform does not offer the above natively. + +Two related mitigations exist in the service itself, but neither is a substitute for an auth boundary: + +- `customProvider.baseUrl` is validated to reject loopback, link-local, RFC1918 private ranges, and known cloud + metadata endpoints (see `src/core/url-safety.ts`) before this service makes any outbound request to it. This is + a best-effort, static hostname check — it does not resolve DNS, and does not prevent requests to arbitrary + **public** URLs. +- Missing `LLM_GATEWAY_API_KEY` fails fast with a clear error (see above) rather than silently proceeding. + +**Do not expose this service on an untrusted network without first adding an authentication layer in front of it.** + +## API + +Start the service: + +```sh +make start +``` + +The API listens on port `8002` by default. OpenAPI docs: `http://localhost:8002/docs`. + +Endpoints: + +| Method | Path | Description | +| ------ | ------------- | -------------------------------------------------------------- | +| `GET` | `/health` | Liveness probe | +| `POST` | `/generate` | Generate text (non-streaming) via LLM Gateway | +| `POST` | `/embeddings` | Create embeddings via LLM Gateway | +| `POST` | `/providers` | Best-effort registration of a custom provider with the gateway | + +Example generation request: + +```json +{ + "provider": "openai", + "model": "gpt-4o", + "messages": [{ "role": "user", "content": "Summarize the purpose of ALADIN in one sentence." }], + "system": "Answer concisely.", + "maxOutputTokens": 120 +} +``` + +Example one-shot custom provider call (no prior registration required): + +```json +{ + "provider": "mycompany", + "model": "custom-gpt-4", + "messages": [{ "role": "user", "content": "Hello from my custom provider!" }], + "customProvider": { "baseUrl": "https://api.mycompany.com", "apiKey": "sk-xxx" } +} +``` + +## CLI + +The CLI mirrors the API and is the primary integration point for workflows. It reads a JSON request from a file or +stdin and writes the full JSON response to stdout or a file: + +```sh +npm run cli -- health +echo '{"provider":"openai","model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}' | npm run cli -- generate +npm run cli -- generate request.json -o response.json +npm run cli -- register-provider --name mycompany --base-url https://api.mycompany.com --api-key sk-xxx +``` + +## Docker + +Build: + +```sh +make docker-build +``` + +Run the HTTP API (default): + +```sh +docker run --rm -p 8002:8002 \ + -e LLM_GATEWAY_BASE_URL=http://gateway:4001/v1 \ + -e LLM_GATEWAY_API_KEY \ + llm-gateway-service +``` + +Run the CLI instead: + +```sh +docker run --rm -i \ + -e LLM_GATEWAY_BASE_URL=http://gateway:4001/v1 \ + -e LLM_GATEWAY_API_KEY \ + llm-gateway-service \ + node_modules/.bin/tsx src/cli/index.ts generate - < request.json +``` + +## Development + +```sh +make prep +make lint +make test +make generate-openapi +``` + +Tests mock the LLM Gateway/AI SDK boundary (a local HTTP server standing in for an OpenAI-compatible endpoint, plus +dependency-injected fakes for the API/CLI layers) — no live network calls are made in CI. diff --git a/services/llm-gateway-service/eslint.config.mjs b/services/llm-gateway-service/eslint.config.mjs new file mode 100644 index 0000000..88ee9a9 --- /dev/null +++ b/services/llm-gateway-service/eslint.config.mjs @@ -0,0 +1,39 @@ +import globals from 'globals'; +import mocha from 'eslint-plugin-mocha'; +import js from '@eslint/js'; +import ts from 'typescript-eslint'; + +export default [ + { + ignores: ['dist/', 'coverage/', '*.openapi.json', '*.openapi.yaml'], + }, + { files: ['**/*.{js,mjs,cjs,ts}'] }, + { languageOptions: { globals: { ...globals.node } } }, + js.configs.recommended, + ...ts.configs.recommended.map((config) => ({ + ...config, + files: ['**/*.{ts,tsx,mts}'], + + rules: { + ...config.rules, + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/no-unused-vars': 'off', + }, + })), + { + // enable mocha rules on test files + files: ['test/**/*.ts'], + ...mocha.configs.flat.recommended, + rules: { + ...mocha.configs.flat.recommended.rules, + 'mocha/no-mocha-arrows': 'off', + 'mocha/no-setup-in-describe': 'off', + 'mocha/max-top-level-suites': 'off', + }, + }, +]; diff --git a/services/llm-gateway-service/package-lock.json b/services/llm-gateway-service/package-lock.json new file mode 100644 index 0000000..36f25fe --- /dev/null +++ b/services/llm-gateway-service/package-lock.json @@ -0,0 +1,4599 @@ +{ + "name": "llm-gateway-service", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "llm-gateway-service", + "version": "0.1.0", + "dependencies": { + "@fastify/swagger": "^9.8.1", + "@fastify/swagger-ui": "^6.1.0", + "@llmgateway/ai-sdk-provider": "^3.8.1", + "@sinclair/typebox": "^0.34.52", + "ai": "^6.0.232", + "commander": "^15.0.0", + "fastify": "^5.10.0", + "zod": "^4.1.8" + }, + "bin": { + "llm-gateway": "dist/cli/index.js" + }, + "devDependencies": { + "@eslint/js": "^9.15.0", + "@types/chai": "^5.0.1", + "@types/mocha": "^10.0.10", + "@types/node": "^20.17.0", + "@types/sinon": "^17.0.3", + "c8": "^12.0.0", + "chai": "^5.1.2", + "eslint": "^9.15.0", + "eslint-plugin-mocha": "^10.5.0", + "globals": "^15.12.0", + "mocha": "^10.8.2", + "prettier": "^3.4.1", + "sinon": "^19.0.2", + "tsx": "^4.21.0", + "typescript": "^5.9.2", + "typescript-eslint": "^8.16.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.154", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.154.tgz", + "integrity": "sha512-pKJmMnTxdTOfo+iz7pCAgmnMt4uan2yIw8nChFVIk3yYBeX2A9p2MCGyP8jZ1zpqSYXBmczQZU7Pjvlj1aLbIQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.14", + "@ai-sdk/provider-utils": "4.0.40", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.14", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.14.tgz", + "integrity": "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.40", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.40.tgz", + "integrity": "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.14", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.3.0.tgz", + "integrity": "sha512-9YMYRpCOtMBrqKYWcqiw7ykOrn4D0jogHpJrFS0KGeSuOwzKMM5/mjj7B0CFLVoQ6htqKYw//Zs7APn9DBq05w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^1.0.1", + "fastify-plugin": "^6.0.0", + "fastq": "^1.17.1", + "glob": "^13.0.0" + } + }, + "node_modules/@fastify/swagger": { + "version": "9.8.1", + "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-9.8.1.tgz", + "integrity": "sha512-VpHMnqZTY8iBZYJE8WWkbKPrXIYWy2rDfIf5qLr6DzZSpQYZ+KxQVcJFiq/AMlvNwI4gCBd66++iUlxXXGT0IQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^6.0.0", + "json-schema-resolver": "^3.0.0", + "openapi-types": "^12.1.3", + "rfdc": "^1.3.1", + "yaml": "^2.4.2" + } + }, + "node_modules/@fastify/swagger-ui": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@fastify/swagger-ui/-/swagger-ui-6.1.0.tgz", + "integrity": "sha512-vbhHlJvzXujGco+6yumjt4cwptzb+0ZezPvApFSI+62IdJCfHtjpJrFIT+ln3BCB+KVFiUkI1Y+L9adIGmvdHQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/static": "^9.1.2", + "fastify-plugin": "^6.0.0", + "openapi-types": "^12.1.3", + "rfdc": "^1.3.1", + "yaml": "^2.4.1" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@llmgateway/ai-sdk-provider": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@llmgateway/ai-sdk-provider/-/ai-sdk-provider-3.8.1.tgz", + "integrity": "sha512-w8wfKrcC/anKosQHbGd4jHp2sKkJpGk+FG85qaUVIgsuQW6KVu9OVLcnZuHURs8A385viyjgLQ8D+pHEBDieBg==", + "license": "Apache-2.0", + "dependencies": { + "@llmgateway/models": "^1.0.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ai": "^6.0.0", + "zod": "^3.24.1 || ^v4" + } + }, + "node_modules/@llmgateway/models": { + "version": "1.187.1", + "resolved": "https://registry.npmjs.org/@llmgateway/models/-/models-1.187.1.tgz", + "integrity": "sha512-EFdPexQDleK5WV/Te10UVWsjD1YdS4DgixGjA8dB3F+SX9JV5tH07JggnSRsCXQCp2yuSMu/8TDofUjcdu1bSg==", + "license": "SEE LICENSE IN ../../LICENSE" + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", + "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/sinon": { + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.4.tgz", + "integrity": "sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-15.0.1.tgz", + "integrity": "sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ai": { + "version": "6.0.232", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.232.tgz", + "integrity": "sha512-Ml5Kys8d6TqPq+5dxwGXJYQrW0/k9vzu9Z4Tr8NS0A7pzlJCKLpKMYIUHvCmG96GGrZ8zn9cxoFE29gPCU3cqQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.154", + "@ai-sdk/provider": "3.0.14", + "@ai-sdk/provider-utils": "4.0.40", + "@opentelemetry/api": "^1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/c8": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz", + "integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^8.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^18.0.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/diff": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-mocha": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-mocha/-/eslint-plugin-mocha-10.5.0.tgz", + "integrity": "sha512-F2ALmQVPT1GoP27O1JTZGrV9Pqg8k79OeIuvw63UxMtQKREZtmkK1NFgkZQ2TW7L2JSSFKHFPTtHu5z8R9QNRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-utils": "^3.0.0", + "globals": "^13.24.0", + "rambda": "^7.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-mocha/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", + "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^2.0.0" + }, + "engines": { + "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=5" + } + }, + "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-json-stringify/node_modules/fast-uri": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.1.tgz", + "integrity": "sha512-YPOs1zD5TG2+EZt+r88LwF6mclA7TPkpwMP7ZN3TO2HiHS8TXvq7QA/17iJsV9dubcLo/f8eEYqMBruyQV21hQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-json-stringify/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz", + "integrity": "sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-resolver/-/json-schema-resolver-3.0.0.tgz", + "integrity": "sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fast-uri": "^3.0.5", + "rfdc": "^1.1.4" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/Eomm/json-schema-resolver?sponsor=1" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mocha": { + "version": "10.8.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", + "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.3", + "browser-stdout": "^1.3.1", + "chokidar": "^3.5.3", + "debug": "^4.3.5", + "diff": "^5.2.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^8.1.0", + "he": "^1.2.0", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^5.1.6", + "ms": "^2.1.3", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^6.5.1", + "yargs": "^16.2.0", + "yargs-parser": "^20.2.9", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/mocha/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/mocha/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/mocha/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/mocha/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/mocha/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mocha/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/mocha/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/mocha/node_modules/yargs": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nise": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.5.tgz", + "integrity": "sha512-SnRDPDBjxZZoU2n0+gzzLtSvo1OZo7j6jnbXsoh3AFxEGhaFU7ZF0TmefuKERq79wxR2U+MPn7ArW+Tl+clC3A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^15.1.1", + "just-extend": "^6.2.0", + "path-to-regexp": "^8.3.0" + } + }, + "node_modules/nise/node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/rambda": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/rambda/-/rambda-7.5.0.tgz", + "integrity": "sha512-y/M9weqWAH4iopRd7EHDEQQvpFPHj1AA3oHozE9tfITHUtTR7Z9PSlIRRG2l1GuW7sefC1cXFfIcF+cgnShdBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sinon": { + "version": "19.0.5", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-19.0.5.tgz", + "integrity": "sha512-r15s9/s+ub/d4bxNXqIUmwp6imVSdTorIRaxoecYjqTVLZ8RuoXr/4EDGwIBo6Waxn7f2gnURX9zuhAfCwaF6Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.5", + "@sinonjs/samsam": "^8.0.1", + "diff": "^7.0.0", + "nise": "^6.1.1", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/workerpool": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", + "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/services/llm-gateway-service/package.json b/services/llm-gateway-service/package.json new file mode 100644 index 0000000..0e184b7 --- /dev/null +++ b/services/llm-gateway-service/package.json @@ -0,0 +1,55 @@ +{ + "name": "llm-gateway-service", + "version": "0.1.0", + "description": "HTTP and CLI wrapper around a self-hosted LLM Gateway instance, using the Vercel AI SDK.", + "private": true, + "type": "module", + "bin": { + "llm-gateway": "./dist/cli/index.js" + }, + "scripts": { + "build": "tsc --noEmit", + "prestart": "npm run build", + "start": "npm run test", + "start:api": "tsx src/api/index.ts", + "cli": "tsx src/cli/index.ts", + "test": "mocha", + "test:coverage": "c8 mocha", + "lint": "eslint ./src ./test ./scripts", + "lint:fix": "eslint ./src ./test ./scripts --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "generate:openapi": "tsx scripts/generate-openapi.ts" + }, + "dependencies": { + "@fastify/swagger": "^9.8.1", + "@fastify/swagger-ui": "^6.1.0", + "@llmgateway/ai-sdk-provider": "^3.8.1", + "@sinclair/typebox": "^0.34.52", + "ai": "^6.0.232", + "commander": "^15.0.0", + "fastify": "^5.10.0", + "zod": "^4.1.8" + }, + "devDependencies": { + "@eslint/js": "^9.15.0", + "@types/chai": "^5.0.1", + "@types/mocha": "^10.0.10", + "@types/node": "^20.17.0", + "@types/sinon": "^17.0.3", + "c8": "^12.0.0", + "chai": "^5.1.2", + "eslint": "^9.15.0", + "eslint-plugin-mocha": "^10.5.0", + "globals": "^15.12.0", + "mocha": "^10.8.2", + "prettier": "^3.4.1", + "sinon": "^19.0.2", + "tsx": "^4.21.0", + "typescript": "^5.9.2", + "typescript-eslint": "^8.16.0" + }, + "engines": { + "node": ">=20" + } +} diff --git a/services/llm-gateway-service/scripts/generate-openapi.ts b/services/llm-gateway-service/scripts/generate-openapi.ts new file mode 100644 index 0000000..bd6f322 --- /dev/null +++ b/services/llm-gateway-service/scripts/generate-openapi.ts @@ -0,0 +1,30 @@ +/** + * Generates the OpenAPI spec from the Fastify app and writes it to disk. + * + * Usage: + * tsx scripts/generate-openapi.ts [--yaml] + */ +import { writeFileSync } from 'fs'; +import { buildServer } from '../src/api/server.js'; + +const yaml = process.argv.includes('--yaml'); + +async function generate() { + const server = await buildServer(); + await server.ready(); + + const spec = server.swagger({ yaml }); + const outFile = yaml + ? './llm-gateway-service.openapi.yaml' + : './llm-gateway-service.openapi.json'; + const content = yaml + ? (spec as unknown as string) + : JSON.stringify(spec, null, 2); + + writeFileSync(outFile, content + '\n'); + + await server.close(); + console.log(`OpenAPI spec written to ${outFile}`); +} + +generate(); diff --git a/services/llm-gateway-service/src/api/index.ts b/services/llm-gateway-service/src/api/index.ts new file mode 100644 index 0000000..a9203f1 --- /dev/null +++ b/services/llm-gateway-service/src/api/index.ts @@ -0,0 +1,21 @@ +import { buildServer } from './server.js'; +import { config } from '../config.js'; + +async function start() { + const fastify = await buildServer(); + + try { + await fastify.listen({ port: config.port, host: config.host }); + fastify.log.info( + `OpenAPI spec (JSON) available at http://localhost:${config.port}/docs/json` + ); + fastify.log.info( + `Swagger UI available at http://localhost:${config.port}/docs` + ); + } catch (err) { + fastify.log.error(err); + process.exit(1); + } +} + +start(); diff --git a/services/llm-gateway-service/src/api/reply-error.ts b/services/llm-gateway-service/src/api/reply-error.ts new file mode 100644 index 0000000..d655731 --- /dev/null +++ b/services/llm-gateway-service/src/api/reply-error.ts @@ -0,0 +1,51 @@ +import type { FastifyReply } from 'fastify'; +import { GatewayRequestError } from '../core/types.js'; + +/** + * Maps errors onto HTTP responses, propagating the upstream LLM Gateway + * status (400/401/403/429/5xx) via GatewayRequestError instead of collapsing + * every failure into a single generic status code. + */ +export function replyWithError( + reply: FastifyReply, + err: unknown +): FastifyReply { + if (err instanceof GatewayRequestError) { + return reply.status(err.status).send({ + statusCode: err.status, + error: httpErrorName(err.status), + message: err.message, + code: err.code, + }); + } + + const message = err instanceof Error ? err.message : String(err); + return reply.status(400).send({ + statusCode: 400, + error: 'Bad Request', + message, + }); +} + +function httpErrorName(status: number): string { + switch (status) { + case 400: + return 'Bad Request'; + case 401: + return 'Unauthorized'; + case 403: + return 'Forbidden'; + case 404: + return 'Not Found'; + case 429: + return 'Too Many Requests'; + case 502: + return 'Bad Gateway'; + case 503: + return 'Service Unavailable'; + case 504: + return 'Gateway Timeout'; + default: + return status >= 500 ? 'Internal Server Error' : 'Bad Request'; + } +} diff --git a/services/llm-gateway-service/src/api/routes/generate.ts b/services/llm-gateway-service/src/api/routes/generate.ts new file mode 100644 index 0000000..cb9697f --- /dev/null +++ b/services/llm-gateway-service/src/api/routes/generate.ts @@ -0,0 +1,93 @@ +import type { FastifyPluginAsync } from 'fastify'; +import '@fastify/swagger'; +import { GatewayClient } from '../../core/gateway-client.js'; +import { vercelFormatAdapter } from '../../core/format/vercel.js'; +import { replyWithError } from '../reply-error.js'; +import { + EmbedRequestSchema, + EmbedResponseSchema, + GenerateRequestSchema, + GenerateResponseSchema, + type EmbedRequestType, + type GenerateRequestType, +} from '../schemas/generate.schema.js'; +import { ErrorResponseSchema } from '../schemas/common.schema.js'; +import { config } from '../../config.js'; + +export interface GenerateRoutesOptions { + client?: GatewayClient; +} + +const generateRoutes = ( + options: GenerateRoutesOptions = {} +): FastifyPluginAsync => { + const client = options.client ?? new GatewayClient(config.gateway); + + return async (fastify) => { + fastify.post<{ Body: GenerateRequestType }>( + '/generate', + { + schema: { + tags: ['Generation'], + summary: 'Generate text (non-streaming) via LLM Gateway', + description: + 'Accepts a Vercel AI SDK-style chat request and returns the full generated ' + + 'response, including usage, cost, and finish reason. Supports an inline ' + + '`customProvider` override to call an OpenAI-compatible endpoint directly, ' + + 'bypassing the gateway, without requiring prior provider registration.', + body: GenerateRequestSchema, + response: { + 200: GenerateResponseSchema, + 400: ErrorResponseSchema, + 401: ErrorResponseSchema, + 403: ErrorResponseSchema, + 429: ErrorResponseSchema, + 502: ErrorResponseSchema, + }, + }, + }, + async (request, reply) => { + try { + const generateRequest = vercelFormatAdapter.parseRequest( + request.body + ); + const result = await client.generate(generateRequest); + return reply + .status(200) + .send(vercelFormatAdapter.formatResponse(result)); + } catch (err) { + return replyWithError(reply, err); + } + } + ); + + fastify.post<{ Body: EmbedRequestType }>( + '/embeddings', + { + schema: { + tags: ['Generation'], + summary: 'Create embeddings via LLM Gateway', + body: EmbedRequestSchema, + response: { + 200: EmbedResponseSchema, + 400: ErrorResponseSchema, + 401: ErrorResponseSchema, + 403: ErrorResponseSchema, + 429: ErrorResponseSchema, + 502: ErrorResponseSchema, + }, + }, + }, + async (request, reply) => { + try { + const result = await client.embedText(request.body); + return reply.status(200).send(result); + } catch (err) { + return replyWithError(reply, err); + } + } + ); + }; +}; + +export default generateRoutes; diff --git a/services/llm-gateway-service/src/api/routes/health.ts b/services/llm-gateway-service/src/api/routes/health.ts new file mode 100644 index 0000000..351e0a8 --- /dev/null +++ b/services/llm-gateway-service/src/api/routes/health.ts @@ -0,0 +1,22 @@ +import type { FastifyPluginAsync } from 'fastify'; + +const healthRoutes: FastifyPluginAsync = async (fastify) => { + fastify.get( + '/health', + { + schema: { + tags: ['Health'], + summary: 'Liveness probe', + response: { + 200: { + type: 'object', + properties: { status: { type: 'string' } }, + }, + }, + }, + }, + async () => ({ status: 'ok' }) + ); +}; + +export default healthRoutes; diff --git a/services/llm-gateway-service/src/api/routes/providers.ts b/services/llm-gateway-service/src/api/routes/providers.ts new file mode 100644 index 0000000..43923f3 --- /dev/null +++ b/services/llm-gateway-service/src/api/routes/providers.ts @@ -0,0 +1,56 @@ +import type { FastifyPluginAsync } from 'fastify'; +import '@fastify/swagger'; +import { ProviderRegistry } from '../../core/provider-registry.js'; +import { replyWithError } from '../reply-error.js'; +import { + RegisterProviderRequestSchema, + RegisterProviderResponseSchema, + type RegisterProviderRequestType, +} from '../schemas/provider.schema.js'; +import { ErrorResponseSchema } from '../schemas/common.schema.js'; +import { config } from '../../config.js'; + +export interface ProviderRoutesOptions { + registry?: ProviderRegistry; +} + +const providerRoutes = ( + options: ProviderRoutesOptions = {} +): FastifyPluginAsync => { + const registry = options.registry ?? new ProviderRegistry(config.admin); + + return async (fastify) => { + fastify.post<{ Body: RegisterProviderRequestType }>( + '/providers', + { + schema: { + tags: ['Providers'], + summary: + 'Best-effort registration of a custom provider with LLM Gateway', + description: + "Attempts to register a custom OpenAI-compatible provider with the gateway's " + + 'admin API, when LLM_GATEWAY_ADMIN_BASE_URL/LLM_GATEWAY_ADMIN_TOKEN are ' + + 'configured. This is optional: prefer the `customProvider` override on ' + + '`POST /generate` for one-shot workflow calls that do not require prior ' + + 'gateway-side registration.', + body: RegisterProviderRequestSchema, + response: { + 200: RegisterProviderResponseSchema, + 400: ErrorResponseSchema, + 502: ErrorResponseSchema, + }, + }, + }, + async (request, reply) => { + try { + const result = await registry.register(request.body); + return reply.status(200).send(result); + } catch (err) { + return replyWithError(reply, err); + } + } + ); + }; +}; + +export default providerRoutes; diff --git a/services/llm-gateway-service/src/api/schemas/common.schema.ts b/services/llm-gateway-service/src/api/schemas/common.schema.ts new file mode 100644 index 0000000..c1976f1 --- /dev/null +++ b/services/llm-gateway-service/src/api/schemas/common.schema.ts @@ -0,0 +1,44 @@ +import { Type } from '@sinclair/typebox'; + +export const ErrorResponseSchema = Type.Object( + { + statusCode: Type.Number({ description: 'HTTP status code.' }), + error: Type.String({ description: 'HTTP error name.' }), + message: Type.String({ description: 'Human-readable error message.' }), + code: Type.Optional( + Type.String({ description: 'Upstream error code, when available.' }) + ), + }, + { + $id: 'ErrorResponse', + title: 'ErrorResponse', + description: + 'Error response returned when a request is invalid or the LLM Gateway request fails.', + } +); + +export const CustomProviderOverrideSchema = Type.Object( + { + baseUrl: Type.String({ + minLength: 1, + description: + 'OpenAI-compatible base URL (including any version path, e.g. ' + + '"https://api.mycompany.com/v1") to call directly, bypassing LLM Gateway ' + + 'routing for this request. "/chat/completions" or "/embeddings" is appended ' + + 'automatically. Must not resolve to a loopback, link-local, or private address.', + }), + apiKey: Type.String({ + minLength: 1, + description: 'API key/token for the custom provider endpoint.', + }), + }, + { + $id: 'CustomProviderOverride', + title: 'CustomProviderOverride', + description: + 'Inline connection details for an OpenAI-compatible endpoint that has not been ' + + 'pre-registered with the gateway. When present, this single request is sent directly ' + + 'to the given endpoint instead of being routed through LLM Gateway, so a workflow can ' + + 'deploy, configure, and call a brand-new custom provider in one step.', + } +); diff --git a/services/llm-gateway-service/src/api/schemas/generate.schema.ts b/services/llm-gateway-service/src/api/schemas/generate.schema.ts new file mode 100644 index 0000000..464822b --- /dev/null +++ b/services/llm-gateway-service/src/api/schemas/generate.schema.ts @@ -0,0 +1,146 @@ +import { Type, Static } from '@sinclair/typebox'; +import { CustomProviderOverrideSchema } from './common.schema.js'; + +export const ContentPartSchema = Type.Object( + { + type: Type.Union([Type.Literal('text'), Type.Literal('image')]), + text: Type.Optional(Type.String()), + image: Type.Optional( + Type.String({ description: 'Data URL, http(s) URL, or base64 string.' }) + ), + mediaType: Type.Optional(Type.String()), + }, + { $id: 'ContentPart', title: 'ContentPart' } +); + +export const MessageSchema = Type.Object( + { + role: Type.Union([ + Type.Literal('system'), + Type.Literal('user'), + Type.Literal('assistant'), + Type.Literal('tool'), + ]), + content: Type.Union([Type.String(), Type.Array(ContentPartSchema)]), + toolCallId: Type.Optional(Type.String()), + toolName: Type.Optional(Type.String()), + }, + { + $id: 'Message', + title: 'Message', + description: 'A Vercel AI SDK-style chat message.', + } +); + +export const ToolDefinitionSchema = Type.Object( + { + description: Type.Optional(Type.String()), + inputSchema: Type.Record(Type.String(), Type.Unknown(), { + description: "JSON Schema describing the tool's parameters.", + }), + }, + { $id: 'ToolDefinition', title: 'ToolDefinition' } +); + +export const GenerateRequestSchema = Type.Object( + { + provider: Type.String({ + description: + 'Provider id, e.g. "openai", "anthropic", or a custom provider name registered with the gateway.', + examples: ['openai'], + }), + model: Type.String({ + description: 'Model id within the provider.', + examples: ['gpt-4o'], + }), + messages: Type.Array(MessageSchema, { minItems: 1 }), + system: Type.Optional( + Type.String({ description: 'System / developer instructions.' }) + ), + tools: Type.Optional(Type.Record(Type.String(), ToolDefinitionSchema)), + temperature: Type.Optional(Type.Number({ minimum: 0, maximum: 2 })), + maxOutputTokens: Type.Optional(Type.Integer({ minimum: 1 })), + topP: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })), + stopSequences: Type.Optional(Type.Array(Type.String())), + metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown())), + customProvider: Type.Optional(CustomProviderOverrideSchema), + }, + { + $id: 'GenerateRequest', + title: 'GenerateRequest', + description: + 'Non-streaming text generation request, in Vercel AI SDK message format.', + additionalProperties: false, + } +); + +export type GenerateRequestType = Static; + +export const ToolCallSchema = Type.Object( + { + toolCallId: Type.String(), + toolName: Type.String(), + input: Type.Unknown(), + }, + { $id: 'ToolCall', title: 'ToolCall' } +); + +export const TokenUsageSchema = Type.Object( + { + inputTokens: Type.Optional(Type.Number()), + outputTokens: Type.Optional(Type.Number()), + totalTokens: Type.Optional(Type.Number()), + }, + { $id: 'TokenUsage', title: 'TokenUsage' } +); + +export const GenerateResponseSchema = Type.Object( + { + text: Type.String(), + finishReason: Type.String(), + usage: TokenUsageSchema, + cost: Type.Optional( + Type.Number({ + description: 'USD cost reported by LLM Gateway, when available.', + }) + ), + provider: Type.String(), + model: Type.String(), + toolCalls: Type.Optional(Type.Array(ToolCallSchema)), + raw: Type.Optional( + Type.Unknown({ + description: 'Raw gateway/provider response, for debugging.', + }) + ), + viaGatewayBypass: Type.Optional( + Type.Boolean({ + description: + 'True when this request bypassed the gateway via `customProvider`.', + }) + ), + }, + { $id: 'GenerateResponse', title: 'GenerateResponse' } +); + +export const EmbedRequestSchema = Type.Object( + { + provider: Type.String(), + model: Type.String(), + input: Type.Union([Type.String(), Type.Array(Type.String())]), + customProvider: Type.Optional(CustomProviderOverrideSchema), + }, + { $id: 'EmbedRequest', title: 'EmbedRequest', additionalProperties: false } +); + +export type EmbedRequestType = Static; + +export const EmbedResponseSchema = Type.Object( + { + embeddings: Type.Array(Type.Array(Type.Number())), + usage: TokenUsageSchema, + provider: Type.String(), + model: Type.String(), + raw: Type.Optional(Type.Unknown()), + }, + { $id: 'EmbedResponse', title: 'EmbedResponse' } +); diff --git a/services/llm-gateway-service/src/api/schemas/provider.schema.ts b/services/llm-gateway-service/src/api/schemas/provider.schema.ts new file mode 100644 index 0000000..4f49bb2 --- /dev/null +++ b/services/llm-gateway-service/src/api/schemas/provider.schema.ts @@ -0,0 +1,40 @@ +import { Type, Static } from '@sinclair/typebox'; + +export const RegisterProviderRequestSchema = Type.Object( + { + name: Type.String({ + description: + 'Lowercase provider name, matching /^[a-z]+(-[a-z]+)*$/ (e.g. "mycompany", "eu-west").', + pattern: '^[a-z]+(-[a-z]+)*$', + }), + baseUrl: Type.String({ + minLength: 1, + description: 'OpenAI-compatible base URL for the provider.', + }), + apiKey: Type.String({ + minLength: 1, + description: "The provider's API token.", + }), + }, + { + $id: 'RegisterProviderRequest', + title: 'RegisterProviderRequest', + additionalProperties: false, + } +); + +export type RegisterProviderRequestType = Static< + typeof RegisterProviderRequestSchema +>; + +export const RegisterProviderResponseSchema = Type.Object( + { + registered: Type.Boolean(), + mode: Type.Union([ + Type.Literal('gateway-admin-api'), + Type.Literal('skipped'), + ]), + message: Type.String(), + }, + { $id: 'RegisterProviderResponse', title: 'RegisterProviderResponse' } +); diff --git a/services/llm-gateway-service/src/api/server.ts b/services/llm-gateway-service/src/api/server.ts new file mode 100644 index 0000000..ed8c89f --- /dev/null +++ b/services/llm-gateway-service/src/api/server.ts @@ -0,0 +1,109 @@ +import Fastify from 'fastify'; +import fastifySwagger from '@fastify/swagger'; +import fastifySwaggerUi from '@fastify/swagger-ui'; +import healthRoutes from './routes/health.js'; +import generateRoutes, { + type GenerateRoutesOptions, +} from './routes/generate.js'; +import providerRoutes, { + type ProviderRoutesOptions, +} from './routes/providers.js'; +import { + ContentPartSchema, + EmbedRequestSchema, + EmbedResponseSchema, + GenerateRequestSchema, + GenerateResponseSchema, + MessageSchema, + TokenUsageSchema, + ToolCallSchema, + ToolDefinitionSchema, +} from './schemas/generate.schema.js'; +import { + RegisterProviderRequestSchema, + RegisterProviderResponseSchema, +} from './schemas/provider.schema.js'; +import { + CustomProviderOverrideSchema, + ErrorResponseSchema, +} from './schemas/common.schema.js'; + +// Read version from package.json — tsx supports JSON imports +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); +const pkg = require('../../package.json') as { + version: string; + description: string; +}; + +export interface BuildServerOptions { + generate?: GenerateRoutesOptions; + providers?: ProviderRoutesOptions; +} + +/** + * Creates and configures the Fastify server instance. + * + * Plugins registered: + * - @fastify/swagger → generates OpenAPI 3.0 spec at GET /docs/json + * - @fastify/swagger-ui → serves Swagger UI at GET /docs + * - healthRoutes → GET /health + * - generateRoutes → POST /generate, POST /embeddings + * - providerRoutes → POST /providers + */ +export async function buildServer(options: BuildServerOptions = {}) { + const fastify = Fastify({ logger: true }); + + await fastify.register(fastifySwagger, { + openapi: { + openapi: '3.0.3', + info: { + title: 'LLM Gateway Service', + description: + pkg.description + + '\n\n' + + 'This service wraps a self-hosted [LLM Gateway](https://docs.llmgateway.io/) ' + + 'instance using the Vercel AI SDK, exposing non-streaming text generation, ' + + 'embeddings, and best-effort custom provider registration.', + version: pkg.version, + license: { name: 'MIT' }, + }, + tags: [ + { + name: 'Generation', + description: 'Text generation and embeddings via LLM Gateway.', + }, + { name: 'Providers', description: 'Custom provider registration.' }, + { name: 'Health', description: 'Liveness probe.' }, + ], + components: { + schemas: { + ContentPart: ContentPartSchema, + Message: MessageSchema, + ToolDefinition: ToolDefinitionSchema, + ToolCall: ToolCallSchema, + TokenUsage: TokenUsageSchema, + CustomProviderOverride: CustomProviderOverrideSchema, + GenerateRequest: GenerateRequestSchema, + GenerateResponse: GenerateResponseSchema, + EmbedRequest: EmbedRequestSchema, + EmbedResponse: EmbedResponseSchema, + RegisterProviderRequest: RegisterProviderRequestSchema, + RegisterProviderResponse: RegisterProviderResponseSchema, + ErrorResponse: ErrorResponseSchema, + }, + }, + }, + }); + + await fastify.register(fastifySwaggerUi, { + routePrefix: '/docs', + uiConfig: { docExpansion: 'full', deepLinking: true }, + }); + + await fastify.register(healthRoutes); + await fastify.register(generateRoutes(options.generate)); + await fastify.register(providerRoutes(options.providers)); + + return fastify; +} diff --git a/services/llm-gateway-service/src/cli/commands/generate.ts b/services/llm-gateway-service/src/cli/commands/generate.ts new file mode 100644 index 0000000..67f6bcf --- /dev/null +++ b/services/llm-gateway-service/src/cli/commands/generate.ts @@ -0,0 +1,30 @@ +import { GatewayClient } from '../../core/gateway-client.js'; +import { + vercelFormatAdapter, + type VercelGenerateRequest, +} from '../../core/format/vercel.js'; +import { readJsonInput, writeJsonOutput } from '../io.js'; +import { config } from '../../config.js'; + +export interface GenerateCommandOptions { + output?: string; +} + +/** + * `llm-gateway generate [-o output-file]` + * + * Reads a Vercel AI SDK-format request as JSON (from a file or stdin), + * calls LLM Gateway (or a directly-specified custom provider via the + * `customProvider` field), and writes the full JSON response to stdout or a + * file. This is the primary integration point for workflows. + */ +export async function generateCommand( + inputPath: string | undefined, + options: GenerateCommandOptions, + client: GatewayClient = new GatewayClient(config.gateway) +): Promise { + const input = readJsonInput(inputPath) as VercelGenerateRequest; + const request = vercelFormatAdapter.parseRequest(input); + const result = await client.generate(request); + writeJsonOutput(vercelFormatAdapter.formatResponse(result), options.output); +} diff --git a/services/llm-gateway-service/src/cli/commands/health.ts b/services/llm-gateway-service/src/cli/commands/health.ts new file mode 100644 index 0000000..3962ec4 --- /dev/null +++ b/services/llm-gateway-service/src/cli/commands/health.ts @@ -0,0 +1,5 @@ +import { writeJsonOutput } from '../io.js'; + +export function healthCommand(options: { output?: string } = {}): void { + writeJsonOutput({ status: 'ok' }, options.output); +} diff --git a/services/llm-gateway-service/src/cli/commands/register-provider.ts b/services/llm-gateway-service/src/cli/commands/register-provider.ts new file mode 100644 index 0000000..5c7977f --- /dev/null +++ b/services/llm-gateway-service/src/cli/commands/register-provider.ts @@ -0,0 +1,34 @@ +import { + ProviderRegistry, + type RegisterProviderInput, +} from '../../core/provider-registry.js'; +import { writeJsonOutput } from '../io.js'; +import { config } from '../../config.js'; + +export interface RegisterProviderCommandOptions { + name: string; + baseUrl: string; + apiKey: string; + output?: string; +} + +/** + * `llm-gateway register-provider --name --base-url --api-key ` + * + * Best-effort: see core/provider-registry.ts for why this cannot reliably + * register a provider with a self-hosted gateway without dashboard/session + * auth or an Enterprise master key. Prefer the `customProvider` inline + * override on `generate` for one-shot workflow calls. + */ +export async function registerProviderCommand( + options: RegisterProviderCommandOptions, + registry: ProviderRegistry = new ProviderRegistry(config.admin) +): Promise { + const input: RegisterProviderInput = { + name: options.name, + baseUrl: options.baseUrl, + apiKey: options.apiKey, + }; + const result = await registry.register(input); + writeJsonOutput(result, options.output); +} diff --git a/services/llm-gateway-service/src/cli/index.ts b/services/llm-gateway-service/src/cli/index.ts new file mode 100644 index 0000000..335fbca --- /dev/null +++ b/services/llm-gateway-service/src/cli/index.ts @@ -0,0 +1,72 @@ +#!/usr/bin/env node +import { Command } from 'commander'; +import { generateCommand } from './commands/generate.js'; +import { registerProviderCommand } from './commands/register-provider.js'; +import { healthCommand } from './commands/health.js'; +import { GatewayRequestError } from '../core/types.js'; + +const program = new Command(); + +program + .name('llm-gateway') + .description( + 'CLI wrapper around a self-hosted LLM Gateway instance, using the Vercel AI SDK.' + ); + +program + .command('health') + .description('Print the service health status.') + .option('-o, --output ', 'Write output to a file instead of stdout.') + .action((options) => healthCommand(options)); + +program + .command('generate') + .description( + 'Generate text (non-streaming) via LLM Gateway. Reads a Vercel AI SDK-format ' + + 'request as JSON from a file or stdin, writes the full response as JSON.' + ) + .argument( + '[input-file]', + 'Path to a JSON request file, or "-"/omitted to read from stdin.' + ) + .option('-o, --output ', 'Write output to a file instead of stdout.') + .action(async (inputFile: string | undefined, options) => { + await generateCommand(inputFile, options); + }); + +program + .command('register-provider') + .description( + 'Best-effort registration of a custom OpenAI-compatible provider with LLM Gateway. ' + + 'Prefer the `customProvider` field on `generate` for one-shot calls that should not ' + + 'depend on prior gateway-side registration.' + ) + .requiredOption( + '--name ', + 'Lowercase provider name (e.g. "mycompany").' + ) + .requiredOption( + '--base-url ', + 'OpenAI-compatible base URL for the provider.' + ) + .requiredOption('--api-key ', "The provider's API token.") + .option('-o, --output ', 'Write output to a file instead of stdout.') + .action(async (options) => { + await registerProviderCommand(options); + }); + +async function main() { + try { + await program.parseAsync(process.argv); + } catch (err) { + if (err instanceof GatewayRequestError) { + process.stderr.write(`Error (${err.status}): ${err.message}\n`); + } else { + const message = err instanceof Error ? err.message : String(err); + process.stderr.write(`Error: ${message}\n`); + } + process.exitCode = 1; + } +} + +main(); diff --git a/services/llm-gateway-service/src/cli/io.ts b/services/llm-gateway-service/src/cli/io.ts new file mode 100644 index 0000000..b1e2b43 --- /dev/null +++ b/services/llm-gateway-service/src/cli/io.ts @@ -0,0 +1,45 @@ +import { readFileSync, writeFileSync } from 'fs'; + +/** + * Reads a JSON payload from a file path, or from stdin when the path is "-" + * or omitted. This is the primary input mechanism for the CLI, since the + * service is mainly invoked from automated workflows. + */ +export function readJsonInput(path: string | undefined): unknown { + const raw = readRawInput(path); + try { + return JSON.parse(raw); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to parse JSON input: ${message}`); + } +} + +function readRawInput(path: string | undefined): string { + if (!path || path === '-') { + return readStdin(); + } + return readFileSync(path, 'utf-8'); +} + +function readStdin(): string { + try { + return readFileSync(0, 'utf-8'); + } catch { + throw new Error( + 'No input file given and stdin is empty. Pass a JSON file path or pipe JSON via stdin.' + ); + } +} + +export function writeJsonOutput( + payload: unknown, + outputPath: string | undefined +): void { + const serialized = JSON.stringify(payload, null, 2); + if (!outputPath) { + process.stdout.write(serialized + '\n'); + return; + } + writeFileSync(outputPath, serialized + '\n'); +} diff --git a/services/llm-gateway-service/src/config.ts b/services/llm-gateway-service/src/config.ts new file mode 100644 index 0000000..cccfe60 --- /dev/null +++ b/services/llm-gateway-service/src/config.ts @@ -0,0 +1,60 @@ +/** + * Environment-driven configuration for connecting to a self-hosted LLM Gateway + * instance, plus optional (best-effort) admin API settings used to register + * custom providers ahead of time. + */ + +function envValue(name: string): string | undefined { + const value = process.env[name]; + return value && value.length > 0 ? value : undefined; +} + +export interface GatewayConfig { + /** Base URL of the self-hosted LLM Gateway's OpenAI-compatible API, e.g. http://gateway:3002/v1 */ + baseUrl: string; + /** Bearer token used to authenticate inference requests against the gateway. */ + apiKey: string; + /** Request timeout (ms) applied to gateway and custom-provider HTTP calls. */ + timeoutMs: number; +} + +export interface AdminConfig { + /** + * Base URL of the LLM Gateway *management* API (apps/api), used only for the + * best-effort `register-provider` command. This is distinct from the + * inference `baseUrl` above. + */ + baseUrl?: string; + /** + * Bearer token for the management API (e.g. an Enterprise "master key", + * `llmgmk_...`). Without this, programmatic provider registration is + * skipped and callers should rely on the `customProvider` request + * override instead (see core/provider-registry.ts). + */ + token?: string; +} + +export interface AppConfig { + gateway: GatewayConfig; + admin: AdminConfig; + port: number; + host: string; +} + +export function loadConfig(): AppConfig { + return { + gateway: { + baseUrl: envValue('LLM_GATEWAY_BASE_URL') ?? 'http://localhost:4001/v1', + apiKey: envValue('LLM_GATEWAY_API_KEY') ?? '', + timeoutMs: parseInt(envValue('LLM_GATEWAY_TIMEOUT_MS') ?? '60000', 10), + }, + admin: { + baseUrl: envValue('LLM_GATEWAY_ADMIN_BASE_URL'), + token: envValue('LLM_GATEWAY_ADMIN_TOKEN'), + }, + port: parseInt(envValue('PORT') ?? '8002', 10), + host: envValue('HOST') ?? '0.0.0.0', + }; +} + +export const config = loadConfig(); diff --git a/services/llm-gateway-service/src/core/format/types.ts b/services/llm-gateway-service/src/core/format/types.ts new file mode 100644 index 0000000..5d1b394 --- /dev/null +++ b/services/llm-gateway-service/src/core/format/types.ts @@ -0,0 +1,11 @@ +/** + * Interface implemented by each wire-format adapter (Vercel today; OpenAI / + * Anthropic could be added later) so the HTTP/CLI layers and the gateway + * client never need to know which wire format is in use. + */ +import type { GenerateRequest, GenerateResponse } from '../types.js'; + +export interface FormatAdapter { + parseRequest(input: TRequest): GenerateRequest; + formatResponse(response: GenerateResponse): TResponse; +} diff --git a/services/llm-gateway-service/src/core/format/vercel.ts b/services/llm-gateway-service/src/core/format/vercel.ts new file mode 100644 index 0000000..fae0497 --- /dev/null +++ b/services/llm-gateway-service/src/core/format/vercel.ts @@ -0,0 +1,192 @@ +/** + * Vercel AI SDK format adapter. + * + * Translates between the wire format used by Vercel AI SDK frontends + * (`useChat`/`generateText`-style messages and results) and this service's + * internal, format-agnostic `GenerateRequest`/`GenerateResponse` types. + * + * This is the only format implemented today; the adapter interface in + * `./types.ts` exists so OpenAI- or Anthropic-shaped adapters can be added + * later without touching the gateway client or provider registry. + */ +import type { + ChatMessage, + ContentPart, + CustomProviderOverride, + GenerateRequest, + GenerateResponse, + ToolDefinition, +} from '../types.js'; +import type { FormatAdapter } from './types.js'; +import { assertSafeCustomProviderBaseUrl } from '../url-safety.js'; + +export interface VercelContentPart { + type: 'text' | 'image'; + text?: string; + image?: string; + mediaType?: string; +} + +export interface VercelMessage { + role: 'system' | 'user' | 'assistant' | 'tool'; + content: string | VercelContentPart[]; + toolCallId?: string; + toolName?: string; +} + +export interface VercelToolDefinition { + description?: string; + /** JSON Schema for the tool's parameters, matching the AI SDK's `inputSchema`. */ + inputSchema: Record; +} + +export interface VercelGenerateRequest { + provider: string; + model: string; + messages: VercelMessage[]; + system?: string; + tools?: Record; + temperature?: number; + maxOutputTokens?: number; + topP?: number; + stopSequences?: string[]; + metadata?: Record; + /** + * Inline connection details for an OpenAI-compatible endpoint that has not + * been pre-registered with the gateway. Enables "deploy, configure, and + * call a custom provider" in a single request. + */ + customProvider?: CustomProviderOverride; +} + +export interface VercelToolCall { + toolCallId: string; + toolName: string; + input: unknown; +} + +export interface VercelGenerateResponse { + text: string; + finishReason: string; + usage: { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + }; + cost?: number; + provider: string; + model: string; + toolCalls?: VercelToolCall[]; + raw?: unknown; + viaGatewayBypass?: boolean; +} + +export class VercelFormatAdapter implements FormatAdapter< + VercelGenerateRequest, + VercelGenerateResponse +> { + parseRequest(input: VercelGenerateRequest): GenerateRequest { + if (!input || typeof input !== 'object') { + throw new Error('Request body must be a JSON object.'); + } + if (!input.provider || typeof input.provider !== 'string') { + throw new Error('"provider" is required and must be a string.'); + } + if (!input.model || typeof input.model !== 'string') { + throw new Error('"model" is required and must be a string.'); + } + if (!Array.isArray(input.messages) || input.messages.length === 0) { + throw new Error('"messages" is required and must be a non-empty array.'); + } + if (input.customProvider !== undefined) { + validateCustomProviderOverride(input.customProvider); + } + + return { + provider: input.provider, + model: input.model, + messages: input.messages.map(toChatMessage), + system: input.system, + tools: input.tools ? toToolDefinitions(input.tools) : undefined, + temperature: input.temperature, + maxOutputTokens: input.maxOutputTokens, + topP: input.topP, + stopSequences: input.stopSequences, + metadata: input.metadata, + customProvider: input.customProvider, + }; + } + + formatResponse(response: GenerateResponse): VercelGenerateResponse { + return { + text: response.text, + finishReason: response.finishReason, + usage: response.usage, + cost: response.cost, + provider: response.provider, + model: response.model, + toolCalls: response.toolCalls?.map((call) => ({ + toolCallId: call.toolCallId, + toolName: call.toolName, + input: call.args, + })), + raw: response.raw, + viaGatewayBypass: response.viaGatewayBypass, + }; + } +} + +function validateCustomProviderOverride( + customProvider: CustomProviderOverride +): void { + if (!customProvider || typeof customProvider !== 'object') { + throw new Error( + '"customProvider" must be an object with "baseUrl" and "apiKey".' + ); + } + if (!customProvider.baseUrl || typeof customProvider.baseUrl !== 'string') { + throw new Error( + '"customProvider.baseUrl" is required and must be a non-empty string.' + ); + } + if (!customProvider.apiKey || typeof customProvider.apiKey !== 'string') { + throw new Error( + '"customProvider.apiKey" is required and must be a non-empty string.' + ); + } + assertSafeCustomProviderBaseUrl(customProvider.baseUrl); +} + +function toChatMessage(message: VercelMessage): ChatMessage { + return { + role: message.role, + content: Array.isArray(message.content) + ? message.content.map(toContentPart) + : message.content, + toolCallId: message.toolCallId, + toolName: message.toolName, + }; +} + +function toContentPart(part: VercelContentPart): ContentPart { + if (part.type === 'image') { + return { + type: 'image', + image: part.image ?? '', + mediaType: part.mediaType, + }; + } + return { type: 'text', text: part.text ?? '' }; +} + +function toToolDefinitions( + tools: Record +): ToolDefinition[] { + return Object.entries(tools).map(([name, definition]) => ({ + name, + description: definition.description, + parameters: definition.inputSchema, + })); +} + +export const vercelFormatAdapter = new VercelFormatAdapter(); diff --git a/services/llm-gateway-service/src/core/gateway-client.ts b/services/llm-gateway-service/src/core/gateway-client.ts new file mode 100644 index 0000000..cecebd9 --- /dev/null +++ b/services/llm-gateway-service/src/core/gateway-client.ts @@ -0,0 +1,462 @@ +import { + createLLMGateway, + type LLMGatewayProvider, +} from '@llmgateway/ai-sdk-provider'; +import { generateText, tool, jsonSchema, type ModelMessage } from 'ai'; +import type { GatewayConfig } from '../config.js'; +import { + GatewayRequestError, + type ChatMessage, + type ContentPart, + type EmbedRequest, + type EmbedResponse, + type FinishReason, + type GenerateRequest, + type GenerateResponse, + type TokenUsage, + type ToolCall, + type ToolDefinition, +} from './types.js'; + +/** + * Talks to a self-hosted LLM Gateway instance via the Vercel AI SDK + + * `@llmgateway/ai-sdk-provider` for the normal (gateway-routed) path. + * + * Custom provider handling: LLM Gateway custom providers are normally + * registered ahead of time via the dashboard/admin API (see + * `provider-registry.ts`). Since a general-purpose, bearer-token + * authenticated admin endpoint for *creating* custom provider keys is not + * reliably available (it is dashboard/session-auth gated, and LLM Gateway's + * programmatic "Master Keys" API — Enterprise only — does not cover provider + * key management either), this client also supports an inline + * `customProvider` override on a per-request basis: when present, the + * request bypasses the gateway entirely and is sent directly to the given + * OpenAI-compatible endpoint (`/chat/completions` or `/embeddings`), via a + * plain `fetch` call so the bypass path doesn't depend on an AI SDK provider + * package version tied to the gateway's own AI SDK major version. This is + * what allows a workflow to deploy, configure, and call a brand-new custom + * provider in a single step without any prior gateway registration. + * + * `customProvider.baseUrl` is expected to have already been validated by the + * caller (see `core/url-safety.ts` and `core/format/vercel.ts::parseRequest`, + * which run at the untrusted HTTP/CLI input boundary) — this class itself + * does not re-validate it, so it remains usable in trusted/internal contexts + * (tests, programmatic callers) that intentionally target loopback or + * private addresses. + */ +export class GatewayClient { + private readonly gateway: LLMGatewayProvider; + + constructor(private readonly gatewayConfig: GatewayConfig) { + this.gateway = createLLMGateway({ + baseURL: gatewayConfig.baseUrl, + apiKey: gatewayConfig.apiKey, + }); + } + + async generate(request: GenerateRequest): Promise { + if (request.customProvider) { + return this.generateViaCustomProvider(request); + } + return this.generateViaGateway(request); + } + + async embedText(request: EmbedRequest): Promise { + if (request.customProvider) { + return this.embedViaCustomProvider(request); + } + return this.embedViaGateway(request); + } + + private async generateViaGateway( + request: GenerateRequest + ): Promise { + this.requireGatewayApiKey(); + + try { + const result = await generateText({ + // `usage.include` asks LLM Gateway to attach cost/usage-accounting + // details under `providerMetadata.llmgateway.usage`, which we + // surface on the response as `cost` (see extractCost below). + model: this.gateway(`${request.provider}/${request.model}`, { + usage: { include: true }, + }), + system: request.system, + messages: toModelMessages(request.messages), + tools: toToolSet(request.tools), + temperature: request.temperature, + maxOutputTokens: request.maxOutputTokens, + topP: request.topP, + stopSequences: request.stopSequences, + maxRetries: 1, + abortSignal: AbortSignal.timeout(this.gatewayConfig.timeoutMs), + }); + + return { + text: result.text, + finishReason: normalizeFinishReason(result.finishReason), + usage: { + inputTokens: result.usage?.inputTokens ?? undefined, + outputTokens: result.usage?.outputTokens ?? undefined, + totalTokens: result.usage?.totalTokens ?? undefined, + }, + cost: extractCost(result.providerMetadata), + provider: request.provider, + model: request.model, + toolCalls: toToolCalls(result.toolCalls), + raw: result, + viaGatewayBypass: false, + }; + } catch (err) { + throw toGatewayRequestError(err); + } + } + + private async generateViaCustomProvider( + request: GenerateRequest + ): Promise { + const cp = request.customProvider!; + const body = await postOpenAICompatible( + `${normalizeBaseUrl(cp.baseUrl)}/chat/completions`, + cp.apiKey, + { + model: request.model, + messages: toOpenAIChatMessages(request.system, request.messages), + tools: toOpenAITools(request.tools), + temperature: request.temperature, + max_tokens: request.maxOutputTokens, + top_p: request.topP, + stop: request.stopSequences, + }, + this.gatewayConfig.timeoutMs + ); + + const choice = body?.choices?.[0]; + if (!choice) { + throw new GatewayRequestError( + 'Custom provider response did not include any choices.', + 502, + undefined, + body + ); + } + + return { + text: choice.message?.content ?? '', + finishReason: mapOpenAIFinishReason(choice.finish_reason), + usage: toChatTokenUsage(body?.usage), + provider: request.provider, + model: request.model, + toolCalls: toToolCallsFromOpenAI(choice.message?.tool_calls), + raw: body, + viaGatewayBypass: true, + }; + } + + /** + * `@llmgateway/ai-sdk-provider` does not expose an embedding model + * abstraction, even though the gateway itself has a `/v1/embeddings` + * REST endpoint (mirroring OpenAI's embeddings API). We call it directly, + * via the same helper used for the custom-provider bypass so both paths + * compute usage identically. + */ + private async embedViaGateway(request: EmbedRequest): Promise { + this.requireGatewayApiKey(); + + const body = await postOpenAICompatible( + `${normalizeBaseUrl(this.gatewayConfig.baseUrl)}/embeddings`, + this.gatewayConfig.apiKey, + { model: `${request.provider}/${request.model}`, input: request.input }, + this.gatewayConfig.timeoutMs + ); + + return { + embeddings: (body?.data ?? []).map((item: any) => item.embedding), + usage: toEmbeddingTokenUsage(body?.usage), + provider: request.provider, + model: request.model, + raw: body, + }; + } + + private async embedViaCustomProvider( + request: EmbedRequest + ): Promise { + const cp = request.customProvider!; + const body = await postOpenAICompatible( + `${normalizeBaseUrl(cp.baseUrl)}/embeddings`, + cp.apiKey, + { model: request.model, input: request.input }, + this.gatewayConfig.timeoutMs + ); + + return { + embeddings: (body?.data ?? []).map((item: any) => item.embedding), + usage: toEmbeddingTokenUsage(body?.usage), + provider: request.provider, + model: request.model, + raw: body, + }; + } + + private requireGatewayApiKey(): void { + if (!this.gatewayConfig.apiKey) { + throw new GatewayRequestError( + 'LLM_GATEWAY_API_KEY is not configured. Set it to call the gateway, or pass a ' + + '"customProvider" override to call an OpenAI-compatible endpoint directly.', + 500 + ); + } + } +} + +function normalizeBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/$/, ''); +} + +/** + * Shared "call an OpenAI-compatible REST endpoint" helper used by both the + * gateway path (embeddings) and the custom-provider bypass path (chat + + * embeddings), so error handling, timeouts, and response parsing can't drift + * between the two. + */ +async function postOpenAICompatible( + url: string, + apiKey: string, + body: unknown, + timeoutMs: number +): Promise { + let response: Response; + try { + response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (err) { + throw toGatewayRequestError(err); + } + + const json = await response.json().catch(() => undefined); + if (!response.ok) { + throw new GatewayRequestError( + json?.error?.message ?? + `Request to ${url} failed with status ${response.status}`, + response.status, + json?.error?.code, + json + ); + } + return json; +} + +function toModelMessages(messages: ChatMessage[]): ModelMessage[] { + return messages.map((message) => ({ + role: message.role, + content: message.content, + ...(message.toolCallId ? { toolCallId: message.toolCallId } : {}), + ...(message.toolName ? { toolName: message.toolName } : {}), + })) as ModelMessage[]; +} + +function toToolSet(tools: ToolDefinition[] | undefined) { + if (!tools || tools.length === 0) { + return undefined; + } + + const toolSet: Record> = {}; + for (const definition of tools) { + toolSet[definition.name] = tool({ + description: definition.description, + inputSchema: jsonSchema(definition.parameters as any), + // No `execute`: tool calls are returned to the caller (e.g. the + // Vercel AI SDK frontend) to execute themselves, rather than being + // invoked by this service. + }); + } + return toolSet; +} + +function toToolCalls(toolCalls: unknown): ToolCall[] | undefined { + if (!Array.isArray(toolCalls) || toolCalls.length === 0) { + return undefined; + } + return toolCalls.map((call: any) => ({ + toolCallId: call.toolCallId, + toolName: call.toolName, + args: call.input ?? call.args, + })); +} + +function normalizeFinishReason(reason: unknown): FinishReason { + const known: FinishReason[] = [ + 'stop', + 'length', + 'content-filter', + 'tool-calls', + 'error', + 'other', + 'unknown', + ]; + return known.includes(reason as FinishReason) + ? (reason as FinishReason) + : 'unknown'; +} + +/** + * Extracts the per-request cost reported by LLM Gateway's usage-accounting + * extension, when present in the AI SDK result's provider metadata. + */ +function extractCost(providerMetadata: unknown): number | undefined { + if (!providerMetadata || typeof providerMetadata !== 'object') { + return undefined; + } + const meta = providerMetadata as Record; + const llmgateway = meta.llmgateway ?? meta.openai ?? undefined; + const cost = llmgateway?.usage?.cost ?? llmgateway?.cost; + return typeof cost === 'number' ? cost : undefined; +} + +// --------------------------------------------------------------------------- +// OpenAI chat-completions request/response mapping, used only for the +// customProvider bypass path (raw fetch, no AI SDK model wrapper involved). +// --------------------------------------------------------------------------- + +function toOpenAIChatMessages( + system: string | undefined, + messages: ChatMessage[] +): unknown[] { + const result: unknown[] = []; + if (system) { + result.push({ role: 'system', content: system }); + } + for (const message of messages) { + result.push({ + role: message.role, + content: Array.isArray(message.content) + ? message.content.map(toOpenAIContentPart) + : message.content, + ...(message.toolCallId ? { tool_call_id: message.toolCallId } : {}), + }); + } + return result; +} + +function toOpenAIContentPart(part: ContentPart): unknown { + if (part.type === 'image') { + return { type: 'image_url', image_url: { url: part.image } }; + } + return { type: 'text', text: part.text }; +} + +function toOpenAITools( + tools: ToolDefinition[] | undefined +): unknown[] | undefined { + if (!tools || tools.length === 0) { + return undefined; + } + return tools.map((definition) => ({ + type: 'function', + function: { + name: definition.name, + description: definition.description, + parameters: definition.parameters, + }, + })); +} + +function toToolCallsFromOpenAI(toolCalls: unknown): ToolCall[] | undefined { + if (!Array.isArray(toolCalls) || toolCalls.length === 0) { + return undefined; + } + return toolCalls.map((call: any) => ({ + toolCallId: call.id, + toolName: call.function?.name, + args: parseToolArguments(call.function?.arguments), + })); +} + +function parseToolArguments(rawArguments: unknown): unknown { + if (typeof rawArguments !== 'string') { + return rawArguments; + } + try { + return JSON.parse(rawArguments); + } catch { + return rawArguments; + } +} + +function mapOpenAIFinishReason(reason: unknown): FinishReason { + switch (reason) { + case 'stop': + return 'stop'; + case 'length': + return 'length'; + case 'content_filter': + return 'content-filter'; + case 'tool_calls': + case 'function_call': + return 'tool-calls'; + default: + return typeof reason === 'string' ? 'other' : 'unknown'; + } +} + +function toChatTokenUsage(usage: any): TokenUsage { + return { + inputTokens: usage?.prompt_tokens ?? undefined, + outputTokens: usage?.completion_tokens ?? undefined, + totalTokens: usage?.total_tokens ?? undefined, + }; +} + +function toEmbeddingTokenUsage(usage: any): TokenUsage { + // Embeddings requests have no output tokens, so total == input. + const inputTokens = usage?.prompt_tokens ?? undefined; + return { + inputTokens, + totalTokens: usage?.total_tokens ?? inputTokens, + }; +} + +/** + * Maps AI SDK / provider errors onto a GatewayRequestError carrying the + * upstream HTTP status, so the API/CLI layer can propagate meaningful status + * codes instead of collapsing every failure into one generic error. + */ +export function toGatewayRequestError(err: unknown): GatewayRequestError { + if (err instanceof GatewayRequestError) { + return err; + } + + // The AI SDK wraps transient failures (including HTTP error responses, + // which it treats as retryable) in an AI_RetryError after exhausting + // retries. The original APICallError — and its real HTTP status — is + // available on `lastError` (and in the `errors` array), not on the + // RetryError itself. + const anyErr = err as any; + const innermost = anyErr?.lastError ?? anyErr; + + const status: number | undefined = + innermost?.statusCode ?? + innermost?.status ?? + innermost?.response?.status ?? + innermost?.cause?.statusCode; + const message: string = + innermost?.message ?? anyErr?.message ?? 'LLM Gateway request failed'; + const code: string | undefined = innermost?.code ?? innermost?.name; + + // Default to 502 (bad gateway) when the upstream didn't provide a status, + // matching "we couldn't successfully reach/interpret the LLM Gateway". + return new GatewayRequestError( + message, + status ?? 502, + code, + innermost?.data ?? innermost?.responseBody + ); +} diff --git a/services/llm-gateway-service/src/core/provider-registry.ts b/services/llm-gateway-service/src/core/provider-registry.ts new file mode 100644 index 0000000..2362c8d --- /dev/null +++ b/services/llm-gateway-service/src/core/provider-registry.ts @@ -0,0 +1,116 @@ +import type { AdminConfig } from '../config.js'; +import { GatewayRequestError } from './types.js'; + +export interface RegisterProviderInput { + /** Lowercase name, must match /^[a-z]+(-[a-z]+)*$/ per LLM Gateway's custom provider naming rules. */ + name: string; + /** OpenAI-compatible base URL for the custom provider (gateway appends /v1/chat/completions). */ + baseUrl: string; + /** Provider-specific API token, sent as `Authorization: Bearer {token}` by the gateway. */ + apiKey: string; +} + +export interface RegisterProviderResult { + registered: boolean; + mode: 'gateway-admin-api' | 'skipped'; + message: string; +} + +const CUSTOM_PROVIDER_NAME_PATTERN = /^[a-z]+(-[a-z]+)*$/; + +/** + * Registers a custom OpenAI-compatible provider with LLM Gateway. + * + * ## Why this is "best effort" + * + * LLM Gateway's custom-provider registration (`providerKey` table, exposed + * via `apps/api/src/routes/keys-provider.ts` in the gateway's source) is + * designed to be driven from the dashboard with a logged-in session. LLM + * Gateway's only documented bearer-token-authenticated programmatic API + * ("Master Keys", see /features/master-keys) is an **Enterprise-only** + * feature, and even then it only covers projects, gateway API keys, and IAM + * rules — not provider-key/BYOK registration. + * + * As a result, there is no generally-available, stable, bearer-token API to + * create a custom provider key in one automated step. This function still + * attempts it — via a configurable admin base URL + bearer token, in case a + * self-hosted deployment exposes such an endpoint (e.g. an Enterprise master + * key, or a future/internal admin API) — but treats failure as a soft + * failure and logs guidance rather than throwing, since the *actual* + * "deploy + configure + call in one go" requirement is satisfied by the + * `customProvider` inline override on `POST /generate` / `generate` CLI + * command instead (see `gateway-client.ts`), which bypasses the need for + * prior registration entirely. + */ +export class ProviderRegistry { + constructor(private readonly adminConfig: AdminConfig) {} + + async register( + input: RegisterProviderInput + ): Promise { + validateProviderName(input.name); + + if (!this.adminConfig.baseUrl || !this.adminConfig.token) { + return { + registered: false, + mode: 'skipped', + message: + 'LLM_GATEWAY_ADMIN_BASE_URL / LLM_GATEWAY_ADMIN_TOKEN are not configured, ' + + 'so no attempt was made to register this provider with the gateway. ' + + 'Use the `customProvider` field on generate requests to call this endpoint ' + + 'directly (bypassing the gateway) without prior registration, or register the ' + + 'provider once via the LLM Gateway dashboard for gateway-side cost tracking and reuse.', + }; + } + + const url = `${this.adminConfig.baseUrl.replace(/\/$/, '')}/keys/provider`; + let response: Response; + try { + response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.adminConfig.token}`, + }, + body: JSON.stringify({ + provider: 'custom', + name: input.name, + baseUrl: input.baseUrl, + token: input.apiKey, + }), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new GatewayRequestError( + `Failed to reach LLM Gateway admin API: ${message}`, + 502 + ); + } + + const body = await response.json().catch(() => undefined); + if (!response.ok) { + throw new GatewayRequestError( + body?.error?.message ?? + `LLM Gateway admin API rejected provider registration with status ${response.status}`, + response.status, + body?.error?.code, + body + ); + } + + return { + registered: true, + mode: 'gateway-admin-api', + message: `Custom provider "${input.name}" registered with LLM Gateway.`, + }; + } +} + +export function validateProviderName(name: string): void { + if (!CUSTOM_PROVIDER_NAME_PATTERN.test(name)) { + throw new GatewayRequestError( + `Invalid provider name "${name}". Custom provider names must be lowercase letters with optional single hyphens (e.g. "mycompany", "eu-west").`, + 400 + ); + } +} diff --git a/services/llm-gateway-service/src/core/types.ts b/services/llm-gateway-service/src/core/types.ts new file mode 100644 index 0000000..a702d16 --- /dev/null +++ b/services/llm-gateway-service/src/core/types.ts @@ -0,0 +1,137 @@ +/** + * Internal, format-agnostic request/response model. + * + * Wire formats (Vercel AI SDK today, potentially OpenAI/Anthropic later) are + * translated into and out of these types by adapters under `core/format/`. + * This keeps the gateway client and provider registry decoupled from any + * single request/response shape. + */ + +export type ChatRole = 'system' | 'user' | 'assistant' | 'tool'; + +export interface TextPart { + type: 'text'; + text: string; +} + +export interface ImagePart { + type: 'image'; + /** Data URL, http(s) URL, or base64 string. */ + image: string; + mediaType?: string; +} + +export type ContentPart = TextPart | ImagePart; + +export interface ChatMessage { + role: ChatRole; + content: string | ContentPart[]; + /** Present on tool result messages. */ + toolCallId?: string; + toolName?: string; +} + +export interface ToolDefinition { + name: string; + description?: string; + /** JSON Schema describing the tool's parameters. */ + parameters: Record; +} + +/** + * Inline connection details for an OpenAI-compatible endpoint that has *not* + * been pre-registered with the gateway. When present, the gateway client + * bypasses gateway routing for this single request and calls the endpoint + * directly (see core/provider-registry.ts for the rationale). + */ +export interface CustomProviderOverride { + baseUrl: string; + apiKey: string; +} + +export interface GenerateRequest { + /** Provider id (e.g. "openai", "anthropic", or a registered custom provider name). */ + provider: string; + /** Model id within the provider (e.g. "gpt-4o"). */ + model: string; + messages: ChatMessage[]; + system?: string; + tools?: ToolDefinition[]; + temperature?: number; + maxOutputTokens?: number; + topP?: number; + stopSequences?: string[]; + metadata?: Record; + /** Ad-hoc custom provider connection info; see CustomProviderOverride. */ + customProvider?: CustomProviderOverride; +} + +export interface ToolCall { + toolCallId: string; + toolName: string; + args: unknown; +} + +export interface TokenUsage { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; +} + +export type FinishReason = + | 'stop' + | 'length' + | 'content-filter' + | 'tool-calls' + | 'error' + | 'other' + | 'unknown'; + +export interface GenerateResponse { + text: string; + finishReason: FinishReason; + usage: TokenUsage; + /** USD cost reported by the gateway, when available. */ + cost?: number; + provider: string; + model: string; + toolCalls?: ToolCall[]; + /** Raw gateway/provider response, passed through for debugging. */ + raw?: unknown; + /** True when this request bypassed the gateway via customProvider. */ + viaGatewayBypass?: boolean; +} + +export interface EmbedRequest { + provider: string; + model: string; + input: string | string[]; + customProvider?: CustomProviderOverride; +} + +export interface EmbedResponse { + embeddings: number[][]; + usage: TokenUsage; + provider: string; + model: string; + raw?: unknown; +} + +/** + * Structured error carrying the upstream HTTP status so it can be mapped + * through to the service's own HTTP responses instead of collapsing + * everything to a single status code. + */ +export class GatewayRequestError extends Error { + readonly status: number; + readonly code?: string; + readonly raw?: unknown; + + constructor(message: string, status: number, code?: string, raw?: unknown) { + super(message); + this.name = 'GatewayRequestError'; + this.status = status; + this.code = code; + this.raw = raw; + } +} diff --git a/services/llm-gateway-service/src/core/url-safety.ts b/services/llm-gateway-service/src/core/url-safety.ts new file mode 100644 index 0000000..ea4c065 --- /dev/null +++ b/services/llm-gateway-service/src/core/url-safety.ts @@ -0,0 +1,133 @@ +import { GatewayRequestError } from './types.js'; + +/** + * Best-effort SSRF mitigation for the `customProvider.baseUrl` override. + * + * This is a static check against the URL's literal hostname; it does not + * resolve DNS, so a hostname that only *resolves* to an internal address at + * request time is not caught here. Full protection against that requires + * network-level egress controls at the deployment layer. Combined with + * requiring authentication on the HTTP API (tracked separately), this closes + * the most common accidental/careless cases (literal loopback, link-local, + * and RFC1918 private-range targets, plus cloud metadata endpoints). + */ +export function assertSafeCustomProviderBaseUrl(rawUrl: string): void { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new GatewayRequestError( + `"customProvider.baseUrl" is not a valid URL: ${rawUrl}`, + 400 + ); + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new GatewayRequestError( + `"customProvider.baseUrl" must use http or https, got "${url.protocol}"`, + 400 + ); + } + + const hostname = url.hostname.toLowerCase(); + if (isDisallowedHostname(hostname)) { + throw new GatewayRequestError( + `"customProvider.baseUrl" resolves to a disallowed loopback/private/link-local address: ${hostname}`, + 400 + ); + } +} + +function isDisallowedHostname(hostname: string): boolean { + const normalized = hostname.replace(/^\[/, '').replace(/\]$/, ''); + + if (normalized === 'localhost' || normalized === '0.0.0.0') { + return true; + } + + // Cloud metadata services (AWS/GCP/Azure/Alibaba all use this address). + if ( + normalized === '169.254.169.254' || + normalized === 'metadata.google.internal' + ) { + return true; + } + + const ipv4 = parseIPv4(normalized); + if (ipv4) { + return isPrivateOrLoopbackIPv4(ipv4); + } + + if (normalized === '::1' || normalized === '::') { + return true; + } + if ( + normalized.startsWith('fe80:') || + normalized.startsWith('fc') || + normalized.startsWith('fd') + ) { + // Link-local (fe80::/10) and unique local (fc00::/7) IPv6 ranges. + return true; + } + const mapped = parseIPv4MappedIPv6(normalized); + if (mapped) { + return isPrivateOrLoopbackIPv4(mapped); + } + + return false; +} + +/** + * Parses an IPv4-mapped IPv6 address, in either its dotted-decimal form + * (`::ffff:127.0.0.1`) or the hex-group form the WHATWG URL parser + * normalizes it to (`::ffff:7f00:1`). + */ +function parseIPv4MappedIPv6( + normalized: string +): [number, number, number, number] | undefined { + if (!normalized.startsWith('::ffff:')) { + return undefined; + } + const rest = normalized.slice('::ffff:'.length); + + const dotted = parseIPv4(rest); + if (dotted) { + return dotted; + } + + const hexMatch = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(rest); + if (!hexMatch) { + return undefined; + } + const high = parseInt(hexMatch[1], 16); + const low = parseInt(hexMatch[2], 16); + return [(high >> 8) & 0xff, high & 0xff, (low >> 8) & 0xff, low & 0xff]; +} + +function parseIPv4( + value: string +): [number, number, number, number] | undefined { + const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(value); + if (!match) { + return undefined; + } + const parts = match.slice(1).map(Number); + if (parts.some((part) => part < 0 || part > 255)) { + return undefined; + } + return parts as [number, number, number, number]; +} + +function isPrivateOrLoopbackIPv4([a, b]: [ + number, + number, + number, + number, +]): boolean { + if (a === 127) return true; // 127.0.0.0/8 loopback + if (a === 10) return true; // 10.0.0.0/8 + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 + if (a === 192 && b === 168) return true; // 192.168.0.0/16 + if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local + return false; +} diff --git a/services/llm-gateway-service/test/api/generate.test.ts b/services/llm-gateway-service/test/api/generate.test.ts new file mode 100644 index 0000000..d728d63 --- /dev/null +++ b/services/llm-gateway-service/test/api/generate.test.ts @@ -0,0 +1,171 @@ +import { expect } from 'chai'; +import type { FastifyInstance } from 'fastify'; +import { buildServer } from '../../src/api/server.js'; +import type { GatewayClient } from '../../src/core/gateway-client.js'; +import { + GatewayRequestError, + type EmbedRequest, + type GenerateRequest, +} from '../../src/core/types.js'; + +class FakeGatewayClient { + lastGenerateRequest?: GenerateRequest; + lastEmbedRequest?: EmbedRequest; + generateError?: Error; + + async generate(request: GenerateRequest) { + this.lastGenerateRequest = request; + if (this.generateError) throw this.generateError; + return { + text: `echo: ${JSON.stringify(request.messages)}`, + finishReason: 'stop' as const, + usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 }, + cost: 0.001, + provider: request.provider, + model: request.model, + viaGatewayBypass: Boolean(request.customProvider), + }; + } + + async embedText(request: EmbedRequest) { + this.lastEmbedRequest = request; + return { + embeddings: [[0.1, 0.2]], + usage: { inputTokens: 1, totalTokens: 1 }, + provider: request.provider, + model: request.model, + }; + } +} + +describe('POST /generate', () => { + let app: FastifyInstance; + let client: FakeGatewayClient; + + beforeEach(async () => { + client = new FakeGatewayClient(); + app = await buildServer({ + generate: { client: client as unknown as GatewayClient }, + }); + }); + + afterEach(async () => { + await app.close(); + }); + + it('translates a Vercel-format request and returns the formatted response', async () => { + const response = await app.inject({ + method: 'POST', + url: '/generate', + payload: { + provider: 'openai', + model: 'gpt-4o', + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(response.statusCode).to.equal(200); + const body = response.json(); + expect(body.provider).to.equal('openai'); + expect(body.model).to.equal('gpt-4o'); + expect(body.cost).to.equal(0.001); + expect(client.lastGenerateRequest?.messages).to.deep.equal([ + { + role: 'user', + content: 'hello', + toolCallId: undefined, + toolName: undefined, + }, + ]); + }); + + it('rejects a request missing required fields with 400', async () => { + const response = await app.inject({ + method: 'POST', + url: '/generate', + payload: { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] }, + }); + + expect(response.statusCode).to.equal(400); + }); + + it('propagates the upstream status code from a GatewayRequestError', async () => { + client.generateError = new GatewayRequestError( + 'rate limited', + 429, + 'rate_limit_error' + ); + + const response = await app.inject({ + method: 'POST', + url: '/generate', + payload: { + provider: 'openai', + model: 'gpt-4o', + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(response.statusCode).to.equal(429); + expect(response.json()).to.deep.equal({ + statusCode: 429, + error: 'Too Many Requests', + message: 'rate limited', + code: 'rate_limit_error', + }); + }); + + it('forwards a customProvider override to the client', async () => { + const response = await app.inject({ + method: 'POST', + url: '/generate', + payload: { + provider: 'mycompany', + model: 'custom-gpt-4', + messages: [{ role: 'user', content: 'hello' }], + customProvider: { + baseUrl: 'https://api.mycompany.com', + apiKey: 'sk-xxx', + }, + }, + }); + + expect(response.statusCode).to.equal(200); + expect(response.json().viaGatewayBypass).to.equal(true); + expect(client.lastGenerateRequest?.customProvider).to.deep.equal({ + baseUrl: 'https://api.mycompany.com', + apiKey: 'sk-xxx', + }); + }); +}); + +describe('POST /embeddings', () => { + let app: FastifyInstance; + let client: FakeGatewayClient; + + beforeEach(async () => { + client = new FakeGatewayClient(); + app = await buildServer({ + generate: { client: client as unknown as GatewayClient }, + }); + }); + + afterEach(async () => { + await app.close(); + }); + + it('returns embeddings for the given input', async () => { + const response = await app.inject({ + method: 'POST', + url: '/embeddings', + payload: { + provider: 'openai', + model: 'text-embedding-3-small', + input: 'hello', + }, + }); + + expect(response.statusCode).to.equal(200); + expect(response.json().embeddings).to.deep.equal([[0.1, 0.2]]); + }); +}); diff --git a/services/llm-gateway-service/test/api/health.test.ts b/services/llm-gateway-service/test/api/health.test.ts new file mode 100644 index 0000000..1efa688 --- /dev/null +++ b/services/llm-gateway-service/test/api/health.test.ts @@ -0,0 +1,21 @@ +import { expect } from 'chai'; +import type { FastifyInstance } from 'fastify'; +import { buildServer } from '../../src/api/server.js'; + +describe('GET /health', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = await buildServer(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('returns ok', async () => { + const response = await app.inject({ method: 'GET', url: '/health' }); + expect(response.statusCode).to.equal(200); + expect(response.json()).to.deep.equal({ status: 'ok' }); + }); +}); diff --git a/services/llm-gateway-service/test/api/providers.test.ts b/services/llm-gateway-service/test/api/providers.test.ts new file mode 100644 index 0000000..e05dfaa --- /dev/null +++ b/services/llm-gateway-service/test/api/providers.test.ts @@ -0,0 +1,66 @@ +import { expect } from 'chai'; +import type { FastifyInstance } from 'fastify'; +import { buildServer } from '../../src/api/server.js'; +import type { ProviderRegistry } from '../../src/core/provider-registry.js'; + +class FakeProviderRegistry { + async register(input: { name: string; baseUrl: string; apiKey: string }) { + if (input.name === 'boom') { + throw new Error('unexpected failure'); + } + return { + registered: false, + mode: 'skipped' as const, + message: 'no admin API configured', + }; + } +} + +describe('POST /providers', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = await buildServer({ + providers: { + registry: new FakeProviderRegistry() as unknown as ProviderRegistry, + }, + }); + }); + + afterEach(async () => { + await app.close(); + }); + + it('returns the registration result', async () => { + const response = await app.inject({ + method: 'POST', + url: '/providers', + payload: { + name: 'mycompany', + baseUrl: 'https://api.mycompany.com', + apiKey: 'sk-xxx', + }, + }); + + expect(response.statusCode).to.equal(200); + expect(response.json()).to.deep.equal({ + registered: false, + mode: 'skipped', + message: 'no admin API configured', + }); + }); + + it('rejects a request with an invalid provider name at the schema level', async () => { + const response = await app.inject({ + method: 'POST', + url: '/providers', + payload: { + name: 'Invalid Name', + baseUrl: 'https://api.mycompany.com', + apiKey: 'sk-xxx', + }, + }); + + expect(response.statusCode).to.equal(400); + }); +}); diff --git a/services/llm-gateway-service/test/cli/generate.test.ts b/services/llm-gateway-service/test/cli/generate.test.ts new file mode 100644 index 0000000..e9df82a --- /dev/null +++ b/services/llm-gateway-service/test/cli/generate.test.ts @@ -0,0 +1,66 @@ +import { expect } from 'chai'; +import { writeFileSync, readFileSync, mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { generateCommand } from '../../src/cli/commands/generate.js'; +import type { GatewayClient } from '../../src/core/gateway-client.js'; +import type { GenerateRequest } from '../../src/core/types.js'; + +class FakeGatewayClient { + async generate(request: GenerateRequest) { + return { + text: `you said: ${request.messages[0]?.content}`, + finishReason: 'stop' as const, + usage: { totalTokens: 5 }, + provider: request.provider, + model: request.model, + }; + } +} + +describe('generateCommand', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'llm-gateway-cli-test-')); + }); + + it('reads a request file and writes the response to an output file', async () => { + const inputPath = join(dir, 'request.json'); + const outputPath = join(dir, 'response.json'); + writeFileSync( + inputPath, + JSON.stringify({ + provider: 'openai', + model: 'gpt-4o', + messages: [{ role: 'user', content: 'hi there' }], + }) + ); + + await generateCommand( + inputPath, + { output: outputPath }, + new FakeGatewayClient() as unknown as GatewayClient + ); + + const output = JSON.parse(readFileSync(outputPath, 'utf-8')); + expect(output.text).to.equal('you said: hi there'); + expect(output.provider).to.equal('openai'); + }); + + it('throws a helpful error for malformed JSON input', async () => { + const inputPath = join(dir, 'bad.json'); + writeFileSync(inputPath, '{not valid json'); + + try { + await generateCommand( + inputPath, + {}, + new FakeGatewayClient() as unknown as GatewayClient + ); + expect.fail('expected generateCommand to throw'); + } catch (err) { + expect((err as Error).message).to.match(/Failed to parse JSON input/); + } + }); +}); diff --git a/services/llm-gateway-service/test/cli/health.test.ts b/services/llm-gateway-service/test/cli/health.test.ts new file mode 100644 index 0000000..f94343c --- /dev/null +++ b/services/llm-gateway-service/test/cli/health.test.ts @@ -0,0 +1,18 @@ +import { expect } from 'chai'; +import { readFileSync, mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { healthCommand } from '../../src/cli/commands/health.js'; + +describe('healthCommand', () => { + it('writes the health status to an output file', () => { + const dir = mkdtempSync(join(tmpdir(), 'llm-gateway-cli-test-')); + const outputPath = join(dir, 'health.json'); + + healthCommand({ output: outputPath }); + + expect(JSON.parse(readFileSync(outputPath, 'utf-8'))).to.deep.equal({ + status: 'ok', + }); + }); +}); diff --git a/services/llm-gateway-service/test/cli/register-provider.test.ts b/services/llm-gateway-service/test/cli/register-provider.test.ts new file mode 100644 index 0000000..8d68b2b --- /dev/null +++ b/services/llm-gateway-service/test/cli/register-provider.test.ts @@ -0,0 +1,40 @@ +import { expect } from 'chai'; +import { readFileSync, mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { registerProviderCommand } from '../../src/cli/commands/register-provider.js'; +import type { ProviderRegistry } from '../../src/core/provider-registry.js'; + +class FakeRegistry { + async register(input: { name: string; baseUrl: string; apiKey: string }) { + return { + registered: true, + mode: 'gateway-admin-api' as const, + message: `registered ${input.name}`, + }; + } +} + +describe('registerProviderCommand', () => { + it('writes the registration result to the output file', async () => { + const dir = mkdtempSync(join(tmpdir(), 'llm-gateway-cli-test-')); + const outputPath = join(dir, 'result.json'); + + await registerProviderCommand( + { + name: 'mycompany', + baseUrl: 'https://api.mycompany.com', + apiKey: 'sk-xxx', + output: outputPath, + }, + new FakeRegistry() as unknown as ProviderRegistry + ); + + const output = JSON.parse(readFileSync(outputPath, 'utf-8')); + expect(output).to.deep.equal({ + registered: true, + mode: 'gateway-admin-api', + message: 'registered mycompany', + }); + }); +}); diff --git a/services/llm-gateway-service/test/core/format/vercel.test.ts b/services/llm-gateway-service/test/core/format/vercel.test.ts new file mode 100644 index 0000000..b0296d5 --- /dev/null +++ b/services/llm-gateway-service/test/core/format/vercel.test.ts @@ -0,0 +1,234 @@ +import { expect } from 'chai'; +import { vercelFormatAdapter } from '../../../src/core/format/vercel.js'; +import type { GenerateResponse } from '../../../src/core/types.js'; + +describe('vercelFormatAdapter', () => { + describe('parseRequest', () => { + it('parses a minimal request', () => { + const result = vercelFormatAdapter.parseRequest({ + provider: 'openai', + model: 'gpt-4o', + messages: [{ role: 'user', content: 'hello' }], + }); + + expect(result).to.deep.equal({ + provider: 'openai', + model: 'gpt-4o', + messages: [ + { + role: 'user', + content: 'hello', + toolCallId: undefined, + toolName: undefined, + }, + ], + system: undefined, + tools: undefined, + temperature: undefined, + maxOutputTokens: undefined, + topP: undefined, + stopSequences: undefined, + metadata: undefined, + customProvider: undefined, + }); + }); + + it('translates tool definitions from a record into a list', () => { + const result = vercelFormatAdapter.parseRequest({ + provider: 'openai', + model: 'gpt-4o', + messages: [{ role: 'user', content: 'what is the weather?' }], + tools: { + weather: { + description: 'Get the weather for a location', + inputSchema: { + type: 'object', + properties: { location: { type: 'string' } }, + }, + }, + }, + }); + + expect(result.tools).to.deep.equal([ + { + name: 'weather', + description: 'Get the weather for a location', + parameters: { + type: 'object', + properties: { location: { type: 'string' } }, + }, + }, + ]); + }); + + it('translates image content parts', () => { + const result = vercelFormatAdapter.parseRequest({ + provider: 'openai', + model: 'gpt-4o', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'describe this' }, + { + type: 'image', + image: 'data:image/png;base64,AAA', + mediaType: 'image/png', + }, + ], + }, + ], + }); + + expect(result.messages[0].content).to.deep.equal([ + { type: 'text', text: 'describe this' }, + { + type: 'image', + image: 'data:image/png;base64,AAA', + mediaType: 'image/png', + }, + ]); + }); + + it('passes through the customProvider override', () => { + const result = vercelFormatAdapter.parseRequest({ + provider: 'mycompany', + model: 'custom-gpt-4', + messages: [{ role: 'user', content: 'hi' }], + customProvider: { + baseUrl: 'https://api.mycompany.com', + apiKey: 'sk-xxx', + }, + }); + + expect(result.customProvider).to.deep.equal({ + baseUrl: 'https://api.mycompany.com', + apiKey: 'sk-xxx', + }); + }); + + it('rejects a customProvider override targeting a loopback/private address', () => { + expect(() => + vercelFormatAdapter.parseRequest({ + provider: 'mycompany', + model: 'custom-gpt-4', + messages: [{ role: 'user', content: 'hi' }], + customProvider: { + baseUrl: 'http://169.254.169.254/latest/meta-data', + apiKey: 'sk-xxx', + }, + }) + ).to.throw(/disallowed/); + }); + + it('rejects a customProvider override with an empty baseUrl', () => { + expect(() => + vercelFormatAdapter.parseRequest({ + provider: 'mycompany', + model: 'custom-gpt-4', + messages: [{ role: 'user', content: 'hi' }], + customProvider: { baseUrl: '', apiKey: 'sk-xxx' }, + }) + ).to.throw('"customProvider.baseUrl" is required'); + }); + + it('rejects a customProvider override with an empty apiKey', () => { + expect(() => + vercelFormatAdapter.parseRequest({ + provider: 'mycompany', + model: 'custom-gpt-4', + messages: [{ role: 'user', content: 'hi' }], + customProvider: { baseUrl: 'https://api.mycompany.com', apiKey: '' }, + }) + ).to.throw('"customProvider.apiKey" is required'); + }); + + it('throws when provider is missing', () => { + expect(() => + vercelFormatAdapter.parseRequest({ + model: 'gpt-4o', + messages: [{ role: 'user', content: 'hi' }], + } as any) + ).to.throw('"provider" is required'); + }); + + it('throws when model is missing', () => { + expect(() => + vercelFormatAdapter.parseRequest({ + provider: 'openai', + messages: [{ role: 'user', content: 'hi' }], + } as any) + ).to.throw('"model" is required'); + }); + + it('throws when messages is missing or empty', () => { + expect(() => + vercelFormatAdapter.parseRequest({ + provider: 'openai', + model: 'gpt-4o', + messages: [], + } as any) + ).to.throw('"messages" is required'); + }); + + it('throws when the input is not an object', () => { + expect(() => vercelFormatAdapter.parseRequest(null as any)).to.throw( + 'must be a JSON object' + ); + }); + }); + + describe('formatResponse', () => { + it('formats a full response including tool calls', () => { + const response: GenerateResponse = { + text: 'hello there', + finishReason: 'stop', + usage: { inputTokens: 3, outputTokens: 4, totalTokens: 7 }, + cost: 0.0012, + provider: 'openai', + model: 'gpt-4o', + toolCalls: [ + { + toolCallId: 'call_1', + toolName: 'weather', + args: { location: 'Berlin' }, + }, + ], + raw: { any: 'thing' }, + viaGatewayBypass: false, + }; + + expect(vercelFormatAdapter.formatResponse(response)).to.deep.equal({ + text: 'hello there', + finishReason: 'stop', + usage: { inputTokens: 3, outputTokens: 4, totalTokens: 7 }, + cost: 0.0012, + provider: 'openai', + model: 'gpt-4o', + toolCalls: [ + { + toolCallId: 'call_1', + toolName: 'weather', + input: { location: 'Berlin' }, + }, + ], + raw: { any: 'thing' }, + viaGatewayBypass: false, + }); + }); + + it('omits toolCalls when there are none', () => { + const response: GenerateResponse = { + text: 'hi', + finishReason: 'stop', + usage: {}, + provider: 'openai', + model: 'gpt-4o', + }; + + expect(vercelFormatAdapter.formatResponse(response).toolCalls).to.equal( + undefined + ); + }); + }); +}); diff --git a/services/llm-gateway-service/test/core/gateway-client.test.ts b/services/llm-gateway-service/test/core/gateway-client.test.ts new file mode 100644 index 0000000..0180f6f --- /dev/null +++ b/services/llm-gateway-service/test/core/gateway-client.test.ts @@ -0,0 +1,620 @@ +import { expect } from 'chai'; +import { + GatewayClient, + toGatewayRequestError, +} from '../../src/core/gateway-client.js'; +import { + GatewayRequestError, + type GenerateRequest, +} from '../../src/core/types.js'; +import { + MockOpenAIServer, + chatCompletionResponse, + embeddingResponse, +} from '../support/mock-openai-server.js'; + +describe('GatewayClient', () => { + let server: MockOpenAIServer; + let baseUrl: string; + + beforeEach(async () => { + server = new MockOpenAIServer(); + baseUrl = await server.listen(); + }); + + afterEach(async () => { + await server.close(); + }); + + function baseRequest( + overrides: Partial = {} + ): GenerateRequest { + return { + provider: 'openai', + model: 'gpt-4o', + messages: [{ role: 'user', content: 'hello' }], + ...overrides, + }; + } + + describe('generate — via gateway', () => { + it('routes through the configured gateway base URL and returns a normalized response', async () => { + server.on('/chat/completions', (body) => { + expect(body.model).to.equal('openai/gpt-4o'); + expect(body.messages).to.deep.equal([ + { role: 'user', content: 'hello' }, + ]); + return { status: 200, body: chatCompletionResponse() }; + }); + + const client = new GatewayClient({ + baseUrl, + apiKey: 'test-key', + timeoutMs: 5000, + }); + const result = await client.generate(baseRequest()); + + expect(result.text).to.equal('Hello there'); + expect(result.finishReason).to.equal('stop'); + expect(result.usage).to.deep.equal({ + inputTokens: 3, + outputTokens: 4, + totalTokens: 7, + }); + expect(result.provider).to.equal('openai'); + expect(result.model).to.equal('gpt-4o'); + expect(result.viaGatewayBypass).to.equal(false); + }); + + it('surfaces the cost reported by LLM Gateway usage accounting', async () => { + server.on('/chat/completions', () => ({ + status: 200, + body: chatCompletionResponse({ + usage: { + prompt_tokens: 3, + completion_tokens: 4, + total_tokens: 7, + cost: 0.0042, + }, + }), + })); + + const client = new GatewayClient({ + baseUrl, + apiKey: 'test-key', + timeoutMs: 5000, + }); + const result = await client.generate(baseRequest()); + + expect(result.cost).to.equal(0.0042); + }); + + it('maps a 4xx gateway error to a GatewayRequestError with the same status', async () => { + server.on('/chat/completions', () => ({ + status: 429, + body: { error: { message: 'rate limited', type: 'rate_limit_error' } }, + })); + + const client = new GatewayClient({ + baseUrl, + apiKey: 'test-key', + timeoutMs: 5000, + }); + + try { + await client.generate(baseRequest()); + expect.fail('expected generate() to throw'); + } catch (err) { + expect(err).to.be.instanceOf(GatewayRequestError); + expect((err as GatewayRequestError).status).to.equal(429); + } + }); + + it('maps tool calls back onto the response', async () => { + server.on('/chat/completions', () => ({ + status: 200, + body: chatCompletionResponse({ + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + name: 'weather', + arguments: '{"location":"Berlin"}', + }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + }), + })); + + const client = new GatewayClient({ + baseUrl, + apiKey: 'test-key', + timeoutMs: 5000, + }); + const result = await client.generate( + baseRequest({ + tools: [ + { + name: 'weather', + description: 'Get the weather', + parameters: { + type: 'object', + properties: { location: { type: 'string' } }, + }, + }, + ], + }) + ); + + expect(result.finishReason).to.equal('tool-calls'); + expect(result.toolCalls).to.have.length(1); + expect(result.toolCalls?.[0].toolName).to.equal('weather'); + }); + }); + + describe('generate — missing gateway API key', () => { + it('fails fast with a clear message instead of silently calling the gateway unauthenticated', async () => { + let gatewayCalled = false; + server.on('/chat/completions', () => { + gatewayCalled = true; + return { status: 200, body: chatCompletionResponse() }; + }); + + const client = new GatewayClient({ + baseUrl, + apiKey: '', + timeoutMs: 5000, + }); + + try { + await client.generate(baseRequest()); + expect.fail('expected generate() to throw'); + } catch (err) { + expect(err).to.be.instanceOf(GatewayRequestError); + expect((err as GatewayRequestError).message).to.match( + /LLM_GATEWAY_API_KEY is not configured/ + ); + } + expect(gatewayCalled).to.equal(false); + }); + }); + + describe('generate — via customProvider bypass', () => { + it('calls the custom endpoint directly instead of the gateway', async () => { + let gatewayCalled = false; + server.on('/chat/completions', () => { + gatewayCalled = true; + return { status: 200, body: chatCompletionResponse() }; + }); + + const customServer = new MockOpenAIServer(); + const customBaseUrl = await customServer.listen(); + customServer.on('/chat/completions', (body) => { + expect(body.model).to.equal('custom-gpt-4'); + return { + status: 200, + body: chatCompletionResponse({ model: 'custom-gpt-4' }), + }; + }); + + try { + const client = new GatewayClient({ + baseUrl, + apiKey: 'test-key', + timeoutMs: 5000, + }); + const result = await client.generate( + baseRequest({ + provider: 'mycompany', + model: 'custom-gpt-4', + customProvider: { baseUrl: customBaseUrl, apiKey: 'custom-key' }, + }) + ); + + expect(result.viaGatewayBypass).to.equal(true); + expect(result.text).to.equal('Hello there'); + expect(gatewayCalled).to.equal(false); + } finally { + await customServer.close(); + } + }); + + it('sends system, image content, and tool definitions in OpenAI chat-completions shape', async () => { + const customServer = new MockOpenAIServer(); + const customBaseUrl = await customServer.listen(); + customServer.on('/chat/completions', (body) => { + expect(body.messages[0]).to.deep.equal({ + role: 'system', + content: 'Answer concisely.', + }); + expect(body.messages[1].content).to.deep.equal([ + { type: 'text', text: 'describe this' }, + { + type: 'image_url', + image_url: { url: 'https://example.com/cat.png' }, + }, + ]); + expect(body.tools).to.deep.equal([ + { + type: 'function', + function: { + name: 'weather', + description: 'Get the weather', + parameters: { + type: 'object', + properties: { location: { type: 'string' } }, + }, + }, + }, + ]); + return { status: 200, body: chatCompletionResponse() }; + }); + + try { + const client = new GatewayClient({ + baseUrl, + apiKey: 'test-key', + timeoutMs: 5000, + }); + await client.generate( + baseRequest({ + provider: 'mycompany', + model: 'custom-gpt-4', + system: 'Answer concisely.', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'describe this' }, + { type: 'image', image: 'https://example.com/cat.png' }, + ], + }, + ], + tools: [ + { + name: 'weather', + description: 'Get the weather', + parameters: { + type: 'object', + properties: { location: { type: 'string' } }, + }, + }, + ], + customProvider: { baseUrl: customBaseUrl, apiKey: 'custom-key' }, + }) + ); + } finally { + await customServer.close(); + } + }); + + it('maps tool calls and finish reason returned by a custom provider', async () => { + const customServer = new MockOpenAIServer(); + const customBaseUrl = await customServer.listen(); + customServer.on('/chat/completions', () => ({ + status: 200, + body: chatCompletionResponse({ + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'call_9', + type: 'function', + function: { + name: 'weather', + arguments: '{"location":"Paris"}', + }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + }), + })); + + try { + const client = new GatewayClient({ + baseUrl, + apiKey: 'test-key', + timeoutMs: 5000, + }); + const result = await client.generate( + baseRequest({ + provider: 'mycompany', + model: 'custom-gpt-4', + customProvider: { baseUrl: customBaseUrl, apiKey: 'custom-key' }, + }) + ); + + expect(result.finishReason).to.equal('tool-calls'); + expect(result.toolCalls).to.deep.equal([ + { + toolCallId: 'call_9', + toolName: 'weather', + args: { location: 'Paris' }, + }, + ]); + } finally { + await customServer.close(); + } + }); + + it('falls back to the raw string when tool call arguments are not valid JSON', async () => { + const customServer = new MockOpenAIServer(); + const customBaseUrl = await customServer.listen(); + customServer.on('/chat/completions', () => ({ + status: 200, + body: chatCompletionResponse({ + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'weather', arguments: 'not-json' }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + }), + })); + + try { + const client = new GatewayClient({ + baseUrl, + apiKey: 'test-key', + timeoutMs: 5000, + }); + const result = await client.generate( + baseRequest({ + provider: 'mycompany', + model: 'custom-gpt-4', + customProvider: { baseUrl: customBaseUrl, apiKey: 'custom-key' }, + }) + ); + + expect(result.toolCalls?.[0].args).to.equal('not-json'); + } finally { + await customServer.close(); + } + }); + + it('surfaces a 502 when the custom provider returns no choices', async () => { + const customServer = new MockOpenAIServer(); + const customBaseUrl = await customServer.listen(); + customServer.on('/chat/completions', () => ({ + status: 200, + body: { choices: [] }, + })); + + try { + const client = new GatewayClient({ + baseUrl, + apiKey: 'test-key', + timeoutMs: 5000, + }); + try { + await client.generate( + baseRequest({ + provider: 'mycompany', + model: 'custom-gpt-4', + customProvider: { baseUrl: customBaseUrl, apiKey: 'custom-key' }, + }) + ); + expect.fail('expected generate() to throw'); + } catch (err) { + expect(err).to.be.instanceOf(GatewayRequestError); + expect((err as GatewayRequestError).status).to.equal(502); + } + } finally { + await customServer.close(); + } + }); + + it('maps content_filter and unrecognized finish reasons', async () => { + const customServer = new MockOpenAIServer(); + const customBaseUrl = await customServer.listen(); + customServer.on('/chat/completions', () => ({ + status: 200, + body: chatCompletionResponse({ + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'blocked' }, + finish_reason: 'content_filter', + }, + ], + }), + })); + + try { + const client = new GatewayClient({ + baseUrl, + apiKey: 'test-key', + timeoutMs: 5000, + }); + const result = await client.generate( + baseRequest({ + provider: 'mycompany', + model: 'custom-gpt-4', + customProvider: { baseUrl: customBaseUrl, apiKey: 'custom-key' }, + }) + ); + expect(result.finishReason).to.equal('content-filter'); + } finally { + await customServer.close(); + } + }); + }); + + describe('embedText', () => { + it('calls the gateway embeddings endpoint directly', async () => { + server.on('/embeddings', (body) => { + expect(body.model).to.equal('openai/text-embedding-3-small'); + return { status: 200, body: embeddingResponse([[0.1, 0.2, 0.3]]) }; + }); + + const client = new GatewayClient({ + baseUrl, + apiKey: 'test-key', + timeoutMs: 5000, + }); + const result = await client.embedText({ + provider: 'openai', + model: 'text-embedding-3-small', + input: 'hello', + }); + + expect(result.embeddings).to.deep.equal([[0.1, 0.2, 0.3]]); + expect(result.provider).to.equal('openai'); + expect(result.model).to.equal('text-embedding-3-small'); + }); + + it('maps embeddings errors to GatewayRequestError', async () => { + server.on('/embeddings', () => ({ + status: 401, + body: { error: { message: 'invalid api key' } }, + })); + + const client = new GatewayClient({ + baseUrl, + apiKey: 'bad-key', + timeoutMs: 5000, + }); + + try { + await client.embedText({ + provider: 'openai', + model: 'text-embedding-3-small', + input: 'hello', + }); + expect.fail('expected embedText() to throw'); + } catch (err) { + expect(err).to.be.instanceOf(GatewayRequestError); + expect((err as GatewayRequestError).status).to.equal(401); + } + }); + + it('embeds via a custom provider bypass', async () => { + const customServer = new MockOpenAIServer(); + const customBaseUrl = await customServer.listen(); + customServer.on('/embeddings', () => ({ + status: 200, + body: embeddingResponse([[0.4, 0.5]]), + })); + + try { + const client = new GatewayClient({ + baseUrl, + apiKey: 'test-key', + timeoutMs: 5000, + }); + const result = await client.embedText({ + provider: 'mycompany', + model: 'custom-embed', + input: 'hello', + customProvider: { baseUrl: customBaseUrl, apiKey: 'custom-key' }, + }); + + expect(result.embeddings).to.deep.equal([[0.4, 0.5]]); + } finally { + await customServer.close(); + } + }); + + it('batches multiple inputs into a single request via a custom provider', async () => { + const customServer = new MockOpenAIServer(); + const customBaseUrl = await customServer.listen(); + let callCount = 0; + customServer.on('/embeddings', (body) => { + callCount += 1; + expect(body.input).to.deep.equal(['hello', 'world']); + return { status: 200, body: embeddingResponse([[0.1], [0.2]]) }; + }); + + try { + const client = new GatewayClient({ + baseUrl, + apiKey: 'test-key', + timeoutMs: 5000, + }); + const result = await client.embedText({ + provider: 'mycompany', + model: 'custom-embed', + input: ['hello', 'world'], + customProvider: { baseUrl: customBaseUrl, apiKey: 'custom-key' }, + }); + + expect(callCount).to.equal(1); + expect(result.embeddings).to.deep.equal([[0.1], [0.2]]); + } finally { + await customServer.close(); + } + }); + + it('fails fast with a clear message when the gateway API key is missing', async () => { + const client = new GatewayClient({ + baseUrl, + apiKey: '', + timeoutMs: 5000, + }); + + try { + await client.embedText({ + provider: 'openai', + model: 'text-embedding-3-small', + input: 'hi', + }); + expect.fail('expected embedText() to throw'); + } catch (err) { + expect(err).to.be.instanceOf(GatewayRequestError); + expect((err as GatewayRequestError).message).to.match( + /LLM_GATEWAY_API_KEY is not configured/ + ); + } + }); + }); +}); + +describe('toGatewayRequestError', () => { + it('passes through an existing GatewayRequestError unchanged', () => { + const original = new GatewayRequestError('boom', 418); + expect(toGatewayRequestError(original)).to.equal(original); + }); + + it('defaults to a 502 status when the error has no status information', () => { + const wrapped = toGatewayRequestError(new Error('connection reset')); + expect(wrapped.status).to.equal(502); + expect(wrapped.message).to.equal('connection reset'); + }); + + it('extracts a status code from provider-shaped errors', () => { + const wrapped = toGatewayRequestError({ + message: 'bad request', + statusCode: 400, + }); + expect(wrapped.status).to.equal(400); + }); +}); diff --git a/services/llm-gateway-service/test/core/provider-registry.test.ts b/services/llm-gateway-service/test/core/provider-registry.test.ts new file mode 100644 index 0000000..86f40f8 --- /dev/null +++ b/services/llm-gateway-service/test/core/provider-registry.test.ts @@ -0,0 +1,146 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { + ProviderRegistry, + validateProviderName, +} from '../../src/core/provider-registry.js'; +import { GatewayRequestError } from '../../src/core/types.js'; + +describe('validateProviderName', () => { + it('accepts valid lowercase hyphenated names', () => { + expect(() => validateProviderName('mycompany')).to.not.throw(); + expect(() => validateProviderName('eu-west')).to.not.throw(); + }); + + it('rejects invalid names', () => { + for (const invalid of [ + 'MyCompany', + 'my_company', + '123test', + '-mycompany', + 'my-', + 'my--company', + ]) { + expect(() => validateProviderName(invalid)).to.throw(GatewayRequestError); + } + }); +}); + +describe('ProviderRegistry', () => { + let fetchStub: sinon.SinonStub; + + beforeEach(() => { + fetchStub = sinon.stub(globalThis, 'fetch' as any); + }); + + afterEach(() => { + fetchStub.restore(); + }); + + it('skips registration when admin config is not set', async () => { + const registry = new ProviderRegistry({}); + const result = await registry.register({ + name: 'mycompany', + baseUrl: 'https://api.mycompany.com', + apiKey: 'sk-x', + }); + + expect(result.registered).to.equal(false); + expect(result.mode).to.equal('skipped'); + expect(fetchStub.called).to.equal(false); + }); + + it('rejects invalid provider names before calling the admin API', async () => { + const registry = new ProviderRegistry({ + baseUrl: 'https://admin.example.com', + token: 'llmgmk_x', + }); + + try { + await registry.register({ + name: 'Invalid Name', + baseUrl: 'https://api.mycompany.com', + apiKey: 'sk-x', + }); + expect.fail('expected register() to throw'); + } catch (err) { + expect(err).to.be.instanceOf(GatewayRequestError); + } + expect(fetchStub.called).to.equal(false); + }); + + it('calls the configured admin API and reports success', async () => { + fetchStub.resolves({ + ok: true, + json: async () => ({ id: 'pk_123' }), + } as any); + + const registry = new ProviderRegistry({ + baseUrl: 'https://admin.example.com', + token: 'llmgmk_x', + }); + const result = await registry.register({ + name: 'mycompany', + baseUrl: 'https://api.mycompany.com', + apiKey: 'sk-x', + }); + + expect(result).to.deep.equal({ + registered: true, + mode: 'gateway-admin-api', + message: 'Custom provider "mycompany" registered with LLM Gateway.', + }); + expect(fetchStub.calledOnce).to.equal(true); + const [url, init] = fetchStub.firstCall.args; + expect(url).to.equal('https://admin.example.com/keys/provider'); + expect(init.headers.Authorization).to.equal('Bearer llmgmk_x'); + }); + + it('propagates the admin API status code on failure', async () => { + fetchStub.resolves({ + ok: false, + status: 409, + json: async () => ({ + error: { message: 'already exists', code: 'conflict' }, + }), + } as any); + + const registry = new ProviderRegistry({ + baseUrl: 'https://admin.example.com', + token: 'llmgmk_x', + }); + + try { + await registry.register({ + name: 'mycompany', + baseUrl: 'https://api.mycompany.com', + apiKey: 'sk-x', + }); + expect.fail('expected register() to throw'); + } catch (err) { + expect(err).to.be.instanceOf(GatewayRequestError); + expect((err as GatewayRequestError).status).to.equal(409); + expect((err as GatewayRequestError).message).to.equal('already exists'); + } + }); + + it('wraps network failures as a 502 GatewayRequestError', async () => { + fetchStub.rejects(new Error('network down')); + const registry = new ProviderRegistry({ + baseUrl: 'https://admin.example.com', + token: 'llmgmk_x', + }); + + try { + await registry.register({ + name: 'mycompany', + baseUrl: 'https://api.mycompany.com', + apiKey: 'sk-x', + }); + expect.fail('expected register() to throw'); + } catch (err) { + expect(err).to.be.instanceOf(GatewayRequestError); + expect((err as GatewayRequestError).status).to.equal(502); + } + }); +}); diff --git a/services/llm-gateway-service/test/core/url-safety.test.ts b/services/llm-gateway-service/test/core/url-safety.test.ts new file mode 100644 index 0000000..f7d483d --- /dev/null +++ b/services/llm-gateway-service/test/core/url-safety.test.ts @@ -0,0 +1,50 @@ +import { expect } from 'chai'; +import { assertSafeCustomProviderBaseUrl } from '../../src/core/url-safety.js'; +import { GatewayRequestError } from '../../src/core/types.js'; + +describe('assertSafeCustomProviderBaseUrl', () => { + it('allows a public https URL', () => { + expect(() => + assertSafeCustomProviderBaseUrl('https://api.mycompany.com/v1') + ).to.not.throw(); + }); + + it('rejects an invalid URL', () => { + expect(() => assertSafeCustomProviderBaseUrl('not-a-url')).to.throw( + GatewayRequestError + ); + }); + + it('rejects a non-http(s) scheme', () => { + expect(() => + assertSafeCustomProviderBaseUrl('file:///etc/passwd') + ).to.throw(GatewayRequestError); + }); + + for (const url of [ + 'http://localhost:4001/v1', + 'http://127.0.0.1/v1', + 'http://0.0.0.0/v1', + 'http://169.254.169.254/latest/meta-data', + 'http://metadata.google.internal/computeMetadata/v1', + 'http://10.0.0.5/v1', + 'http://172.16.0.5/v1', + 'http://192.168.1.5/v1', + 'http://[::1]/v1', + 'http://[fe80::1]/v1', + 'http://[fd00::1]/v1', + 'http://[::ffff:127.0.0.1]/v1', + ]) { + it(`rejects disallowed target ${url}`, () => { + expect(() => assertSafeCustomProviderBaseUrl(url)).to.throw( + GatewayRequestError + ); + }); + } + + it('allows a public IPv4 address', () => { + expect(() => + assertSafeCustomProviderBaseUrl('https://8.8.8.8/v1') + ).to.not.throw(); + }); +}); diff --git a/services/llm-gateway-service/test/integration/workflow.test.ts b/services/llm-gateway-service/test/integration/workflow.test.ts new file mode 100644 index 0000000..fded0ea --- /dev/null +++ b/services/llm-gateway-service/test/integration/workflow.test.ts @@ -0,0 +1,206 @@ +import { expect } from 'chai'; +import { mkdtempSync, readFileSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + MockOpenAIServer, + chatCompletionResponse, +} from '../support/mock-openai-server.js'; +import { runCli } from '../support/run-cli.js'; + +/** + * End-to-end workflow simulation, driving the actual CLI as a real + * subprocess (real argv parsing, env-driven config, stdin/stdout JSON I/O — + * matching how an automated workflow would invoke this service): + * + * 1. `register-provider` — registers a custom OpenAI-compatible provider + * with LLM Gateway's admin API in one step (mocked here). + * 2. `generate` — once registered, calls that provider *through the + * gateway* (by `{provider}/{model}`, no `customProvider` override + * needed) with a multi-turn message history and a system prompt. + * + * This exercises the full stack for both commands: CLI parsing, config + * loading from env vars, JSON file I/O, the provider registry's admin-API + * call, the Vercel format adapter, and the gateway client's real HTTP + * request/response handling — against local mock HTTP servers standing in + * for LLM Gateway's admin and inference APIs. + */ +describe('workflow: register a custom provider, then generate through the gateway', () => { + let adminServer: MockOpenAIServer; + let adminBaseUrl: string; + let gatewayServer: MockOpenAIServer; + let gatewayBaseUrl: string; + let workdir: string; + + beforeEach(async () => { + adminServer = new MockOpenAIServer(); + adminBaseUrl = await adminServer.listen(); + + gatewayServer = new MockOpenAIServer(); + gatewayBaseUrl = await gatewayServer.listen(); + + workdir = mkdtempSync(join(tmpdir(), 'llm-gateway-workflow-test-')); + }); + + afterEach(async () => { + await adminServer.close(); + await gatewayServer.close(); + }); + + it('registers a custom provider and then generates a response through it, with message history and a system prompt', async function () { + this.timeout(20000); + + // ------------------------------------------------------------------- + // Step 1: the workflow registers a brand-new custom provider with the + // gateway's admin API, in one CLI call. + // ------------------------------------------------------------------- + let registrationRequestBody: unknown; + adminServer.on('/keys/provider', (body) => { + registrationRequestBody = body; + return { status: 201, body: { id: 'pk_workflow_test' } }; + }); + + const registerResultPath = join(workdir, 'register-result.json'); + const registerRun = await runCli( + [ + 'register-provider', + '--name', + 'mycompany', + '--base-url', + 'https://api.mycompany.com', + '--api-key', + 'sk-custom-provider-key', + '-o', + registerResultPath, + ], + { + env: { + LLM_GATEWAY_ADMIN_BASE_URL: adminBaseUrl, + LLM_GATEWAY_ADMIN_TOKEN: 'llmgmk_test_master_key', + }, + } + ); + + expect( + registerRun.exitCode, + `register-provider stderr: ${registerRun.stderr}` + ).to.equal(0); + + const registerResult = JSON.parse( + readFileSync(registerResultPath, 'utf-8') + ); + expect(registerResult).to.deep.equal({ + registered: true, + mode: 'gateway-admin-api', + message: 'Custom provider "mycompany" registered with LLM Gateway.', + }); + + // Confirm the CLI actually forwarded the provider connection details to + // the gateway's admin API, not just echoing back a fabricated success. + expect(registrationRequestBody).to.deep.equal({ + provider: 'custom', + name: 'mycompany', + baseUrl: 'https://api.mycompany.com', + token: 'sk-custom-provider-key', + }); + + // ------------------------------------------------------------------- + // Step 2: now that the provider is registered, the workflow calls + // `generate` addressing it through the gateway as "mycompany/" + // (no `customProvider` override needed), with a realistic multi-turn + // conversation and a system prompt. + // ------------------------------------------------------------------- + let generateRequestBody: any; + gatewayServer.on('/chat/completions', (body) => { + generateRequestBody = body; + return { + status: 200, + body: chatCompletionResponse({ + model: 'mycompany/custom-gpt-4', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'The result is 84.' }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 42, + completion_tokens: 6, + total_tokens: 48, + cost: 0.00123, + }, + }), + }; + }); + + const generateRequest = { + provider: 'mycompany', + model: 'custom-gpt-4', + system: + 'You are a concise math assistant for an ALADIN workflow. Only answer with the final number.', + messages: [ + { role: 'user', content: 'What is 21 + 21?' }, + { role: 'assistant', content: '21 + 21 is 42.' }, + { role: 'user', content: 'Great — now multiply that result by 2.' }, + ], + temperature: 0.2, + maxOutputTokens: 64, + }; + + const generateRequestPath = join(workdir, 'generate-request.json'); + const generateResultPath = join(workdir, 'generate-result.json'); + writeFileSync(generateRequestPath, JSON.stringify(generateRequest)); + + const generateRun = await runCli( + ['generate', generateRequestPath, '-o', generateResultPath], + { + env: { + LLM_GATEWAY_BASE_URL: gatewayBaseUrl, + LLM_GATEWAY_API_KEY: 'llmgtwy_test_key', + }, + } + ); + + expect( + generateRun.exitCode, + `generate stderr: ${generateRun.stderr}` + ).to.equal(0); + + // The gateway received the request routed through the *registered* + // provider — addressed as "{provider}/{model}" — not a customProvider + // bypass, and with the full conversation history plus the system + // prompt intact. + expect(generateRequestBody.model).to.equal('mycompany/custom-gpt-4'); + const roles = generateRequestBody.messages.map((m: any) => m.role); + expect(roles).to.deep.equal(['system', 'user', 'assistant', 'user']); + expect(generateRequestBody.messages[0].content).to.equal( + 'You are a concise math assistant for an ALADIN workflow. Only answer with the final number.' + ); + expect(generateRequestBody.messages[1].content).to.equal( + 'What is 21 + 21?' + ); + expect(generateRequestBody.messages[2].content).to.equal('21 + 21 is 42.'); + expect(generateRequestBody.messages[3].content).to.equal( + 'Great — now multiply that result by 2.' + ); + + // The CLI's final output reflects the full round trip: generated text, + // usage/cost accounting, resolved provider/model, and that this went + // through the gateway rather than a customProvider bypass. + const generateResult = JSON.parse( + readFileSync(generateResultPath, 'utf-8') + ); + expect(generateResult.text).to.equal('The result is 84.'); + expect(generateResult.finishReason).to.equal('stop'); + expect(generateResult.provider).to.equal('mycompany'); + expect(generateResult.model).to.equal('custom-gpt-4'); + expect(generateResult.usage).to.deep.equal({ + inputTokens: 42, + outputTokens: 6, + totalTokens: 48, + }); + expect(generateResult.cost).to.equal(0.00123); + expect(generateResult.viaGatewayBypass).to.equal(false); + }); +}); diff --git a/services/llm-gateway-service/test/support/mock-openai-server.ts b/services/llm-gateway-service/test/support/mock-openai-server.ts new file mode 100644 index 0000000..6c928c7 --- /dev/null +++ b/services/llm-gateway-service/test/support/mock-openai-server.ts @@ -0,0 +1,102 @@ +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from 'http'; + +export type MockHandler = ( + body: any, + req: IncomingMessage +) => { status: number; body: unknown }; + +/** + * Minimal local HTTP server that mimics an OpenAI-compatible API + * (`/chat/completions`, `/embeddings`) for exercising the real AI SDK + * request/response pipeline without hitting the network. + */ +export class MockOpenAIServer { + private server: Server | undefined; + private handlers = new Map(); + + on(path: string, handler: MockHandler): void { + this.handlers.set(path, handler); + } + + async listen(): Promise { + this.server = createServer((req, res) => this.handleRequest(req, res)); + await new Promise((resolve) => + this.server!.listen(0, '127.0.0.1', resolve) + ); + const address = this.server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to determine mock server address'); + } + return `http://127.0.0.1:${address.port}`; + } + + async close(): Promise { + await new Promise((resolve, reject) => { + if (!this.server) return resolve(); + this.server.close((err) => (err ? reject(err) : resolve())); + }); + } + + private handleRequest(req: IncomingMessage, res: ServerResponse): void { + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf-8'); + const body = raw ? JSON.parse(raw) : undefined; + const path = (req.url ?? '').split('?')[0]; + const handler = this.handlers.get(path); + + if (!handler) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ error: { message: `No mock handler for ${path}` } }) + ); + return; + } + + const { status, body: responseBody } = handler(body, req); + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(responseBody)); + }); + } +} + +export function chatCompletionResponse(overrides: Record = {}) { + return { + id: 'chatcmpl-123', + object: 'chat.completion', + created: 1700000000, + model: 'gpt-4o', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Hello there', + tool_calls: undefined, + }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 3, completion_tokens: 4, total_tokens: 7 }, + ...overrides, + }; +} + +export function embeddingResponse(vectors: number[][]) { + return { + object: 'list', + data: vectors.map((embedding, index) => ({ + object: 'embedding', + embedding, + index, + })), + model: 'text-embedding-3-small', + usage: { prompt_tokens: vectors.length, total_tokens: vectors.length }, + }; +} diff --git a/services/llm-gateway-service/test/support/run-cli.ts b/services/llm-gateway-service/test/support/run-cli.ts new file mode 100644 index 0000000..7ac9e62 --- /dev/null +++ b/services/llm-gateway-service/test/support/run-cli.ts @@ -0,0 +1,52 @@ +import { spawn } from 'child_process'; +import { fileURLToPath } from 'url'; +import { dirname, resolve } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CLI_ENTRY = resolve(__dirname, '../../src/cli/index.ts'); + +export interface RunCliResult { + exitCode: number | null; + stdout: string; + stderr: string; +} + +/** + * Spawns the actual CLI entrypoint as a real child process (via `tsx`), so + * integration tests exercise real argv parsing, env-driven configuration, + * and stdin/stdout I/O exactly as a workflow invoking this CLI would — + * rather than calling internal command functions directly in-process. + */ +export function runCli( + args: string[], + options: { env?: Record; stdin?: string } = {} +): Promise { + return new Promise((resolvePromise, reject) => { + // Strip any debugger bootloader injected via NODE_OPTIONS (e.g. by an + // attached IDE debugger) — it can hang a spawned child process. + const env: Record = { ...process.env }; + delete env.NODE_OPTIONS; + Object.assign(env, options.env); + + const child = spawn( + process.execPath, + ['--import', 'tsx', CLI_ENTRY, ...args], + { env } + ); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => (stdout += chunk.toString())); + child.stderr.on('data', (chunk) => (stderr += chunk.toString())); + + child.on('error', reject); + child.on('close', (exitCode) => { + resolvePromise({ exitCode, stdout, stderr }); + }); + + if (options.stdin !== undefined) { + child.stdin.write(options.stdin); + } + child.stdin.end(); + }); +} diff --git a/services/llm-gateway-service/tsconfig.json b/services/llm-gateway-service/tsconfig.json new file mode 100644 index 0000000..27df3d0 --- /dev/null +++ b/services/llm-gateway-service/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "removeComments": true, + "noEmit": true, + + "strict": true, + "noImplicitAny": false, + + "moduleResolution": "Bundler", + "esModuleInterop": true, + "resolveJsonModule": true, + + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src", "test", "scripts"], + "exclude": ["node_modules", "dist"] +}