From 2d75a9a456a6b6fb055ca6a6069269a08f8cee38 Mon Sep 17 00:00:00 2001 From: Tash Date: Mon, 20 Jul 2026 18:47:52 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20resolve=20#523=20#568=20#308=20#436=20#5?= =?UTF-8?q?36=20=E2=80=94=20TTS=20voice=20mapping,=20response=20model=20na?= =?UTF-8?q?mes,=20Termux=20support,=20docs=20index,=20health-check=20error?= =?UTF-8?q?=20surfacing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TTS (#523): map OpenAI voice names (alloy, echo, fable, ...) to native voices per provider (SiliconFlow, Gemini, Pollinations) with safe default fallbacks; Cloudflare MeloTTS no longer receives the voice as a language code. - Model names (#568): chat responses report the routed model id for all providers on both streaming and non-streaming paths, instead of leaking upstream placeholders like "default" (Reka). - Android/Termux (#308): node:sqlite adapter auto-selected on Android with migration, nested-transaction, WAL, and file-backed support; better-sqlite3 becomes an optionalDependency; adds docs/install/android-termux.md. - Docs (#436): docs/README.md index plus prominent README links to the documentation index and contributor guide. - Health errors (#536): key health-check failures are captured with the provider's reason, redacted, persisted (last_health_error), exposed via /api/keys and /api/health, cleared on recovery, and shown under the key in the dashboard. Cloudflare token verification now returns diagnostic failures instead of a bare boolean, and the redactor also catches JSON-quoted secret labels and bare high-entropy tokens. Also adopts live-verified provider quirks encountered while testing (Requesty sampling workaround, Pollinations base URL move). Co-Authored-By: Claude Fable 5 --- README.md | 4 + client/src/components/keys/provider-list.tsx | 14 ++- client/src/components/keys/shared.tsx | 4 +- docs/README.md | 38 ++++++ docs/install/android-termux.md | 110 +++++++++++++++++ package-lock.json | 83 +++++++++---- server/package.json | 4 +- .../__tests__/db/migrate/roundtrip.test.ts | 2 + server/src/__tests__/db/node-sqlite.test.ts | 75 ++++++++++++ .../src/__tests__/lib/error-redaction.test.ts | 44 +++++++ .../__tests__/providers/cloudflare.test.ts | 20 ++- server/src/__tests__/providers/google.test.ts | 4 +- .../__tests__/providers/openai-compat.test.ts | 46 ++++++- .../providers/reasoning-timeouts.test.ts | 4 +- .../routes/proxy-pinned-model.test.ts | 7 +- .../routes/proxy-stream-integrity.test.ts | 7 +- .../__tests__/services/catalog-sync.test.ts | 60 +++++++++ .../src/__tests__/services/embeddings.test.ts | 17 +++ .../__tests__/services/health-error.test.ts | 77 ++++++++++++ .../src/__tests__/services/health-log.test.ts | 6 +- server/src/__tests__/services/media.test.ts | 95 ++++++++++++--- server/src/__tests__/services/quirks.test.ts | 5 +- server/src/db/index.ts | 21 +++- server/src/db/migrate/defaults.ts | 3 + .../20260101_000000_legacy_baseline.ts | 6 +- .../20260720_000001_key_health_error.ts | 19 +++ server/src/db/node-sqlite.ts | 115 ++++++++++++++++++ server/src/db/types.ts | 2 + server/src/docs/openapi.ts | 6 +- server/src/lib/error-redaction.ts | 6 +- server/src/providers/aihorde.ts | 6 +- server/src/providers/base.ts | 41 ++++++- server/src/providers/cloudflare.ts | 44 ++++--- server/src/providers/cohere.ts | 6 +- server/src/providers/google.ts | 14 ++- server/src/providers/index.ts | 17 +-- server/src/providers/openai-compat.ts | 33 +++-- server/src/routes/health.ts | 3 +- server/src/routes/keys.ts | 1 + server/src/routes/proxy.ts | 16 ++- server/src/services/catalog-sync.ts | 99 +++++++++++++++ server/src/services/embeddings.ts | 14 +++ server/src/services/health.ts | 43 +++++-- server/src/services/media.ts | 105 +++++++++++++++- server/src/services/provider-quota.ts | 2 +- shared/types.ts | 1 + 46 files changed, 1208 insertions(+), 141 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/install/android-termux.md create mode 100644 server/src/__tests__/db/node-sqlite.test.ts create mode 100644 server/src/__tests__/lib/error-redaction.test.ts create mode 100644 server/src/__tests__/services/health-error.test.ts create mode 100644 server/src/db/migrations/20260720_000001_key_health_error.ts create mode 100644 server/src/db/node-sqlite.ts diff --git a/README.md b/README.md index 5f3b19bed..bedc547ad 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ Your router updates its own model catalog from a signed feed: new free models, q - [How it works](#how-it-works) - [Context Handoff](#context-handoff) - [Limitations](#limitations) +- [Documentation index](docs/README.md) +- [Contributor guide](CONTRIBUTING.md) - [Contributing](#contributing) - [Terms of Service review](#terms-of-service-review) - [Disclaimer](#disclaimer) @@ -164,6 +166,8 @@ Prefer to read before you pipe to bash? [The script is here](https://freellmapi. On Windows, the easiest path is the desktop **[`.exe` installer from Releases](https://github.com/tashfeenahmed/freellmapi/releases/latest)** (below); the Docker steps work in WSL or any bash shell. +On Android, see the experimental [Termux installation guide](docs/install/android-termux.md). It uses Node's built-in SQLite driver and does not require the Android NDK. + **Or manually with Docker Compose.** It runs the API and dashboard together on port 3001 and persists SQLite in a named volume. **Prerequisites:** Docker, Docker Compose, OpenSSL. diff --git a/client/src/components/keys/provider-list.tsx b/client/src/components/keys/provider-list.tsx index 1bddc1cb7..62cb49952 100644 --- a/client/src/components/keys/provider-list.tsx +++ b/client/src/components/keys/provider-list.tsx @@ -17,7 +17,7 @@ import { DropdownMenuItem, DropdownMenuCheckboxItem, } from '@/components/ui/dropdown-menu' -import { ChevronDown, ExternalLink, KeyRound, MoreHorizontal, Pencil, Plus, RefreshCw, Search, Trash2 } from 'lucide-react' +import { ChevronDown, CircleAlert, ExternalLink, KeyRound, MoreHorizontal, Pencil, Plus, RefreshCw, Search, Trash2 } from 'lucide-react' import type { ApiKey, ApiKeyModel } from '../../../../shared/types' import { formatSqliteUtcToLocalTime } from '@/lib/utils' import { useI18n } from '@/i18n' @@ -163,7 +163,7 @@ export function ProviderList({ onAddKey }: { onAddKey: () => void }) { } }, [editingKeyId]) - const healthKeyMap = new Map() + const healthKeyMap = new Map() for (const k of healthData?.keys ?? []) healthKeyMap.set(k.id, k) const statusOf = (k: ApiKey) => healthKeyMap.get(k.id)?.status ?? k.status @@ -346,7 +346,9 @@ export function ProviderList({ onAddKey }: { onAddKey: () => void }) {
{group.keys.map(k => { const status = statusOf(k) - const lastChecked = healthKeyMap.get(k.id)?.lastCheckedAt + const health = healthKeyMap.get(k.id) + const lastChecked = health?.lastCheckedAt ?? k.lastCheckedAt + const lastHealthError = health?.lastHealthError ?? k.lastHealthError const isEditing = editingKeyId === k.id const customModels = k.models ?? [] const hasCustomModels = customModels.length > 0 @@ -437,6 +439,12 @@ export function ProviderList({ onAddKey }: { onAddKey: () => void }) {
+ {lastHealthError && ( +
+ + {lastHealthError} +
+ )} {hasCustomModels && isExpanded && (
{customModels.map(model => { diff --git a/client/src/components/keys/shared.tsx b/client/src/components/keys/shared.tsx index c14daf069..ff80afdc7 100644 --- a/client/src/components/keys/shared.tsx +++ b/client/src/components/keys/shared.tsx @@ -38,7 +38,7 @@ export const PLATFORMS: { value: Platform; label: string; url: string; keyless?: { value: 'zhipu', label: 'Zhipu AI (Z.ai)', url: 'https://z.ai/manage-apikey/apikey-list' }, { value: 'ollama', label: 'Ollama Cloud', url: 'https://ollama.com/settings/keys' }, { value: 'kilo', label: 'Kilo Gateway (no key needed)', url: 'https://app.kilo.ai', keyless: true }, - { value: 'pollinations', label: 'Pollinations (no key needed)', url: 'https://pollinations.ai', keyless: true }, + { value: 'pollinations', label: 'Pollinations', url: 'https://enter.pollinations.ai' }, { value: 'ovh', label: 'OVH AI Endpoints (no key needed)', url: 'https://endpoints.ai.cloud.ovh.net', keyless: true }, { value: 'llm7', label: 'LLM7 (anon ok)', url: 'https://llm7.io' }, { value: 'huggingface', label: 'HuggingFace Router', url: 'https://huggingface.co/settings/tokens' }, @@ -110,6 +110,6 @@ export interface HealthPlatform { export interface HealthData { platforms: HealthPlatform[] - keys: { id: number; platform: string; status: string; lastCheckedAt: string | null }[] + keys: { id: number; platform: string; status: string; lastCheckedAt: string | null; lastHealthError: string | null }[] quotaStates: ProviderQuotaState[] } diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..375c316f1 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,38 @@ +# FreeLLMAPI documentation + +This index points to the focused guides in the repository. The root [README](../README.md) remains the product overview and complete API reference. + +## Install and deploy + +- [Quick start](../README.md#quick-start) — Docker one-liner, manual Docker Compose, and local development. +- [Docker deployment](../docker/README.md) — container configuration and persistent storage. +- [Desktop app](../desktop/README.md) — build and package the Electron application. +- [Android with Termux](install/android-termux.md) — experimental local installation using Node's built-in SQLite driver. + +## Configure and operate + +- [Declarative startup configuration](../README.md#declarative-startup-config) — configure keys and custom providers from environment variables. +- [Credentials and local data](../README.md#credentials-and-where-your-data-lives) — desktop paths and credential storage. +- [Premium live catalog](../README.md#premium-live-catalog) — catalog update behavior and licensing. +- [Database migrations](../server/src/db/README.md) — create, apply, inspect, and roll back schema migrations. + +## Integrate clients + +- [OpenAI-compatible clients](../README.md#works-with-openai-compatible-clients) — SDK and tool configuration. +- [Coding agents](../README.md#coding-agents) — Codex CLI, Claude Code, OpenCode, and MCP clients. +- [Using the API](../README.md#using-the-api) — request examples for chat, streaming, tools, images, and audio. +- [Embeddings](../README.md#embeddings) — embedding-model routing and custom endpoints. +- [Anthropic and Claude clients](../README.md#anthropic--claude-clients) — Messages API compatibility. + +## Develop and contribute + +- [Contributor guide](../CONTRIBUTING.md) — development loop, testing expectations, and contribution policy. +- [How the router works](../README.md#how-it-works) — architecture and fallback behavior. +- [Database migration guide](../server/src/db/README.md) — migration CLI and conventions. + +## Website assets in this directory + +- [`index.html`](index.html) — project landing page. +- [`install.sh`](install.sh) — Unix Docker bootstrap script. +- [`install.ps1`](install.ps1) — PowerShell bootstrap script. +- [`success.html`](success.html) — post-install success page. diff --git a/docs/install/android-termux.md b/docs/install/android-termux.md new file mode 100644 index 000000000..e6c54dce8 --- /dev/null +++ b/docs/install/android-termux.md @@ -0,0 +1,110 @@ +# Android (Termux) installation + +> Experimental and community-supported. FreeLLMAPI runs locally on the Android device. + +FreeLLMAPI can run in [Termux](https://termux.dev/) without an Android NDK toolchain. On Android, the server uses Node's built-in SQLite driver instead of the native `better-sqlite3` package. + +## Requirements + +- Android 7 or newer +- About 1 GB of free storage +- [Termux from F-Droid](https://f-droid.org/packages/com.termux/) (the Play Store build is outdated) +- Node.js 22.13 or newer; Node 24 LTS is recommended + +Do not mix Termux or add-on packages from different stores. + +## Install + +Update Termux and install the required packages: + +```bash +pkg update +pkg upgrade -y +pkg install -y nodejs-lts git +``` + +Confirm that Node is new enough for `node:sqlite`: + +```bash +node --version +``` + +Then clone and start FreeLLMAPI: + +```bash +git clone https://github.com/tashfeenahmed/freellmapi.git +cd freellmapi +npm install --no-audit --no-fund +npm run dev +``` + +An installation warning for the optional `better-sqlite3` package is harmless on Android. If npm asks you to approve dependency install scripts, approve the `esbuild` script and run `npm install` again. + +Open `http://localhost:5173` in a browser on the phone. The API is available at `http://localhost:3001/v1`. + +## Access from another device + +The dashboard contains API keys and administrative controls. Only expose it on a trusted network. + +```bash +HOST=0.0.0.0 npm run dev:lan +``` + +Find the phone's local address with Android's Wi-Fi settings or: + +```bash +ip addr show wlan0 +``` + +Open `http://PHONE_IP:5173` from another device on the same network. Do not port-forward these development servers directly to the internet; use a private VPN such as Tailscale if remote access is required. + +## Keep the process awake + +Android may suspend Termux when the screen turns off. Install the Termux:API add-on from the same store as Termux, then run: + +```bash +pkg install -y termux-api +termux-wake-lock +``` + +Release the lock when the server is stopped: + +```bash +termux-wake-unlock +``` + +## Troubleshooting + +### `node:sqlite` is unavailable + +Upgrade the Termux packages and verify that `node --version` reports 22.13 or newer: + +```bash +pkg update +pkg upgrade -y +pkg install -y nodejs-lts +``` + +### `concurrently: not found` + +The dependency installation did not complete. Run `npm install --no-audit --no-fund` again and address the first non-optional error it reports. + +### Port already in use + +Stop the process using port 3001 or 5173, or configure a different `PORT` in the repository's `.env` file. + +### The process stops when the screen turns off + +Use `termux-wake-lock` and disable battery optimization for Termux in Android settings. + +## Updating + +Stop the running development server, then run: + +```bash +git pull --ff-only +npm install --no-audit --no-fund +npm run dev +``` + +When reporting an Android issue, include the Android version, Termux source and version, device architecture, `node --version`, and the complete error output. diff --git a/package-lock.json b/package-lock.json index bbad2d0b8..0e9645276 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4271,6 +4271,7 @@ "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", "hasInstallScript": true, "license": "MIT", + "optional": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -4286,7 +4287,8 @@ "license": "MIT", "dependencies": { "file-uri-to-path": "1.0.0" - } + }, + "optional": true }, "node_modules/bl": { "version": "4.1.0", @@ -4297,7 +4299,8 @@ "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" - } + }, + "optional": true }, "node_modules/body-parser": { "version": "2.2.2", @@ -4401,7 +4404,8 @@ "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" - } + }, + "optional": true }, "node_modules/buffer-from": { "version": "1.1.2", @@ -4623,7 +4627,8 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/class-variance-authority": { "version": "0.7.1", @@ -5086,7 +5091,8 @@ }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "optional": true }, "node_modules/dedent": { "version": "1.7.2", @@ -5119,7 +5125,8 @@ "license": "MIT", "engines": { "node": ">=4.0.0" - } + }, + "optional": true }, "node_modules/deep-is": { "version": "0.1.4", @@ -5928,7 +5935,8 @@ "license": "MIT", "dependencies": { "once": "^1.4.0" - } + }, + "optional": true }, "node_modules/enhanced-resolve": { "version": "5.21.6", @@ -6377,7 +6385,8 @@ "license": "(MIT OR WTFPL)", "engines": { "node": ">=6" - } + }, + "optional": true }, "node_modules/expect-type": { "version": "1.3.0", @@ -6625,7 +6634,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/fill-range": { "version": "7.1.1", @@ -6732,7 +6742,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/fs-extra": { "version": "11.3.5", @@ -6889,7 +6900,8 @@ "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/glob-parent": { "version": "6.0.2", @@ -7215,7 +7227,8 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/inline-style-parser": { "version": "0.2.7", @@ -8970,7 +8983,8 @@ }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } + }, + "optional": true }, "node_modules/minimatch": { "version": "3.1.5", @@ -8998,7 +9012,8 @@ "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/ms": { "version": "2.1.3", @@ -9162,7 +9177,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/natural-compare": { "version": "1.4.0", @@ -9190,7 +9206,8 @@ }, "engines": { "node": ">=10" - } + }, + "optional": true }, "node_modules/node-abi/node_modules/semver": { "version": "7.8.1", @@ -9202,7 +9219,8 @@ }, "engines": { "node": ">=10" - } + }, + "optional": true }, "node_modules/node-domexception": { "version": "1.0.0", @@ -9739,7 +9757,8 @@ }, "engines": { "node": ">=10" - } + }, + "optional": true }, "node_modules/prelude-ls": { "version": "1.2.1", @@ -9819,7 +9838,8 @@ "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" - } + }, + "optional": true }, "node_modules/punycode": { "version": "2.3.1", @@ -9903,7 +9923,8 @@ }, "bin": { "rc": "cli.js" - } + }, + "optional": true }, "node_modules/rc/node_modules/strip-json-comments": { "version": "2.0.1", @@ -9912,7 +9933,8 @@ "license": "MIT", "engines": { "node": ">=0.10.0" - } + }, + "optional": true }, "node_modules/react": { "version": "19.2.6", @@ -10697,7 +10719,8 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/simple-get": { "version": "4.0.1", @@ -10722,7 +10745,8 @@ "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" - } + }, + "optional": true }, "node_modules/sisteransi": { "version": "1.0.5", @@ -11070,7 +11094,8 @@ "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" - } + }, + "optional": true }, "node_modules/tar-stream": { "version": "2.2.0", @@ -11086,7 +11111,8 @@ }, "engines": { "node": ">=6" - } + }, + "optional": true }, "node_modules/tiny-invariant": { "version": "1.3.3", @@ -11307,7 +11333,8 @@ }, "engines": { "node": "*" - } + }, + "optional": true }, "node_modules/tw-animate-css": { "version": "1.4.0", @@ -13212,7 +13239,6 @@ "version": "0.2.1", "dependencies": { "@freellmapi/shared": "*", - "better-sqlite3": "^12.10.0", "cors": "^2.8.5", "dotenv": "^17.4.2", "drizzle-orm": "^0.44.2", @@ -13237,6 +13263,9 @@ "engines": { "node": ">=20.18.0 <25.0.0", "npm": ">=10.0.0" + }, + "optionalDependencies": { + "better-sqlite3": "^12.10.0" } }, "server/node_modules/@types/node": { diff --git a/server/package.json b/server/package.json index 113440110..0d40ee36b 100644 --- a/server/package.json +++ b/server/package.json @@ -19,7 +19,6 @@ }, "dependencies": { "@freellmapi/shared": "*", - "better-sqlite3": "^12.10.0", "cors": "^2.8.5", "dotenv": "^17.4.2", "drizzle-orm": "^0.44.2", @@ -44,5 +43,8 @@ "engines": { "node": ">=20.18.0 <25.0.0", "npm": ">=10.0.0" + }, + "optionalDependencies": { + "better-sqlite3": "^12.10.0" } } diff --git a/server/src/__tests__/db/migrate/roundtrip.test.ts b/server/src/__tests__/db/migrate/roundtrip.test.ts index 2172fa4a6..cd5b2690f 100644 --- a/server/src/__tests__/db/migrate/roundtrip.test.ts +++ b/server/src/__tests__/db/migrate/roundtrip.test.ts @@ -12,6 +12,7 @@ const GITHUB_GPT41_CONTEXT_FILENAME = '20260630_000001_github_gpt41_context.ts'; const REQUEST_CLIENT_INFO_FILENAME = '20260706_000001_request_client_info.ts'; const CUSTOM_MODEL_TOOL_SUPPORT_FILENAME = '20260706_000002_custom_model_tool_support.ts'; const PROFILE_CHAIN_BACKFILL_FILENAME = '20260714_000001_profile_chain_backfill.ts'; +const KEY_HEALTH_ERROR_FILENAME = '20260720_000001_key_health_error.ts'; interface SchemaRow { type: string; @@ -70,6 +71,7 @@ describe('migration round trip', () => { REQUEST_CLIENT_INFO_FILENAME, CUSTOM_MODEL_TOOL_SUPPORT_FILENAME, PROFILE_CHAIN_BACKFILL_FILENAME, + KEY_HEALTH_ERROR_FILENAME, ]); } finally { db.close(); diff --git a/server/src/__tests__/db/node-sqlite.test.ts b/server/src/__tests__/db/node-sqlite.test.ts new file mode 100644 index 000000000..b2b5e43fe --- /dev/null +++ b/server/src/__tests__/db/node-sqlite.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { connectDb, defaultDbFactory } from '../../db/index.js'; +import { nodeSqliteFactory } from '../../db/node-sqlite.js'; +import { runMigrationsSync } from '../../db/migrate/runner.js'; +import type { Db } from '../../db/types.js'; + +describe('node:sqlite Android fallback', () => { + let db: Db | undefined; + const tempDirs: string[] = []; + + afterEach(() => { + db?.close?.(); + db = undefined; + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('is selected only for Android', () => { + expect(defaultDbFactory('android')).toBe(nodeSqliteFactory); + expect(defaultDbFactory('darwin')).not.toBe(nodeSqliteFactory); + expect(defaultDbFactory('linux')).not.toBe(nodeSqliteFactory); + }); + + it('runs the complete application migration set', () => { + db = connectDb(':memory:', { factory: nodeSqliteFactory, ensureDir: false }); + runMigrationsSync(db, 'up'); + + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as { name: string }[]; + expect(tables.map((row) => row.name)).toContain('api_keys'); + expect((db.prepare('SELECT COUNT(*) AS count FROM models').get() as { count: number }).count).toBeGreaterThan(0); + }); + + it('opens a file-backed database with WAL and file metadata', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'freellmapi-node-sqlite-')); + tempDirs.push(dir); + const dbPath = path.join(dir, 'freeapi.db'); + + db = connectDb(dbPath, { factory: nodeSqliteFactory }); + db.exec('CREATE TABLE sample (id INTEGER PRIMARY KEY, value TEXT NOT NULL)'); + db.prepare('INSERT INTO sample (value) VALUES (?)').run('persisted'); + + expect(db.name).toBe(dbPath); + expect(db.memory).toBe(false); + expect(fs.existsSync(dbPath)).toBe(true); + expect(db.pragma('journal_mode')).toEqual([{ journal_mode: 'wal' }]); + }); + + it('supports nested transaction commit and rollback semantics', () => { + db = connectDb(':memory:', { factory: nodeSqliteFactory, ensureDir: false }); + db.exec('CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL)'); + + const outer = db.transaction(() => { + db!.prepare('INSERT INTO items (value) VALUES (?)').run('outer'); + db!.transaction(() => { + db!.prepare('INSERT INTO items (value) VALUES (?)').run('inner'); + })(); + + try { + db!.transaction(() => { + db!.prepare('INSERT INTO items (value) VALUES (?)').run('rolled-back'); + throw new Error('rollback inner savepoint'); + })(); + } catch { + // The outer transaction remains usable and commits. + } + }); + + outer(); + + const rows = db.prepare('SELECT value FROM items ORDER BY id').all() as { value: string }[]; + expect(rows.map((row) => row.value)).toEqual(['outer', 'inner']); + }); +}); diff --git a/server/src/__tests__/lib/error-redaction.test.ts b/server/src/__tests__/lib/error-redaction.test.ts new file mode 100644 index 000000000..755c521c9 --- /dev/null +++ b/server/src/__tests__/lib/error-redaction.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { sanitizeProviderErrorMessage } from '../../lib/error-redaction.js'; + +describe('sanitizeProviderErrorMessage', () => { + it('redacts Bearer tokens', () => { + expect(sanitizeProviderErrorMessage('auth failed: Bearer abc.def-123')).toBe( + 'auth failed: Bearer [redacted]', + ); + }); + + it('redacts labeled secrets in plain form', () => { + expect(sanitizeProviderErrorMessage('api_key=supersecretvalue rejected')).toBe( + 'api_key=[redacted] rejected', + ); + }); + + it('redacts JSON-quoted secret labels', () => { + const out = sanitizeProviderErrorMessage('upstream said {"api_key": "supersecretvalue123"}'); + expect(out).not.toContain('supersecretvalue123'); + expect(out).toContain('"api_key": "[redacted]'); + }); + + it('redacts bare high-entropy tokens with no known prefix', () => { + const out = sanitizeProviderErrorMessage('key co-A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8 was rejected'); + expect(out).not.toContain('A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8'); + expect(out).toContain('[redacted-token]'); + }); + + it('redacts URLs including key-bearing query strings', () => { + const out = sanitizeProviderErrorMessage('GET https://api.example.com/v1?key=sk-abc123 failed'); + expect(out).not.toContain('api.example.com'); + expect(out).toContain('[redacted-url]'); + }); + + it('leaves ordinary provider prose and model ids intact', () => { + const msg = 'model llama-3.3-70b-versatile is over capacity, retry after 30s (HTTP 429)'; + expect(sanitizeProviderErrorMessage(msg)).toBe(msg); + }); + + it('caps message length', () => { + const out = sanitizeProviderErrorMessage('x '.repeat(400)); + expect(out.length).toBeLessThanOrEqual(240); + }); +}); diff --git a/server/src/__tests__/providers/cloudflare.test.ts b/server/src/__tests__/providers/cloudflare.test.ts index a36d65803..56b8adbe7 100644 --- a/server/src/__tests__/providers/cloudflare.test.ts +++ b/server/src/__tests__/providers/cloudflare.test.ts @@ -90,15 +90,23 @@ describe('CloudflareProvider', () => { expect(calls[1]).toBe('https://api.cloudflare.com/client/v4/accounts/acc123/tokens/verify'); }); - it('returns false only when both scopes reject the token', async () => { + it('fails with the upstream reason when both scopes reject the token', async () => { vi.spyOn(global, 'fetch').mockImplementation(async () => { - return { ok: false, status: 403, json: () => Promise.resolve({}) } as any; + return { + ok: false, + status: 403, + statusText: 'Forbidden', + json: () => Promise.resolve({ success: false, errors: [{ code: 9109, message: 'Invalid access token' }] }), + } as any; }); - expect(await provider.validateKey('acc123:tok')).toBe(false); + const result = await provider.validateKey('acc123:tok'); + expect(result).toMatchObject({ valid: false }); + expect((result as any).error).toContain('HTTP 403'); + expect((result as any).error).toContain('Invalid access token'); }); - it('returns false when the account scope reports an inactive token', async () => { + it('fails with the token status when the account scope reports an inactive token', async () => { vi.spyOn(global, 'fetch').mockImplementation(async (url) => { if ((url as string).includes('/user/tokens/verify')) { return { ok: false, status: 403, json: () => Promise.resolve({}) } as any; @@ -110,7 +118,9 @@ describe('CloudflareProvider', () => { } as any; }); - expect(await provider.validateKey('acc123:tok')).toBe(false); + const result = await provider.validateKey('acc123:tok'); + expect(result).toMatchObject({ valid: false }); + expect((result as any).error).toContain('token status is "disabled"'); }); }); diff --git a/server/src/__tests__/providers/google.test.ts b/server/src/__tests__/providers/google.test.ts index 4de07d141..e77595e0d 100644 --- a/server/src/__tests__/providers/google.test.ts +++ b/server/src/__tests__/providers/google.test.ts @@ -86,7 +86,7 @@ describe('GoogleProvider', () => { expect(await provider.validateKey('valid-key')).toBe(true); vi.spyOn(global, 'fetch').mockResolvedValueOnce({ ok: false, status: 401 } as any); - expect(await provider.validateKey('invalid-key')).toBe(false); + expect(await provider.validateKey('invalid-key')).toMatchObject({ valid: false }); }); // #268: Google reports a bad key as HTTP 400 INVALID_ARGUMENT / API_KEY_INVALID, @@ -104,7 +104,7 @@ describe('GoogleProvider', () => { }, }), } as any); - expect(await provider.validateKey('bad-key')).toBe(false); + expect(await provider.validateKey('bad-key')).toMatchObject({ valid: false }); }); // #268: a permission/region/restriction 403 (e.g. API not enabled on the project, diff --git a/server/src/__tests__/providers/openai-compat.test.ts b/server/src/__tests__/providers/openai-compat.test.ts index ce689083c..0fc2b5d94 100644 --- a/server/src/__tests__/providers/openai-compat.test.ts +++ b/server/src/__tests__/providers/openai-compat.test.ts @@ -319,9 +319,17 @@ describe('OpenAICompatProvider', () => { expect(await provider.validateKey('valid')).toBe(true); }); - it('validateKey returns false on confirmed 401', async () => { - vi.spyOn(global, 'fetch').mockResolvedValueOnce({ ok: false, status: 401 } as any); - expect(await provider.validateKey('bad')).toBe(false); + it('validateKey preserves the provider reason on confirmed 401', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + json: () => Promise.resolve({ error: { message: 'This token has expired' } }), + } as any); + expect(await provider.validateKey('bad')).toEqual({ + valid: false, + error: 'TestProvider key validation failed (HTTP 401): This token has expired', + }); }); it('validateKey propagates transport errors instead of swallowing', async () => { @@ -494,6 +502,7 @@ describe('OpenAICompatProvider - platform instances', () => { { platform: 'mistral', name: 'Mistral', baseUrl: 'https://api.mistral.ai/v1' }, { platform: 'openrouter', name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1' }, { platform: 'github', name: 'GitHub Models', baseUrl: 'https://models.github.ai/inference' }, + { platform: 'pollinations', name: 'Pollinations', baseUrl: 'https://gen.pollinations.ai/v1' }, { platform: 'zhipu', name: 'Zhipu AI', baseUrl: 'https://open.bigmodel.cn/api/paas/v4' }, { platform: 'opencode', name: 'OpenCode Zen', baseUrl: 'https://opencode.ai/zen/v1' }, { platform: 'aion', name: 'Aion Labs', baseUrl: 'https://api.aionlabs.ai/v1' }, @@ -526,6 +535,37 @@ describe('OpenAICompatProvider - platform instances', () => { }); } + it('omits greedy temperature=0 for Requesty Leanstral and supplies neutral top_p', async () => { + const provider = new OpenAICompatProvider({ + platform: 'requesty', + name: 'Requesty', + baseUrl: 'https://router.requesty.ai/v1', + }); + let body: Record = {}; + vi.spyOn(global, 'fetch').mockImplementation(async (_url, init) => { + body = JSON.parse(String((init as RequestInit).body)); + return { + ok: true, + headers: new Headers(), + json: () => Promise.resolve({ + id: 'id', object: 'chat.completion', created: 1, model: 'mistral/leanstral-1-5', + choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + } as any; + }); + + await provider.chatCompletion( + 'key', + [{ role: 'user', content: 'hi' }], + 'mistral/leanstral-1-5', + { temperature: 0 }, + ); + + expect(body.temperature).toBeUndefined(); + expect(body.top_p).toBe(1); + }); + // #264: Groq (and others) reject a model's inline tool-call dialect with a 400 // `tool_use_failed`, handing back the raw text in `error.failed_generation`. // We rescue it into structured tool_calls instead of dead-ending the turn. diff --git a/server/src/__tests__/providers/reasoning-timeouts.test.ts b/server/src/__tests__/providers/reasoning-timeouts.test.ts index eab9ba271..33dabbe79 100644 --- a/server/src/__tests__/providers/reasoning-timeouts.test.ts +++ b/server/src/__tests__/providers/reasoning-timeouts.test.ts @@ -29,7 +29,7 @@ describe('reasoning-model chat timeouts', () => { expect((provider as unknown as { timeoutMs: number }).timeoutMs).toBe(ms); }); - it('cloudflare chat aborts on a 60s timer, not the 15s default', async () => { + it('cloudflare GLM 4.7 Flash gets its live-verified 200s per-model timeout', async () => { const provider = new CloudflareProvider(); const delays: number[] = []; const origSetTimeout = global.setTimeout; @@ -54,7 +54,7 @@ describe('reasoning-model chat timeouts', () => { '@cf/zai-org/glm-4.7-flash', ); - expect(delays).toContain(60_000); + expect(delays).toContain(200_000); expect(delays).not.toContain(15_000); }); }); diff --git a/server/src/__tests__/routes/proxy-pinned-model.test.ts b/server/src/__tests__/routes/proxy-pinned-model.test.ts index b0e2cc2d0..1e59b3632 100644 --- a/server/src/__tests__/routes/proxy-pinned-model.test.ts +++ b/server/src/__tests__/routes/proxy-pinned-model.test.ts @@ -70,7 +70,7 @@ describe('requested_model analytics logging', () => { return { ok: true, json: () => Promise.resolve({ - id: 'chatcmpl-pin', object: 'chat.completion', created: 1, model: groqModelId, + id: 'chatcmpl-pin', object: 'chat.completion', created: 1, model: 'default', choices: [{ index: 0, message: { role: 'assistant', content: 'hi' }, finish_reason: 'stop' }], usage: { prompt_tokens: 2, completion_tokens: 1, total_tokens: 3 }, }), @@ -85,11 +85,14 @@ describe('requested_model analytics logging', () => { }); it('logs the pinned model id when the client names a model', async () => { - const { status } = await request(app, 'POST', '/v1/chat/completions', { + const { status, body } = await request(app, 'POST', '/v1/chat/completions', { model: groqModelId, messages: [{ role: 'user', content: 'hi' }], }, authHeaders()); expect(status).toBe(200); + // Provider metadata is not forwarded blindly: Reka returns "default" for + // concrete models, and any compatible provider may do the same (#568). + expect(body.model).toBe(groqModelId); const row = getDb().prepare('SELECT model_id, requested_model FROM requests ORDER BY id DESC LIMIT 1').get() as any; expect(row.requested_model).toBe(groqModelId); diff --git a/server/src/__tests__/routes/proxy-stream-integrity.test.ts b/server/src/__tests__/routes/proxy-stream-integrity.test.ts index 3aee04e51..9716cfd4e 100644 --- a/server/src/__tests__/routes/proxy-stream-integrity.test.ts +++ b/server/src/__tests__/routes/proxy-stream-integrity.test.ts @@ -54,7 +54,7 @@ function mockUpstream(script: Array<{ body: string; status?: number }>) { const urlStr = typeof url === 'string' ? url : url.toString(); // Only intercept provider upstreams; the test's own localhost request // and anything else goes through. - if (!/api\.groq\.com|openrouter\.ai|api\.cohere|generativelanguage|integrate\.api\.nvidia|api\.cerebras|api\.mistral|router\.huggingface|api\.cloudflare|models\.github|open\.bigmodel|api\.llm7|api\.kilo|text\.pollinations|ollama\.com|opencode\.ai|api\.aionlabs\.ai|router\.requesty\.ai|api\.navy|router\.bynara\.id/.test(urlStr)) { + if (!/api\.groq\.com|openrouter\.ai|api\.cohere|generativelanguage|integrate\.api\.nvidia|api\.cerebras|api\.mistral|router\.huggingface|api\.cloudflare|models\.github|open\.bigmodel|api\.llm7|api\.kilo|gen\.pollinations|ollama\.com|opencode\.ai|api\.aionlabs\.ai|router\.requesty\.ai|api\.navy|router\.bynara\.id/.test(urlStr)) { return origFetch(url as any, init); } const reqBody = JSON.parse(String((init as RequestInit).body)); @@ -252,7 +252,7 @@ describe('proxy stream turn-integrity', () => { }); it('streams ordinary text through unmodified, always ending in a terminal finish_reason', async () => { - mockUpstream([ + const up = mockUpstream([ { body: sse(roleChunk, textChunk('Hello'), textChunk(' world'), finishChunk('stop'), '[DONE]') }, ]); const r = await request(app, '/v1/chat/completions', { @@ -262,6 +262,9 @@ describe('proxy stream turn-integrity', () => { const text = fs.map(f => f.choices?.[0]?.delta?.content ?? '').join(''); expect(text).toBe('Hello world'); expect(fs.map(f => f.choices?.[0]?.finish_reason).filter(Boolean)).toEqual(['stop']); + // The crafted upstream frames all advertise model "m". Public frames must + // instead identify the concrete model selected by the router (#568). + expect(fs.every(f => f.error || f.model === up.seen[0].model)).toBe(true); expect(r.text.trim().endsWith('data: [DONE]')).toBe(true); }); diff --git a/server/src/__tests__/services/catalog-sync.test.ts b/server/src/__tests__/services/catalog-sync.test.ts index 70cbad1b3..0e725f65a 100644 --- a/server/src/__tests__/services/catalog-sync.test.ts +++ b/server/src/__tests__/services/catalog-sync.test.ts @@ -72,6 +72,28 @@ function existingAsCatalogModels(): AnyCatalog['models'] { ); } +type CatalogEmbedding = NonNullable[number]; + +function existingAsCatalogEmbeddings(): CatalogEmbedding[] { + const rows = getDb().prepare(` + SELECT family, platform, model_id, display_name, dimensions, + max_input_tokens, priority, enabled, quota_label + FROM embedding_models + WHERE platform != 'custom' AND key_id IS NULL + `).all() as Array>; + return rows.map((r) => ({ + family: r.family as string, + platform: r.platform as string, + modelId: r.model_id as string, + displayName: r.display_name as string, + dimensions: r.dimensions as number, + maxInputTokens: r.max_input_tokens as number | null, + priority: r.priority as number, + enabled: (r.enabled as number) === 1, + quotaLabel: r.quota_label as string, + })); +} + describe('applyCatalog', () => { beforeAll(() => { process.env.ENCRYPTION_KEY = '0'.repeat(64); @@ -219,6 +241,44 @@ describe('applyCatalog', () => { expect(getDb().prepare("SELECT id FROM models WHERE platform = 'some-future-provider'").get()).toBeUndefined(); }); + it('applies the full embedding snapshot and retires a replaced provider id', () => { + const embeddings = existingAsCatalogEmbeddings(); + const openRouter = embeddings.find( + (m) => m.platform === 'openrouter' && m.modelId === 'nvidia/llama-nemotron-embed-vl-1b-v2', + ); + expect(openRouter).toBeDefined(); + openRouter!.modelId = 'nvidia/llama-nemotron-embed-vl-1b-v2:free'; + openRouter!.displayName = 'Nemotron Embed VL 1B (OR free)'; + embeddings.push({ + family: 'sea-lion-e5-embedding-600m', + platform: 'sealion', + modelId: 'aisingapore/SEA-LION-E5-Embedding-600M', + displayName: 'SEA-LION E5 Embedding 600M', + dimensions: 1024, + maxInputTokens: 8192, + priority: 1, + enabled: true, + quotaLabel: 'free · 10 rpm', + }); + const catalog = catalogOf(existingAsCatalogModels()); + catalog.embeddings = embeddings; + + applyCatalog(getDb(), catalog); + + expect(getDb().prepare(` + SELECT id FROM embedding_models + WHERE platform = 'openrouter' AND model_id = 'nvidia/llama-nemotron-embed-vl-1b-v2' + `).get()).toBeUndefined(); + expect(getDb().prepare(` + SELECT id FROM embedding_models + WHERE platform = 'openrouter' AND model_id = 'nvidia/llama-nemotron-embed-vl-1b-v2:free' + `).get()).toBeDefined(); + expect(getDb().prepare(` + SELECT dimensions FROM embedding_models + WHERE platform = 'sealion' AND model_id = 'aisingapore/SEA-LION-E5-Embedding-600M' + `).get()).toEqual({ dimensions: 1024 }); + }); + it('replaces quirks wholesale', () => { const quirks: AnyCatalog['quirks'] = [ { diff --git a/server/src/__tests__/services/embeddings.test.ts b/server/src/__tests__/services/embeddings.test.ts index a05a9633f..36632a802 100644 --- a/server/src/__tests__/services/embeddings.test.ts +++ b/server/src/__tests__/services/embeddings.test.ts @@ -175,6 +175,23 @@ describe('embeddings service', () => { expect(String(fetchMock.mock.calls[0][0])).toContain('feature-extraction'); }); + it('routes SEA-LION catalog embeddings through its OpenAI-compatible endpoint', async () => { + getDb().prepare(` + INSERT INTO embedding_models + (family, platform, model_id, display_name, dimensions, max_input_tokens, priority, enabled, quota_label) + VALUES + ('sea-lion-e5-embedding-600m', 'sealion', 'aisingapore/SEA-LION-E5-Embedding-600M', + 'SEA-LION E5 Embedding 600M', 1024, 8192, 1, 1, 'free · 10 rpm') + `).run(); + addKey('sealion'); + const fetchMock = mockFetch(async () => okEmbeddingResponse(1024)); + + const result = await runEmbeddings('sea-lion-e5-embedding-600m', ['hello']); + + expect(result.platform).toBe('sealion'); + expect(String(fetchMock.mock.calls[0][0])).toBe('https://api.sea-lion.ai/v1/embeddings'); + }); + it('rejects malformed upstream payloads and fails over', async () => { addKey('nvidia'); addKey('openrouter'); diff --git a/server/src/__tests__/services/health-error.test.ts b/server/src/__tests__/services/health-error.test.ts new file mode 100644 index 000000000..48413990e --- /dev/null +++ b/server/src/__tests__/services/health-error.test.ts @@ -0,0 +1,77 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { encrypt } from '../../lib/crypto.js'; +import { getDb, initDb } from '../../db/index.js'; + +const validateKey = vi.hoisted(() => vi.fn()); + +vi.mock('../../providers/index.js', () => ({ + resolveProvider: () => ({ + name: 'Mistral', + validateKey, + }), +})); + +const { checkKeyHealth } = await import('../../services/health.js'); + +describe('persisted key health diagnostics', () => { + let nextId = 4000; + + beforeAll(() => { + process.env.ENCRYPTION_KEY = '0'.repeat(64); + initDb(':memory:'); + }); + + beforeEach(() => { + validateKey.mockReset(); + vi.restoreAllMocks(); + }); + + function seedKey(lastError: string | null = null): number { + const id = ++nextId; + const encrypted = encrypt('mistral-health-test-key'); + getDb().prepare(` + INSERT INTO api_keys + (id, platform, label, encrypted_key, iv, auth_tag, enabled, status, last_health_error) + VALUES (?, 'mistral', 'health-test', ?, ?, ?, 1, 'unknown', ?) + `).run(id, encrypted.encrypted, encrypted.iv, encrypted.authTag, lastError); + return id; + } + + function row(id: number): { status: string; last_health_error: string | null } { + return getDb().prepare('SELECT status, last_health_error FROM api_keys WHERE id = ?').get(id) as any; + } + + it('stores and logs a confirmed provider rejection reason', async () => { + const id = seedKey(); + validateKey.mockResolvedValue({ + valid: false, + error: 'Mistral key validation failed (HTTP 401): token expired', + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(await checkKeyHealth(id)).toBe('invalid'); + expect(row(id)).toEqual({ + status: 'invalid', + last_health_error: 'Mistral key validation failed (HTTP 401): token expired', + }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('token expired')); + }); + + it('redacts secrets before persisting or logging transport errors', async () => { + const id = seedKey(); + validateKey.mockRejectedValue(new Error('Bearer secret-token-value-1234567890 failed at https://api.example.test/models')); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(await checkKeyHealth(id)).toBe('error'); + expect(row(id).last_health_error).toBe('Bearer [redacted] failed at [redacted-url]'); + expect(String(error.mock.calls[0][0])).not.toContain('secret-token-value'); + }); + + it('clears the previous reason after a successful probe', async () => { + const id = seedKey('old failure'); + validateKey.mockResolvedValue(true); + + expect(await checkKeyHealth(id)).toBe('healthy'); + expect(row(id)).toEqual({ status: 'healthy', last_health_error: null }); + }); +}); diff --git a/server/src/__tests__/services/health-log.test.ts b/server/src/__tests__/services/health-log.test.ts index 849c0df0b..56b25b7ca 100644 --- a/server/src/__tests__/services/health-log.test.ts +++ b/server/src/__tests__/services/health-log.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; -import { initDb } from '../../db/index.js'; +import { getDb, initDb } from '../../db/index.js'; import { encrypt } from '../../lib/crypto.js'; // Mock the providers module BEFORE importing the service so the test never @@ -71,6 +71,8 @@ describe('checkKeyHealth transport-error log format', () => { expect(m[2]).toBe('openai-compat'); expect(m[3]).toBe('https://api.example.com/v1'); expect(m[4]).toBe('mocked transport failure'); + const row = getDb().prepare('SELECT last_health_error FROM api_keys WHERE id = ?').get(id) as { last_health_error: string }; + expect(row.last_health_error).toBe('mocked transport failure'); }); it('falls back to base=default when base_url is null', async () => { @@ -92,4 +94,4 @@ describe('checkKeyHealth transport-error log format', () => { // The cron regex in scripts/freellmapi-watchdog.sh is anchored on this prefix. expect(line.startsWith(`[Health] Key ${id} (`)).toBe(true); }); -}); \ No newline at end of file +}); diff --git a/server/src/__tests__/services/media.test.ts b/server/src/__tests__/services/media.test.ts index 655722a53..17ab296c9 100644 --- a/server/src/__tests__/services/media.test.ts +++ b/server/src/__tests__/services/media.test.ts @@ -153,43 +153,110 @@ describe('media service', () => { it('Cloudflare MeloTTS: base64 audio → audio/mpeg bytes', async () => { addMedia('cloudflare', '@cf/myshell-ai/melotts', 'audio'); addKey('cloudflare', 'acct:tok'); - globalThis.fetch = vi.fn(async () => jsonResponse({ result: { audio: Buffer.from('MP3').toString('base64') } })) as any; - const r = await runSpeech('@cf/myshell-ai/melotts', { input: 'hi' }); + const fetchMock = vi.fn(async () => jsonResponse({ result: { audio: Buffer.from('MP3').toString('base64') } })); + globalThis.fetch = fetchMock as any; + const r = await runSpeech('@cf/myshell-ai/melotts', { input: 'hi', voice: 'alloy' }); expect(r.contentType).toBe('audio/mpeg'); expect(r.audio.toString()).toBe('MP3'); + const body = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body)); + expect(body).toMatchObject({ prompt: 'hi', lang: 'en' }); }); - it('SiliconFlow CosyVoice: raw audio bytes', async () => { + it('SiliconFlow CosyVoice: maps OpenAI voices, accepts native names, and defaults unknown names', async () => { addMedia('siliconflow', 'FunAudioLLM/CosyVoice2-0.5B', 'audio'); addKey('siliconflow'); - globalThis.fetch = vi.fn(async () => - new Response(Buffer.from('COSY'), { status: 200, headers: { 'content-type': 'audio/mpeg' } })) as any; - const r = await runSpeech('FunAudioLLM/CosyVoice2-0.5B', { input: 'hi' }); + const fetchMock = vi.fn(async () => + new Response(Buffer.from('COSY'), { status: 200, headers: { 'content-type': 'audio/mpeg' } })); + globalThis.fetch = fetchMock as any; + const r = await runSpeech('FunAudioLLM/CosyVoice2-0.5B', { input: 'hi', voice: 'nova' }); + await runSpeech('FunAudioLLM/CosyVoice2-0.5B', { input: 'hi', voice: 'diana' }); + await runSpeech('FunAudioLLM/CosyVoice2-0.5B', { input: 'hi', voice: 'not-a-voice' }); expect(r.contentType).toBe('audio/mpeg'); expect(r.audio.toString()).toBe('COSY'); + const voices = fetchMock.mock.calls.map(call => + JSON.parse(String((call[1] as RequestInit).body)).voice, + ); + expect(voices).toEqual([ + 'FunAudioLLM/CosyVoice2-0.5B:anna', + 'FunAudioLLM/CosyVoice2-0.5B:diana', + 'FunAudioLLM/CosyVoice2-0.5B:alex', + ]); }); - it('Pollinations openai-audio: message.audio.data → bytes (keyless)', async () => { + it('Pollinations openai-audio: keeps OpenAI voices and defaults unknown names (keyless)', async () => { addMedia('pollinations', 'openai-audio', 'audio'); - globalThis.fetch = vi.fn(async () => - jsonResponse({ choices: [{ message: { audio: { data: Buffer.from('POLLY').toString('base64') } } }] })) as any; - const r = await runSpeech('openai-audio', { input: 'hi' }); + const fetchMock = vi.fn(async () => + jsonResponse({ choices: [{ message: { audio: { data: Buffer.from('POLLY').toString('base64') } } }] })); + globalThis.fetch = fetchMock as any; + const r = await runSpeech('openai-audio', { input: 'hi', voice: 'shimmer' }); + await runSpeech('openai-audio', { input: 'hi', voice: 'not-a-voice' }); expect(r.audio.toString()).toBe('POLLY'); + const voices = fetchMock.mock.calls.map(call => + JSON.parse(String((call[1] as RequestInit).body)).audio.voice, + ); + expect(voices).toEqual(['shimmer', 'alloy']); }); - it('Gemini TTS: base64 PCM wrapped as WAV (RIFF header)', async () => { + it('Gemini TTS: maps OpenAI voices and wraps base64 PCM as WAV', async () => { addMedia('google', 'gemini-2.5-flash-preview-tts', 'audio'); addKey('google'); const pcm = Buffer.from([1, 2, 3, 4]); - globalThis.fetch = vi.fn(async () => jsonResponse({ + const fetchMock = vi.fn(async () => jsonResponse({ candidates: [{ content: { parts: [{ inlineData: { mimeType: 'audio/L16;codec=pcm;rate=24000', data: pcm.toString('base64') } }] } }], - })) as any; - const r = await runSpeech('gemini-2.5-flash-preview-tts', { input: 'hi' }); + })); + globalThis.fetch = fetchMock as any; + const r = await runSpeech('gemini-2.5-flash-preview-tts', { input: 'hi', voice: 'alloy' }); expect(r.contentType).toBe('audio/wav'); expect(r.audio.subarray(0, 4).toString()).toBe('RIFF'); expect(r.audio.subarray(8, 12).toString()).toBe('WAVE'); // header (44) + 4 PCM bytes expect(r.audio.length).toBe(48); + const body = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body)); + expect(body.generationConfig.speechConfig.voiceConfig.prebuiltVoiceConfig.voiceName).toBe('Schedar'); + }); + + it('Gemini TTS: canonicalizes native voices and defaults unknown names', async () => { + addMedia('google', 'gemini-2.5-flash-preview-tts', 'audio'); + addKey('google'); + const pcm = Buffer.from([1, 2]); + const fetchMock = vi.fn(async () => jsonResponse({ + candidates: [{ content: { parts: [{ inlineData: { mimeType: 'audio/L16;rate=24000', data: pcm.toString('base64') } }] } }], + })); + globalThis.fetch = fetchMock as any; + + await runSpeech('gemini-2.5-flash-preview-tts', { input: 'hi', voice: 'achernar' }); + await runSpeech('gemini-2.5-flash-preview-tts', { input: 'hi', voice: 'not-a-voice' }); + + const voices = fetchMock.mock.calls.map(call => { + const body = JSON.parse(String((call[1] as RequestInit).body)); + return body.generationConfig.speechConfig.voiceConfig.prebuiltVoiceConfig.voiceName; + }); + expect(voices).toEqual(['Achernar', 'Kore']); + }); + + it('uses provider-safe voices during failover without losing a native Gemini voice', async () => { + addMedia('cloudflare', '@cf/myshell-ai/melotts', 'audio', 1); + addKey('cloudflare', 'acct:tok'); + addMedia('google', 'gemini-2.5-flash-preview-tts', 'audio', 2); + addKey('google'); + const pcm = Buffer.from([1, 2]); + const fetchMock = vi.fn(async (url: string | URL | Request) => { + if (String(url).includes('cloudflare.com')) { + return new Response('temporarily unavailable', { status: 503 }); + } + return jsonResponse({ + candidates: [{ content: { parts: [{ inlineData: { mimeType: 'audio/L16;rate=24000', data: pcm.toString('base64') } }] } }], + }); + }); + globalThis.fetch = fetchMock as any; + + const result = await runSpeech('auto', { input: 'hi', voice: 'achernar' }); + + expect(result.platform).toBe('google'); + const cloudflareBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body)); + const googleBody = JSON.parse(String((fetchMock.mock.calls[1][1] as RequestInit).body)); + expect(cloudflareBody.lang).toBe('en'); + expect(googleBody.generationConfig.speechConfig.voiceConfig.prebuiltVoiceConfig.voiceName).toBe('Achernar'); }); it('custom audio models call the bound OpenAI-compatible endpoint', async () => { diff --git a/server/src/__tests__/services/quirks.test.ts b/server/src/__tests__/services/quirks.test.ts index a0d82c414..fcf947c2b 100644 --- a/server/src/__tests__/services/quirks.test.ts +++ b/server/src/__tests__/services/quirks.test.ts @@ -51,9 +51,10 @@ describe('quirks resolution (migrateQuirksV1)', () => { const defs = listQuirkDefinitions(); const keyless = defs.find((d) => d.slug === 'keyless-anonymous'); expect(keyless).toBeDefined(); - // keyless-anonymous targets the four keyless platforms. + // Pollinations chat now requires a publishable key; three anonymous + // providers remain in the baseline selector. const platforms = keyless!.targets.map((t) => t.platform).sort(); - expect(platforms).toEqual(['kilo', 'llm7', 'ovh', 'pollinations']); + expect(platforms).toEqual(['kilo', 'llm7', 'ovh']); }); it('resolves stably and dedups overlapping selectors', () => { diff --git a/server/src/db/index.ts b/server/src/db/index.ts index 9bf6e79c2..d728daf7c 100644 --- a/server/src/db/index.ts +++ b/server/src/db/index.ts @@ -1,16 +1,18 @@ import crypto from 'crypto'; -import BetterSqlite from 'better-sqlite3'; import fs from 'fs'; import path from 'path'; +import { createRequire } from 'node:module'; import { fileURLToPath } from 'url'; import { runMigrationsSync } from './migrate/runner.js'; import { initEncryptionKey, isEncryptionKeyInitialized } from '../lib/crypto.js'; +import { nodeSqliteFactory } from './node-sqlite.js'; import type { Db, DbFactory } from './types.js'; export type { Db, DbFactory } from './types.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DB_PATH = path.resolve(__dirname, '../../data/freeapi.db'); +const runtimeRequire = createRequire(import.meta.url); let db: Db; @@ -27,7 +29,20 @@ export function getDefaultDbPath(): string { /** Default factory: opens a better-sqlite3 connection at the given path. */ function betterSqliteFactory(resolvedPath: string): Db { - return new BetterSqlite(resolvedPath) as unknown as Db; + let BetterSqlite: new (path: string) => unknown; + try { + BetterSqlite = runtimeRequire('better-sqlite3') as new (path: string) => unknown; + } catch (cause) { + throw new Error( + 'better-sqlite3 is not installed. Reinstall dependencies, or use Node.js 22.13+ on Android/Termux.', + { cause }, + ); + } + return new BetterSqlite(resolvedPath) as Db; +} + +export function defaultDbFactory(platform: NodeJS.Platform = process.platform): DbFactory { + return platform === 'android' ? nodeSqliteFactory : betterSqliteFactory; } export function connectDb( @@ -43,7 +58,7 @@ export function connectDb( const resolvedPath = dbPath ?? getDefaultDbPath(); const isMemory = resolvedPath === ':memory:'; const ensureDir = opts?.ensureDir ?? true; - const factory = opts?.factory ?? betterSqliteFactory; + const factory = opts?.factory ?? defaultDbFactory(); if (!isMemory && ensureDir) { const dataDir = path.dirname(resolvedPath); diff --git a/server/src/db/migrate/defaults.ts b/server/src/db/migrate/defaults.ts index b0f263d6d..daa9c526d 100644 --- a/server/src/db/migrate/defaults.ts +++ b/server/src/db/migrate/defaults.ts @@ -7,6 +7,7 @@ import * as githubGpt41Context from '../migrations/20260630_000001_github_gpt41_ import * as requestClientInfo from '../migrations/20260706_000001_request_client_info.js'; import * as customModelToolSupport from '../migrations/20260706_000002_custom_model_tool_support.js'; import * as profileChainBackfill from '../migrations/20260714_000001_profile_chain_backfill.js'; +import * as keyHealthError from '../migrations/20260720_000001_key_health_error.js'; export interface MigrationModule { up(db: Db): void; @@ -26,6 +27,7 @@ export const GITHUB_GPT41_CONTEXT_FILENAME = '20260630_000001_github_gpt41_conte export const REQUEST_CLIENT_INFO_FILENAME = '20260706_000001_request_client_info.ts'; export const CUSTOM_MODEL_TOOL_SUPPORT_FILENAME = '20260706_000002_custom_model_tool_support.ts'; export const PROFILE_CHAIN_BACKFILL_FILENAME = '20260714_000001_profile_chain_backfill.ts'; +export const KEY_HEALTH_ERROR_FILENAME = '20260720_000001_key_health_error.ts'; export const DEFAULT_MIGRATIONS: readonly DefaultMigration[] = [ { filename: LEGACY_BASELINE_FILENAME, module: legacyBaseline }, @@ -36,4 +38,5 @@ export const DEFAULT_MIGRATIONS: readonly DefaultMigration[] = [ { filename: REQUEST_CLIENT_INFO_FILENAME, module: requestClientInfo }, { filename: CUSTOM_MODEL_TOOL_SUPPORT_FILENAME, module: customModelToolSupport }, { filename: PROFILE_CHAIN_BACKFILL_FILENAME, module: profileChainBackfill }, + { filename: KEY_HEALTH_ERROR_FILENAME, module: keyHealthError }, ]; diff --git a/server/src/db/migrations/20260101_000000_legacy_baseline.ts b/server/src/db/migrations/20260101_000000_legacy_baseline.ts index dbedf3246..a09e2da80 100644 --- a/server/src/db/migrations/20260101_000000_legacy_baseline.ts +++ b/server/src/db/migrations/20260101_000000_legacy_baseline.ts @@ -2114,7 +2114,7 @@ function migrateQuirksV1(db: Db) { title: 'No API key required', body: 'Routes anonymously — the catalog ships a keyless sentinel row and calls work with no account or key.', severity: 'info', - targets: [{ platform: 'kilo' }, { platform: 'llm7' }, { platform: 'pollinations' }, { platform: 'ovh' }], + targets: [{ platform: 'kilo' }, { platform: 'llm7' }, { platform: 'ovh' }], }, { slug: 'ovh-anon-trickle', @@ -2125,8 +2125,8 @@ function migrateQuirksV1(db: Db) { }, { slug: 'pollinations-degraded', - title: 'Anon tier degraded (1 concurrent)', - body: 'Pollinations’ legacy text API is deprecated for authenticated users (replacement enter.pollinations.ai is pay-as-you-go), but anonymous access is explicitly unaffected. Anon is queue-limited to 1 concurrent request per IP and serves a single model (openai-fast); expect 429 "Queue full" under any parallelism. Live-probed 2026-06-10.', + title: 'Publishable key uses recurring shared capacity', + body: 'Pollinations chat uses https://gen.pollinations.ai/v1 with a free publishable API key. Shared free capacity currently accrues at one pollen per IP per hour; the legacy text host is no longer used.', severity: 'warning', targets: [{ platform: 'pollinations' }], }, diff --git a/server/src/db/migrations/20260720_000001_key_health_error.ts b/server/src/db/migrations/20260720_000001_key_health_error.ts new file mode 100644 index 000000000..8e6dc6b96 --- /dev/null +++ b/server/src/db/migrations/20260720_000001_key_health_error.ts @@ -0,0 +1,19 @@ +import type { Db } from '../types.js'; + +function hasColumn(db: Db, table: string, column: string): boolean { + const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; + return columns.some((candidate) => candidate.name === column); +} + +/** Persist the most recent failed health-probe reason for local diagnostics. */ +export function up(db: Db): void { + if (!hasColumn(db, 'api_keys', 'last_health_error')) { + db.prepare('ALTER TABLE api_keys ADD COLUMN last_health_error TEXT').run(); + } +} + +export function down(db: Db): void { + if (hasColumn(db, 'api_keys', 'last_health_error')) { + db.prepare('ALTER TABLE api_keys DROP COLUMN last_health_error').run(); + } +} diff --git a/server/src/db/node-sqlite.ts b/server/src/db/node-sqlite.ts new file mode 100644 index 000000000..ad26acc7c --- /dev/null +++ b/server/src/db/node-sqlite.ts @@ -0,0 +1,115 @@ +import { createRequire } from 'node:module'; +import type { Db, DbFactory, DbStatement } from './types.js'; + +const runtimeRequire = createRequire(import.meta.url); + +type NodeSqliteRunResult = { + changes: number | bigint; + lastInsertRowid: number | bigint; +}; + +type NodeSqliteStatement = { + run(...params: unknown[]): NodeSqliteRunResult; + get(...params: unknown[]): unknown; + all(...params: unknown[]): unknown[]; +}; + +type NodeSqliteDatabase = { + prepare(sql: string): NodeSqliteStatement; + exec(sql: string): void; + close(): void; +}; + +type NodeSqliteModule = { + DatabaseSync: new (path: string) => NodeSqliteDatabase; +}; + +function loadNodeSqlite(): NodeSqliteModule { + try { + return runtimeRequire('node:sqlite') as NodeSqliteModule; + } catch (cause) { + throw new Error( + 'Android/Termux requires Node.js 22.13 or newer for the built-in node:sqlite database driver.', + { cause }, + ); + } +} + +function numberResult(value: number | bigint): number { + const n = Number(value); + if (!Number.isSafeInteger(n)) { + throw new Error(`SQLite returned an integer outside JavaScript's safe range: ${value}`); + } + return n; +} + +function wrapStatement(statement: NodeSqliteStatement): DbStatement { + return { + get: (...params) => statement.get(...params), + all: (...params) => statement.all(...params), + run: (...params) => { + const result = statement.run(...params); + return { + changes: numberResult(result.changes), + lastInsertRowid: numberResult(result.lastInsertRowid), + }; + }, + }; +} + +/** + * `node:sqlite` adapter used on Android, where better-sqlite3 does not publish + * prebuilt binaries. It exposes only the small synchronous database contract + * the server uses and implements better-sqlite3-style nested transactions with + * savepoints. + */ +export const nodeSqliteFactory: DbFactory = (resolvedPath) => { + const { DatabaseSync } = loadNodeSqlite(); + const raw = new DatabaseSync(resolvedPath); + let transactionDepth = 0; + let savepointSequence = 0; + + const database: Db = { + name: resolvedPath, + memory: resolvedPath === ':memory:', + prepare: (sql) => wrapStatement(raw.prepare(sql)), + exec: (sql) => raw.exec(sql), + pragma: (source) => raw.prepare(`PRAGMA ${source}`).all(), + close: () => raw.close(), + transaction: unknown>(fn: F): F => { + const wrapped = function (this: unknown, ...args: Parameters): ReturnType { + const outermost = transactionDepth === 0; + const savepoint = `freellmapi_tx_${++savepointSequence}`; + + raw.exec(outermost ? 'BEGIN' : `SAVEPOINT ${savepoint}`); + transactionDepth += 1; + + try { + const result = fn.apply(this, args) as ReturnType; + if (result && typeof (result as { then?: unknown }).then === 'function') { + throw new Error('SQLite transaction callbacks must be synchronous'); + } + raw.exec(outermost ? 'COMMIT' : `RELEASE SAVEPOINT ${savepoint}`); + return result; + } catch (error) { + try { + if (outermost) { + raw.exec('ROLLBACK'); + } else { + raw.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`); + raw.exec(`RELEASE SAVEPOINT ${savepoint}`); + } + } catch { + // Preserve the original callback/commit error. + } + throw error; + } finally { + transactionDepth -= 1; + } + }; + return wrapped as F; + }, + }; + + return database; +}; diff --git a/server/src/db/types.ts b/server/src/db/types.ts index 3c5b4142d..47783a216 100644 --- a/server/src/db/types.ts +++ b/server/src/db/types.ts @@ -19,6 +19,8 @@ export interface Db { // as "no file backing" and fall back accordingly. readonly name?: string; readonly memory?: boolean; + /** Close the backing connection when the driver exposes that operation. */ + close?(): void; } /** Factory that opens (or creates) a database at the given resolved path and diff --git a/server/src/docs/openapi.ts b/server/src/docs/openapi.ts index 27e2c47b7..021f920a1 100644 --- a/server/src/docs/openapi.ts +++ b/server/src/docs/openapi.ts @@ -560,7 +560,11 @@ export const openapiSpec = { properties: { model: { type: 'string', default: 'auto' }, input: { type: 'string', minLength: 1 }, - voice: { type: 'string' }, + voice: { + type: 'string', + description: + 'OpenAI voice names are translated to the selected provider voice. Native Gemini and SiliconFlow names are also accepted; unknown names use the provider default.', + }, response_format: { type: 'string', example: 'mp3' }, }, }, diff --git a/server/src/lib/error-redaction.ts b/server/src/lib/error-redaction.ts index 5183888d8..c66a5df05 100644 --- a/server/src/lib/error-redaction.ts +++ b/server/src/lib/error-redaction.ts @@ -2,12 +2,16 @@ const MAX_PROVIDER_ERROR_LENGTH = 240; const REDACTIONS: Array<[RegExp, string]> = [ [/\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi, 'Bearer [redacted]'], - [/\b(api[_-]?key|access[_-]?token|token|secret|authorization)(\s*[:=]\s*)(["']?)[^"',\s}\]]+/gi, '$1$2$3[redacted]'], + // Quote-tolerant so JSON bodies like {"api_key": "..."} are caught too. + [/(["']?)\b(api[_-]?key|access[_-]?token|token|secret|authorization)\b\1(\s*[:=]\s*)(["']?)[^"',\s}\]]+/gi, '$1$2$1$3$4[redacted]'], [/\bsk-[A-Za-z0-9_-]{8,}\b/g, '[redacted-key]'], [/\bgsk_[A-Za-z0-9_-]{8,}\b/g, '[redacted-key]'], [/\bfreellmapi-[A-Za-z0-9_-]{8,}\b/g, '[redacted-key]'], [/\bAIza[0-9A-Za-z_-]{20,}\b/g, '[redacted-key]'], [/\b[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\b/g, '[redacted-token]'], + // Bare high-entropy tokens with no known prefix: any unbroken alphanumeric + // run of 32+ chars is overwhelmingly a credential, never prose. + [/\b[A-Za-z0-9]{32,}\b/g, '[redacted-token]'], [/\bhttps?:\/\/[^\s"'<>)]*/gi, '[redacted-url]'], ]; diff --git a/server/src/providers/aihorde.ts b/server/src/providers/aihorde.ts index 986c637d7..2f9c67969 100644 --- a/server/src/providers/aihorde.ts +++ b/server/src/providers/aihorde.ts @@ -5,7 +5,7 @@ import type { ChatCompletionChunk, Platform, } from '@freellmapi/shared/types.js'; -import { BaseProvider, providerHttpError, type CompletionOptions } from './base.js'; +import { BaseProvider, providerHttpError, type CompletionOptions, type KeyValidationResult } from './base.js'; import { recordQuotaObservationsFromResponse, type QuotaObservationContext } from '../services/provider-quota.js'; import { providerTimeoutMs } from '../lib/provider-timeout.js'; @@ -197,7 +197,7 @@ export class AIHordeProvider extends BaseProvider { * confirmed 401/403 is treated as an invalid key. Transport errors propagate * to health.ts (marked status='error' without counting a failure). */ - async validateKey(apiKey: string, quotaContext?: QuotaObservationContext): Promise { + async validateKey(apiKey: string, quotaContext?: QuotaObservationContext): Promise { const res = await this.fetchWithTimeout(`${this.baseUrl}/models`, { method: 'GET', headers: { 'Authorization': `Bearer ${this.resolveBearer(apiKey)}` }, @@ -209,6 +209,6 @@ export class AIHordeProvider extends BaseProvider { quotaPoolKey: quotaContext?.quotaPoolKey, endpoint: 'models', }); - return res.status !== 401 && res.status !== 403; + return this.validationResult(res); } } diff --git a/server/src/providers/base.ts b/server/src/providers/base.ts index 8635a760f..d364f4231 100644 --- a/server/src/providers/base.ts +++ b/server/src/providers/base.ts @@ -59,6 +59,14 @@ export interface CompletionOptions extends ExtendedSamplingOptions { timeoutMs?: number; } +export interface KeyValidationFailure { + valid: false; + /** Provider-supplied reason suitable for health logs and the local keys UI. */ + error: string; +} + +export type KeyValidationResult = boolean | KeyValidationFailure; + export abstract class BaseProvider { abstract readonly platform: Platform; abstract readonly name: string; @@ -84,7 +92,38 @@ export abstract class BaseProvider { quotaContext?: QuotaObservationContext, ): AsyncGenerator; - abstract validateKey(apiKey: string, quotaContext?: QuotaObservationContext): Promise; + abstract validateKey(apiKey: string, quotaContext?: QuotaObservationContext): Promise; + + /** + * Turn a conventional 401/403 validation response into a diagnostic result. + * Providers still return a simple boolean when no useful error body exists, + * but preserving the upstream message here lets the health service persist + * and display the reason instead of reducing every failure to "invalid". + */ + protected async validationResult(res: Response): Promise { + if (res.status !== 401 && res.status !== 403) return true; + + let body: any = null; + try { + if (typeof (res as any).json === 'function') body = await res.json(); + } catch { + // A status and provider name are still more useful than no reason. + } + + const detail = [ + body?.error?.message, + body?.errors?.[0]?.message, + body?.message, + body?.detail, + body?.title, + res.statusText, + ].find((value) => typeof value === 'string' && value.trim().length > 0); + + return { + valid: false, + error: `${this.name} key validation failed (HTTP ${res.status})${detail ? `: ${detail}` : ''}`, + }; + } protected async fetchWithTimeout( url: string, diff --git a/server/src/providers/cloudflare.ts b/server/src/providers/cloudflare.ts index f1c598956..65eed3705 100644 --- a/server/src/providers/cloudflare.ts +++ b/server/src/providers/cloudflare.ts @@ -3,7 +3,7 @@ import type { ChatCompletionResponse, ChatCompletionChunk, } from '@freellmapi/shared/types.js'; -import { BaseProvider, providerHttpError, type CompletionOptions } from './base.js'; +import { BaseProvider, providerHttpError, type CompletionOptions, type KeyValidationResult, type KeyValidationFailure } from './base.js'; import { extendedBodyParams } from '../lib/sampling-params.js'; import { contentToString } from '../lib/content.js'; import { recordQuotaObservationsFromResponse, type QuotaObservationContext } from '../services/provider-quota.js'; @@ -20,11 +20,20 @@ import { providerTimeoutMs } from '../lib/provider-timeout.js'; // 2026-07-11: repeated 15s aborts). Matches zhipu/agnes/ollama bumps. // PROVIDER_TIMEOUT_CLOUDFLARE overrides (#547). const CHAT_TIMEOUT_MS = providerTimeoutMs('cloudflare', 60_000); +const GLM_47_FLASH_TIMEOUT_MS = 200_000; export class CloudflareProvider extends BaseProvider { readonly platform = 'cloudflare' as const; readonly name = 'Cloudflare Workers AI'; + private timeoutFor(modelId: string, override?: number): number { + if (override !== undefined) return override; + if (CHAT_TIMEOUT_MS <= 0) return CHAT_TIMEOUT_MS; + return modelId === '@cf/zai-org/glm-4.7-flash' + ? Math.max(CHAT_TIMEOUT_MS, GLM_47_FLASH_TIMEOUT_MS) + : CHAT_TIMEOUT_MS; + } + private parseKey(apiKey: string): { accountId: string; token: string } { const sep = apiKey.indexOf(':'); if (sep === -1) throw new Error('Cloudflare key must be in format "account_id:api_token"'); @@ -67,7 +76,7 @@ export class CloudflareProvider extends BaseProvider { parallel_tool_calls: options?.parallel_tool_calls, ...extendedBodyParams(this.platform, options), }), - }, CHAT_TIMEOUT_MS); + }, this.timeoutFor(modelId, options?.timeoutMs)); recordQuotaObservationsFromResponse(res, { platform: this.platform, keyId: quotaContext?.keyId, @@ -116,7 +125,7 @@ export class CloudflareProvider extends BaseProvider { ...extendedBodyParams(this.platform, options), stream: true, }), - }, CHAT_TIMEOUT_MS); + }, this.timeoutFor(modelId, options?.timeoutMs)); recordQuotaObservationsFromResponse(res, { platform: this.platform, keyId: quotaContext?.keyId, @@ -134,7 +143,7 @@ export class CloudflareProvider extends BaseProvider { yield* this.readSseStream(res); } - async validateKey(apiKey: string, quotaContext?: QuotaObservationContext): Promise { + async validateKey(apiKey: string, quotaContext?: QuotaObservationContext): Promise { // Transport errors propagate — health.ts marks status='error' without // counting toward auto-disable. Only confirmed bad/inactive tokens disable. const { accountId, token } = this.parseKey(apiKey); @@ -148,21 +157,22 @@ export class CloudflareProvider extends BaseProvider { token, quotaContext, ); - if (userResult !== 'auth-failed') return userResult; + if ('result' in userResult) return userResult.result; const accountResult = await this.verifyAt( `https://api.cloudflare.com/client/v4/accounts/${accountId}/tokens/verify`, token, quotaContext, ); - if (accountResult === 'auth-failed') return false; - return accountResult; + if ('result' in accountResult) return accountResult.result; + return accountResult.authFailed; } - // Hits a Cloudflare token-verify endpoint. Returns true/false for a definitive - // active/inactive verdict, or 'auth-failed' when the token lacks access to - // THIS endpoint (401/403) so the caller can try the other scope. - private async verifyAt(url: string, token: string, quotaContext?: QuotaObservationContext): Promise { + // Hits a Cloudflare token-verify endpoint. Returns {result} for a definitive + // verdict, or {authFailed} when the token lacks access to THIS endpoint + // (401/403) so the caller can try the other scope — carrying the upstream + // reason so a key that fails both scopes still surfaces why. + private async verifyAt(url: string, token: string, quotaContext?: QuotaObservationContext): Promise<{ result: KeyValidationResult } | { authFailed: KeyValidationFailure }> { const res = await this.fetchWithTimeout( url, { method: 'GET', headers: { 'Authorization': `Bearer ${token}` } }, @@ -176,9 +186,15 @@ export class CloudflareProvider extends BaseProvider { quotaPoolKey: quotaContext?.quotaPoolKey, endpoint: 'tokens/verify', }); - if (res.status === 401 || res.status === 403) return 'auth-failed'; - if (!res.ok) return true; // unexpected non-2xx that isn't auth — don't disable + if (res.status === 401 || res.status === 403) { + return { authFailed: await this.validationResult(res) as KeyValidationFailure }; + } + if (!res.ok) return { result: true }; // unexpected non-2xx that isn't auth — don't disable const data = await res.json() as any; - return data.success === true && data.result?.status === 'active'; + if (data.success === true && data.result?.status === 'active') return { result: true }; + const reason = data.result?.status + ? `token status is "${data.result.status}"` + : data.errors?.[0]?.message ?? 'verify endpoint did not confirm an active token'; + return { result: { valid: false, error: `${this.name} key validation failed: ${reason}` } }; } } diff --git a/server/src/providers/cohere.ts b/server/src/providers/cohere.ts index b5f8f68bf..074d54aad 100644 --- a/server/src/providers/cohere.ts +++ b/server/src/providers/cohere.ts @@ -4,7 +4,7 @@ import type { ChatCompletionChunk, ChatToolDefinition, } from '@freellmapi/shared/types.js'; -import { BaseProvider, providerHttpError, type CompletionOptions } from './base.js'; +import { BaseProvider, providerHttpError, type CompletionOptions, type KeyValidationResult } from './base.js'; import { extendedBodyParams } from '../lib/sampling-params.js'; import { flattenMessageContent } from '../lib/content.js'; import { recordQuotaObservationsFromResponse, type QuotaObservationContext } from '../services/provider-quota.js'; @@ -124,7 +124,7 @@ export class CohereProvider extends BaseProvider { yield* this.readSseStream(res); } - async validateKey(apiKey: string, quotaContext?: QuotaObservationContext): Promise { + async validateKey(apiKey: string, quotaContext?: QuotaObservationContext): Promise { // Transport errors propagate — health.ts marks status='error' without // counting toward auto-disable. Only confirmed 401/403 disables a key. const res = await this.fetchWithTimeout(`${API_BASE}/models`, { @@ -139,6 +139,6 @@ export class CohereProvider extends BaseProvider { quotaPoolKey: quotaContext?.quotaPoolKey, endpoint: 'models', }); - return res.status !== 401 && res.status !== 403; + return this.validationResult(res); } } diff --git a/server/src/providers/google.ts b/server/src/providers/google.ts index 87300e14c..deb1db93b 100644 --- a/server/src/providers/google.ts +++ b/server/src/providers/google.ts @@ -7,7 +7,7 @@ import type { ChatToolDefinition, TokenUsage, } from '@freellmapi/shared/types.js'; -import { BaseProvider, providerHttpError, type CompletionOptions } from './base.js'; +import { BaseProvider, providerHttpError, type CompletionOptions, type KeyValidationResult } from './base.js'; import { contentToString } from '../lib/content.js'; import { proxyFetch } from '../lib/proxy.js'; import { recordQuotaObservationsFromResponse, type QuotaObservationContext } from '../services/provider-quota.js'; @@ -793,7 +793,7 @@ export class GoogleProvider extends BaseProvider { } } - async validateKey(apiKey: string, quotaContext?: QuotaObservationContext): Promise { + async validateKey(apiKey: string, quotaContext?: QuotaObservationContext): Promise { // Transport errors propagate — health.ts marks status='error' without // counting toward auto-disable. const res = await this.fetchWithTimeout( @@ -838,13 +838,19 @@ export class GoogleProvider extends BaseProvider { /API key not valid|API key expired|API_KEY_INVALID/i.test(message); if (badCredentials) { console.warn(`[Google] validateKey: key rejected as invalid (HTTP ${res.status}${reason ? ` ${reason}` : ''})`); - return false; + return { + valid: false, + error: `Google key validation failed (HTTP ${res.status}${reason ? ` ${reason}` : ''})${message ? `: ${message}` : ''}`, + }; } console.warn( `[Google] validateKey: inconclusive HTTP ${res.status} (${gStatus ?? 'UNKNOWN'}${reason ? `/${reason}` : ''}): ${message.slice(0, 200)} ` + `— treating as 'error', not auto-disabling (the key may be valid but blocked by region/permission/restriction on this host).`, ); - throw new Error(`Google key validation inconclusive (HTTP ${res.status}${gStatus ? ` ${gStatus}` : ''})`); + throw new Error( + `Google key validation inconclusive (HTTP ${res.status}${gStatus ? ` ${gStatus}` : ''}${reason ? ` ${reason}` : ''})` + + `${message ? `: ${message}` : ''}`, + ); } } diff --git a/server/src/providers/index.ts b/server/src/providers/index.ts index 5f730a38a..d73d7039a 100644 --- a/server/src/providers/index.ts +++ b/server/src/providers/index.ts @@ -143,21 +143,14 @@ register(new OpenAICompatProvider({ keyless: true, })); -// Pollinations — OpenAI-compatible, anonymous tier. The chat completions -// endpoint lives at `/openai/v1/chat/completions` (NOT `/v1/...` — the -// `/openai` prefix is mandatory). Public model list returns one anonymous -// model (`openai-fast` = GPT-OSS 20B on OVH, tools=true). -// Registered keyless (June 2026): the legacy text API is deprecated for -// AUTHENTICATED users (replacement enter.pollinations.ai is pay-as-you-go -// "pollen"), while anonymous access is explicitly unaffected — so the anon -// path is the only recurring-free one left. Anon is queue-limited to 1 -// concurrent request per IP (429 "Queue full" on overlap; live-probed -// 2026-06-10). +// Pollinations — OpenAI-compatible recurring shared-capacity tier. The legacy +// text.pollinations.ai host returned 502 in the July 2026 audit; publishable +// keys now use the unified gen.pollinations.ai endpoint. Free capacity accrues +// at one pollen per IP per hour, so chat requires a real publishable key. register(new OpenAICompatProvider({ platform: 'pollinations', name: 'Pollinations', - baseUrl: 'https://text.pollinations.ai/openai/v1', - keyless: true, + baseUrl: 'https://gen.pollinations.ai/v1', })); // LLM7.io — OpenAI-compatible aggregator. 100 req/hr free; anonymous access diff --git a/server/src/providers/openai-compat.ts b/server/src/providers/openai-compat.ts index 64e1401fe..56e5ca530 100644 --- a/server/src/providers/openai-compat.ts +++ b/server/src/providers/openai-compat.ts @@ -5,7 +5,7 @@ import type { ChatToolCall, Platform, } from '@freellmapi/shared/types.js'; -import { BaseProvider, providerHttpError, type CompletionOptions } from './base.js'; +import { BaseProvider, providerHttpError, type CompletionOptions, type KeyValidationResult } from './base.js'; import { extendedBodyParams } from '../lib/sampling-params.js'; import { rescueInlineToolCalls } from '../lib/tool-call-rescue.js'; import { repairToolArguments, toolSchemaMap } from '../lib/tool-args.js'; @@ -108,6 +108,23 @@ export class OpenAICompatProvider extends BaseProvider { return this.keyless ? {} : { 'Authorization': `Bearer ${apiKey}` }; } + /** Requesty's Leanstral route rejects greedy sampling when temperature=0. + * Omitting that value and supplying a neutral top_p keeps the caller's intent + * deterministic enough while using the provider's supported sampling path. */ + private samplingForModel(modelId: string, options?: CompletionOptions): { + temperature: number | undefined; + topP: number | undefined; + } { + if ( + this.platform === 'requesty' && + modelId === 'mistral/leanstral-1-5' && + options?.temperature === 0 + ) { + return { temperature: undefined, topP: options.top_p ?? 1 }; + } + return { temperature: options?.temperature, topP: options?.top_p }; + } + /** Mistral's OpenAI-compatible endpoint is strict about unknown nested fields * and returns 422 for provider-private replay fields that other gateways * ignore. Keep the OpenAI wire shape, but strip our internal reasoning / @@ -156,6 +173,7 @@ export class OpenAICompatProvider extends BaseProvider { options?: CompletionOptions, quotaContext?: QuotaObservationContext, ): Promise { + const sampling = this.samplingForModel(modelId, options); const res = await this.fetchWithTimeout(`${this.baseUrl}/chat/completions`, { method: 'POST', headers: { @@ -166,9 +184,9 @@ export class OpenAICompatProvider extends BaseProvider { body: JSON.stringify({ model: modelId, messages: this.messagesForPlatform(messages), - temperature: options?.temperature, + temperature: sampling.temperature, max_tokens: options?.max_tokens, - top_p: options?.top_p, + top_p: sampling.topP, stop: options?.stop, tools: options?.tools, tool_choice: options?.tool_choice, @@ -268,6 +286,7 @@ export class OpenAICompatProvider extends BaseProvider { options?: CompletionOptions, quotaContext?: QuotaObservationContext, ): AsyncGenerator { + const sampling = this.samplingForModel(modelId, options); const res = await this.fetchWithTimeout(`${this.baseUrl}/chat/completions`, { method: 'POST', headers: { @@ -278,9 +297,9 @@ export class OpenAICompatProvider extends BaseProvider { body: JSON.stringify({ model: modelId, messages: this.messagesForPlatform(messages), - temperature: options?.temperature, + temperature: sampling.temperature, max_tokens: options?.max_tokens, - top_p: options?.top_p, + top_p: sampling.topP, stop: options?.stop, tools: options?.tools, tool_choice: options?.tool_choice, @@ -316,7 +335,7 @@ export class OpenAICompatProvider extends BaseProvider { yield* this.readSseStream(res); } - async validateKey(apiKey: string, quotaContext?: QuotaObservationContext): Promise { + async validateKey(apiKey: string, quotaContext?: QuotaObservationContext): Promise { // Note: transport errors (DNS / timeout / TLS) propagate to the caller. // health.ts catches them and marks status='error' WITHOUT incrementing // the consecutive-failure counter — only confirmed 401/403 disables a key. @@ -340,7 +359,7 @@ export class OpenAICompatProvider extends BaseProvider { quotaPoolKey: quotaContext?.quotaPoolKey, endpoint: 'models', }); - return res.status !== 401 && res.status !== 403; + return this.validationResult(res); } } diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 79aa9bdc9..7e24d5fc1 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -26,7 +26,7 @@ healthRouter.get('/', (_req: Request, res: Response) => { `).all() as any[]; const keys = db.prepare(` - SELECT id, platform, label, status, enabled, created_at, last_checked_at + SELECT id, platform, label, status, enabled, created_at, last_checked_at, last_health_error FROM api_keys ORDER BY platform, created_at DESC `).all() as any[]; @@ -51,6 +51,7 @@ healthRouter.get('/', (_req: Request, res: Response) => { enabled: k.enabled === 1, createdAt: k.created_at, lastCheckedAt: k.last_checked_at, + lastHealthError: k.last_health_error, })), quotaStates: getQuotaStateForKeys(), }); diff --git a/server/src/routes/keys.ts b/server/src/routes/keys.ts index 58ee53100..324e24840 100644 --- a/server/src/routes/keys.ts +++ b/server/src/routes/keys.ts @@ -208,6 +208,7 @@ keysRouter.get('/', (_req: Request, res: Response) => { keyless: resolveProvider(row.platform)?.keyless === true, createdAt: row.created_at, lastCheckedAt: row.last_checked_at, + lastHealthError: row.last_health_error ?? null, models: row.platform === 'custom' ? (modelsByKeyId.get(row.id) ?? []) : undefined, }; }); diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts index 36cae521a..f68ed10e6 100644 --- a/server/src/routes/proxy.ts +++ b/server/src/routes/proxy.ts @@ -1545,7 +1545,12 @@ proxyRouter.post('/chat/completions', async (req: Request, res: Response) => { for await (const chunk of gen) { if (clientGone) break; // client hung up: stop pulling; reader.cancel() aborts upstream - const anyChunk = chunk as Record; + // Provider metadata is not authoritative for the public gateway + // response. Some OpenAI-compatible providers (notably Reka) return + // the literal model name "default" even when a concrete model was + // requested. Normalize every streamed frame at the proxy boundary + // so clients consistently see the model that was actually routed. + const anyChunk: Record = { ...(chunk as Record), model: route.modelId }; // In-band upstream error frame (observed live: Groq emits // {"error":{...,"code":"tool_use_failed"}} inside a 200 SSE @@ -1593,8 +1598,8 @@ proxyRouter.post('/chat/completions', async (req: Request, res: Response) => { if (tc.function?.arguments) acc.args += tc.function.arguments; } - normalizeOutboundContent(chunk); - sanitizeResponse(chunk); + normalizeOutboundContent(anyChunk); + sanitizeResponse(anyChunk); const text = typeof choice.delta?.content === 'string' ? choice.delta.content : ''; if (text.length === 0) { @@ -1757,6 +1762,11 @@ proxyRouter.post('/chat/completions', async (req: Request, res: Response) => { quotaContextForRoute(route, 'chat/completions'), ); + // Upstream `model` fields are provider-controlled and can be a generic + // placeholder such as Reka's "default". The gateway contract exposes + // the concrete routed model, consistently across every provider. + result.model = route.modelId; + // Empty completion (no text, no tool calls) → fail over rather than // return a transport-level "success" the caller can't act on. Mirrors // the zero-chunk streaming case above. Throwing hands it to the shared diff --git a/server/src/services/catalog-sync.ts b/server/src/services/catalog-sync.ts index 4dfdc4205..ad38475e3 100644 --- a/server/src/services/catalog-sync.ts +++ b/server/src/services/catalog-sync.ts @@ -3,6 +3,7 @@ import type { Db } from '../db/types.js'; import { getDb, getSetting, setSetting } from '../db/index.js'; import { hasProvider } from '../providers/index.js'; import { MEDIA_PLATFORMS } from './media.js'; +import { EMBEDDING_PLATFORMS } from './embeddings.js'; import type { Platform } from '@freellmapi/shared/types.js'; import type { Scheduler } from '../lib/scheduler.js'; import { @@ -108,11 +109,26 @@ interface CatalogModel { mediaNote?: string; } +interface CatalogEmbedding { + family: string; + platform: string; + modelId: string; + displayName: string; + dimensions: number; + maxInputTokens: number | null; + priority: number; + enabled: boolean; + quotaLabel: string; +} + interface Catalog { version: string; generatedAt: string; tier: 'live' | 'monthly'; models: CatalogModel[]; + /** Optional for backward compatibility with catalogs published before the + * embedding registry joined the signed freshness feed. */ + embeddings?: CatalogEmbedding[]; quirks: CatalogQuirk[]; } @@ -134,6 +150,18 @@ function isCatalog(value: unknown): value is Catalog { (c.tier === 'live' || c.tier === 'monthly') && Array.isArray(c.models) && Array.isArray(c.quirks) && + (c.embeddings === undefined || + (Array.isArray(c.embeddings) && + c.embeddings.every( + (m) => + typeof m?.family === 'string' && + typeof m?.platform === 'string' && + typeof m?.modelId === 'string' && + typeof m?.displayName === 'string' && + typeof m?.dimensions === 'number' && + typeof m?.priority === 'number' && + typeof m?.enabled === 'boolean', + ))) && c.models.every( (m) => typeof m?.platform === 'string' && @@ -200,10 +228,29 @@ export function applyCatalog(db: Db, catalog: Catalog): NonNullable { const inCatalog = new Set(); const inMediaCatalog = new Set(); + const inEmbeddingCatalog = new Set(); for (const m of catalog.models) { // Media modalities are gated on MEDIA_PLATFORMS (decoupled from the chat @@ -271,6 +318,40 @@ export function applyCatalog(db: Db, catalog: Catalog): NonNullable(); +function recordInvalidFailure(keyId: number): void { + const count = (failureCount.get(keyId) ?? 0) + 1; + failureCount.set(keyId, count); + + if (count >= CONSECUTIVE_FAILURES_TO_DISABLE) { + getDb().prepare('UPDATE api_keys SET enabled = 0 WHERE id = ?').run(keyId); + console.log(`[Health] Auto-disabled key ${keyId} after ${count} consecutive failures`); + } +} + export async function checkKeyHealth(keyId: number): Promise { const db = getDb(); const row = db.prepare('SELECT * FROM api_keys WHERE id = ?').get(keyId) as any; @@ -21,29 +32,34 @@ export async function checkKeyHealth(keyId: number): Promise { try { const apiKey = decrypt(row.encrypted_key, row.iv, row.auth_tag); - const isValid = await provider.validateKey(apiKey, { + const validation = await provider.validateKey(apiKey, { platform: row.platform as Platform, keyId, quotaPoolKey: inferQuotaPoolKey(row.platform as Platform, null), endpoint: 'models', origin: 'health', }); + const isValid = typeof validation === 'boolean' ? validation : validation.valid; + const lastError = isValid + ? null + : sanitizeProviderErrorMessage( + typeof validation === 'boolean' + ? `${provider.name} rejected the API key` + : validation.error, + ); const status: KeyStatus = isValid ? 'healthy' : 'invalid'; - db.prepare("UPDATE api_keys SET status = ?, last_checked_at = datetime('now') WHERE id = ?") - .run(status, keyId); + db.prepare("UPDATE api_keys SET status = ?, last_health_error = ?, last_checked_at = datetime('now') WHERE id = ?") + .run(status, lastError, keyId); if (isValid) { failureCount.delete(keyId); } else { - const count = (failureCount.get(keyId) ?? 0) + 1; - failureCount.set(keyId, count); - - if (count >= CONSECUTIVE_FAILURES_TO_DISABLE) { - db.prepare('UPDATE api_keys SET enabled = 0 WHERE id = ?').run(keyId); - console.log(`[Health] Auto-disabled key ${keyId} after ${count} consecutive failures`); - } + console.warn( + `[Health] Key ${keyId} (${row.platform}, base=${row.base_url ?? 'default'}) invalid: ${lastError}`, + ); + recordInvalidFailure(keyId); } return status; @@ -56,12 +72,13 @@ export async function checkKeyHealth(keyId: number): Promise { // "[Health] Key N (" prefix is preserved so the 12-hourly crash watchdog // (cron bff5ae167d28) that scrapes /tmp/freellmapi.log for these lines // continues to match unchanged. + const lastError = sanitizeProviderErrorMessage(err?.message ?? err); console.error( `[Health] Key ${keyId} (${row.platform}, base=${row.base_url ?? 'default'}) ` + - `transport error: ${err.message}`, + `transport error: ${lastError}`, ); - db.prepare("UPDATE api_keys SET status = ?, last_checked_at = datetime('now') WHERE id = ?") - .run('error', keyId); + db.prepare("UPDATE api_keys SET status = ?, last_health_error = ?, last_checked_at = datetime('now') WHERE id = ?") + .run('error', lastError, keyId); return 'error'; } } diff --git a/server/src/services/media.ts b/server/src/services/media.ts index 4104e6893..8dc1422d4 100644 --- a/server/src/services/media.ts +++ b/server/src/services/media.ts @@ -55,6 +55,96 @@ export interface SpeechResult { export interface ImageParams { prompt: string; n?: number; size?: string } export interface SpeechParams { input: string; voice?: string; format?: string } +// OpenAI clients commonly send one of these voice names even when the user did +// not choose a voice explicitly. Native TTS providers use unrelated voice +// vocabularies, so forwarding the value verbatim makes an otherwise ordinary +// OpenAI request fail. Keep the translation here, at the provider boundary; +// custom OpenAI-compatible endpoints still receive the caller's value as-is. +// Pollinations' openai-audio route exposes the original OpenAI TTS vocabulary. +const POLLINATIONS_VOICES = new Set([ + 'alloy', 'echo', 'fable', 'nova', 'onyx', 'shimmer', +]); + +const SILICONFLOW_NATIVE_VOICES = new Set([ + 'alex', 'anna', 'bella', 'benjamin', 'charles', 'claire', 'david', 'diana', +]); + +const SILICONFLOW_OPENAI_VOICE_MAP: Record = { + alloy: 'alex', + ash: 'claire', + ballad: 'diana', + coral: 'bella', + echo: 'benjamin', + fable: 'charles', + nova: 'anna', + onyx: 'david', + sage: 'benjamin', + shimmer: 'bella', + verse: 'charles', + marin: 'anna', + cedar: 'david', +}; + +const GEMINI_NATIVE_VOICES = [ + 'Zephyr', 'Puck', 'Charon', 'Kore', 'Fenrir', 'Leda', 'Orus', 'Aoede', + 'Callirrhoe', 'Autonoe', 'Enceladus', 'Iapetus', 'Umbriel', 'Algieba', + 'Despina', 'Erinome', 'Algenib', 'Rasalgethi', 'Laomedeia', 'Achernar', + 'Alnilam', 'Schedar', 'Gacrux', 'Pulcherrima', 'Achird', 'Zubenelgenubi', + 'Vindemiatrix', 'Sadachbia', 'Sadaltager', 'Sulafat', +] as const; + +const GEMINI_NATIVE_VOICE_LOOKUP = new Map( + GEMINI_NATIVE_VOICES.map(voice => [voice.toLowerCase(), voice]), +); + +const GEMINI_OPENAI_VOICE_MAP: Record = { + alloy: 'Schedar', // even + ash: 'Charon', // informative + ballad: 'Enceladus', // breathy + coral: 'Sulafat', // warm + echo: 'Iapetus', // clear + fable: 'Puck', // upbeat + nova: 'Zephyr', // bright + onyx: 'Gacrux', // mature + sage: 'Sadaltager', // knowledgeable + shimmer: 'Achernar', // soft + verse: 'Laomedeia', // upbeat + marin: 'Aoede', // breezy + cedar: 'Kore', // firm +}; + +function normalizedVoice(voice?: string): string | undefined { + const value = voice?.trim().toLowerCase(); + return value || undefined; +} + +function siliconFlowVoice(modelId: string, requested?: string): string { + const raw = requested?.trim(); + const prefix = `${modelId}:`; + const qualifiedNative = raw?.startsWith(prefix) + ? normalizedVoice(raw.slice(prefix.length)) + : undefined; + const name = qualifiedNative && SILICONFLOW_NATIVE_VOICES.has(qualifiedNative) + ? qualifiedNative + : normalizedVoice(raw); + const native = name && SILICONFLOW_NATIVE_VOICES.has(name) ? name : undefined; + const mapped = name ? SILICONFLOW_OPENAI_VOICE_MAP[name] : undefined; + return `${modelId}:${native ?? mapped ?? 'alex'}`; +} + +function pollinationsVoice(requested?: string): string { + const voice = normalizedVoice(requested); + return voice && POLLINATIONS_VOICES.has(voice) ? voice : 'alloy'; +} + +function geminiVoice(requested?: string): string { + const voice = normalizedVoice(requested); + if (!voice) return 'Kore'; + return GEMINI_NATIVE_VOICE_LOOKUP.get(voice) + ?? GEMINI_OPENAI_VOICE_MAP[voice] + ?? 'Kore'; +} + // Media generations are slower than chat — a cold FLUX/SDXL run can take 30-60s. const FETCH_TIMEOUT_MS = 60_000; @@ -285,7 +375,9 @@ async function callSpeechProvider( const r = await mediaFetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${row.model_id}`, 'cloudflare', 'audio', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify({ prompt: p.input, lang: p.voice ?? 'en' }), + // MeloTTS has a language selector, not a voice selector. In particular, + // OpenAI's default `alloy` must never be sent as `lang`. + body: JSON.stringify({ prompt: p.input, lang: 'en' }), }); const j = (await r.json()) as { result?: { audio?: string } }; const b64 = j.result?.audio; @@ -297,7 +389,12 @@ async function callSpeechProvider( const r = await mediaFetch('https://api.siliconflow.com/v1/audio/speech', 'siliconflow', 'audio', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` }, - body: JSON.stringify({ model: row.model_id, input: p.input, voice: p.voice ?? `${row.model_id}:alex`, response_format: fmt }), + body: JSON.stringify({ + model: row.model_id, + input: p.input, + voice: siliconFlowVoice(row.model_id, p.voice), + response_format: fmt, + }), }); return { audio: Buffer.from(await r.arrayBuffer()), contentType: contentTypeFor(fmt) }; } @@ -311,7 +408,7 @@ async function callSpeechProvider( body: JSON.stringify({ model: row.model_id, modalities: ['text', 'audio'], - audio: { voice: p.voice ?? 'alloy', format: p.format ?? 'mp3' }, + audio: { voice: pollinationsVoice(p.voice), format: p.format ?? 'mp3' }, messages: [{ role: 'user', content: p.input }], }), }); @@ -334,7 +431,7 @@ async function callSpeechProvider( contents: [{ parts: [{ text: p.input }] }], generationConfig: { responseModalities: ['AUDIO'], - speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: p.voice ?? 'Kore' } } }, + speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: geminiVoice(p.voice) } } }, }, }), }, diff --git a/server/src/services/provider-quota.ts b/server/src/services/provider-quota.ts index 05cfd506b..cbb2bbd42 100644 --- a/server/src/services/provider-quota.ts +++ b/server/src/services/provider-quota.ts @@ -124,7 +124,7 @@ function inferPoolForPlatform(platform: Platform, modelId?: string | null): stri if (platform === 'zhipu') return 'zhipu::account'; if (platform === 'ollama') return 'ollama::cloud'; if (platform === 'kilo') return 'kilo::anonymous'; - if (platform === 'pollinations') return 'pollinations::anonymous'; + if (platform === 'pollinations') return 'pollinations::account'; if (platform === 'llm7') return 'llm7::anonymous'; // AI Horde: anonymous requests share one queue priority (the 0000000000 key), // so they pool together; a registered key has its own kudos priority but we diff --git a/shared/types.ts b/shared/types.ts index 8b240802c..0684c6eff 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -181,6 +181,7 @@ export interface ApiKey { keyless: boolean; createdAt: string; lastCheckedAt: string | null; + lastHealthError: string | null; models?: ApiKeyModel[]; }