diff --git a/.agents/workflows/review-prs.md b/.agents/workflows/review-prs.md index 73e547d40d..97b6dc3f83 100644 --- a/.agents/workflows/review-prs.md +++ b/.agents/workflows/review-prs.md @@ -200,7 +200,7 @@ Perform a **global impact assessment** to verify whether the PR changes are comp ``` - **Fallback (ONLY for external forks without maintainer edit access):** - Using `cherry-pick` instead of fixing the contributor's PR directly is a **LAST RESORT**. You MUST ALWAYS attempt to `git push` your fixes to their branch first. + Using `cherry-pick` instead of fixing the contributor's PR directly is a **LAST RESORT**. You MUST ALWAYS attempt to `git push` your fixes to their branch first. **ONLY if `git push` explicitly fails with a permission/access error** (meaning the contributor unchecked "Allow edits from maintainers" or it's a locked fork), you may use `git cherry-pick` to bring their changes into the release branch and fix the issues locally. Even then, ensure you preserve the contributor's authorship (`git commit --author="Contributor Name "` if creating new commits). Once you have integrated their work into the release branch, **DO NOT close their PR**. Leave it open so the contributor retains credit. Under NO CIRCUMSTANCES should you use `gh pr close`. diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd4dc..a639c3fa36 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -36,9 +36,8 @@ jobs: uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' - plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + plugin_marketplaces: "https://github.com/anthropics/claude-code.git" + plugins: "code-review@claude-code-plugins" + prompt: "/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}" # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options - diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 6b15fac7af..eb9719ecca 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -47,4 +47,3 @@ jobs: # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options # claude_args: '--allowed-tools Bash(gh pr *)' - diff --git a/.issues/feat-batch-delete-provider-accounts.md b/.issues/feat-batch-delete-provider-accounts.md index fe84a123c8..94c7910c4c 100644 --- a/.issues/feat-batch-delete-provider-accounts.md +++ b/.issues/feat-batch-delete-provider-accounts.md @@ -15,6 +15,7 @@ Users managing multiple provider accounts (e.g., 20+ API keys or OAuth connectio 5. Repeating for every account This is: + - **Time-consuming**: O(n) confirm dialogs and API calls for n accounts - **Error-prone**: Easy to accidentally click the wrong account - **Tedious**: No way to quickly clean up stale or duplicate accounts @@ -35,15 +36,15 @@ Add a checkbox-based selection UI to the provider connections list: ### Files to Modify -| File | Change | -|------|--------| -| `src/app/(dashboard)/dashboard/providers/[id]/page.tsx` | Add batch delete state, select-all + per-row checkboxes, batch delete handler | -| `src/app/api/providers/route.ts` | Add `DELETE /api/providers` with `POST` body `ids: string[]` for batch delete | -| `src/lib/db/providers.ts` | Add `deleteProviderConnections(ids: string[])` batch DB function | -| `src/i18n/messages/en.json` | Add i18n keys: `batchDeleteSelected`, `batchDeleteConfirm`, `batchDeleteSuccess` | -| `src/i18n/messages/*.json` | Add i18n keys to all locale files | -| `tests/unit/db-providers-crud.test.ts` | Add unit tests for batch delete DB function | -| `tests/integration/api-routes-critical.test.ts` | Add integration test for batch delete API endpoint | +| File | Change | +| ------------------------------------------------------- | -------------------------------------------------------------------------------- | +| `src/app/(dashboard)/dashboard/providers/[id]/page.tsx` | Add batch delete state, select-all + per-row checkboxes, batch delete handler | +| `src/app/api/providers/route.ts` | Add `DELETE /api/providers` with `POST` body `ids: string[]` for batch delete | +| `src/lib/db/providers.ts` | Add `deleteProviderConnections(ids: string[])` batch DB function | +| `src/i18n/messages/en.json` | Add i18n keys: `batchDeleteSelected`, `batchDeleteConfirm`, `batchDeleteSuccess` | +| `src/i18n/messages/*.json` | Add i18n keys to all locale files | +| `tests/unit/db-providers-crud.test.ts` | Add unit tests for batch delete DB function | +| `tests/integration/api-routes-critical.test.ts` | Add integration test for batch delete API endpoint | ### 1. DB Layer (`src/lib/db/providers.ts`) @@ -60,9 +61,9 @@ export async function deleteProviderConnections(ids: string[]): Promise // Batch delete connections const placeholders = ids.map(() => "?").join(","); - const result = db.prepare( - `DELETE FROM provider_connections WHERE id IN (${placeholders})` - ).run(...ids); + const result = db + .prepare(`DELETE FROM provider_connections WHERE id IN (${placeholders})`) + .run(...ids); backupDbFile("pre-write"); invalidateDbCache("connections"); @@ -210,14 +211,14 @@ interface ConnectionRowProps { 0} - ref={(el) => { if (el) el.indeterminate = selectedIds.size > 0 && selectedIds.size < connections.length; }} + ref={(el) => { + if (el) el.indeterminate = selectedIds.size > 0 && selectedIds.size < connections.length; + }} onChange={handleToggleSelectAll} className="w-4 h-4 rounded border-border text-primary focus:ring-primary/30" /> - {selectedIds.size > 0 - ? `${selectedIds.size} selected` - : `${connections.length} accounts`} + {selectedIds.size > 0 ? `${selectedIds.size} selected` : `${connections.length} accounts`} @@ -252,8 +253,10 @@ interface ConnectionRowProps { ```typescript test("deleteProviderConnections deletes multiple connections", async () => { const ids = [ - (await createProviderConnection({ provider: "openai", name: "test-1", authType: "apikey" })).id!, - (await createProviderConnection({ provider: "openai", name: "test-2", authType: "apikey" })).id!, + (await createProviderConnection({ provider: "openai", name: "test-1", authType: "apikey" })) + .id!, + (await createProviderConnection({ provider: "openai", name: "test-2", authType: "apikey" })) + .id!, ]; const deleted = await deleteProviderConnections(ids); @@ -278,7 +281,7 @@ test("DELETE /api/providers — batch delete", async () => { const ids = [conn1.id, conn2.id]; const res = await fetch("http://localhost:20128/api/providers", { method: "DELETE", - headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` }, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: JSON.stringify({ ids }), }); @@ -307,16 +310,17 @@ test("DELETE /api/providers — batch delete", async () => { ### Risks & Mitigations -| Risk | Mitigation | -|------|------------| -| User accidentally deletes wrong accounts | Require confirmation dialog with count | -| Too many connections selected | Cap at 100 per batch; show error if exceeded | -| Partial failure on batch delete | DB runs in transaction; all-or-nothing semantics | -| Performance with large selections | Batch SQL with `IN (...)` clause is efficient up to 100 | +| Risk | Mitigation | +| ---------------------------------------- | ------------------------------------------------------- | +| User accidentally deletes wrong accounts | Require confirmation dialog with count | +| Too many connections selected | Cap at 100 per batch; show error if exceeded | +| Partial failure on batch delete | DB runs in transaction; all-or-nothing semantics | +| Performance with large selections | Batch SQL with `IN (...)` clause is efficient up to 100 | ### Coverage Per repository rules, this change affects production code in `src/` → automated tests required: + - Unit test for `deleteProviderConnections()` in `tests/unit/db-providers-crud.test.ts` - Integration test for `DELETE /api/providers` batch endpoint in `tests/integration/api-routes-critical.test.ts` - Run `npm run test:coverage` — all 4 metrics must meet 60% minimum diff --git a/.npmignore b/.npmignore index cc14c145c2..80bfe11e38 100644 --- a/.npmignore +++ b/.npmignore @@ -52,8 +52,8 @@ AGENTS.md bun.lock # Build artifacts (pre-built goes inside app/) -.next/ -node_modules/ +/.next/ +/node_modules/ # Ignore large binary files and other build directories *.tgz diff --git a/open-sse/executors/pollinations.ts b/open-sse/executors/pollinations.ts index cd6219d8f1..285557b910 100644 --- a/open-sse/executors/pollinations.ts +++ b/open-sse/executors/pollinations.ts @@ -41,8 +41,10 @@ export class PollinationsExecutor extends BaseExecutor { return headers; } - transformRequest(model: string, body: any, _stream: boolean, _credentials: any): any { + transformRequest(model: string, body: any, stream: boolean, _credentials: any): any { if (typeof body === "object" && body !== null) { + body.model = model; + body.stream = stream; body.jsonMode = true; } return body; diff --git a/open-sse/services/chatgptTlsClient.ts b/open-sse/services/chatgptTlsClient.ts index 8c8c889fcf..4e21f18da4 100644 --- a/open-sse/services/chatgptTlsClient.ts +++ b/open-sse/services/chatgptTlsClient.ts @@ -209,23 +209,24 @@ export interface TlsFetchOptions { proxyUrl?: string; } +import { resolveProxyForRequest } from "../utils/proxyFetch.ts"; + /** * Resolve the proxy URL for a tls-client request. Per-call value wins; - * otherwise we fall back to env. Returns undefined when no proxy is - * configured (caller passes `undefined` through to tls-client-node, which - * treats it as "no proxy"). + * otherwise we use the standard proxy fetch resolution which reads from + * the dashboard AsyncLocalStorage context or falls back to env vars. */ function resolveProxyUrl(perCall: string | undefined): string | undefined { if (perCall && perCall.length > 0) return perCall; - const fromEnv = - process.env.OMNIROUTE_TLS_PROXY_URL || - process.env.HTTPS_PROXY || - process.env.https_proxy || - process.env.HTTP_PROXY || - process.env.http_proxy || - process.env.ALL_PROXY || - process.env.all_proxy; - return fromEnv && fromEnv.length > 0 ? fromEnv : undefined; + try { + const proxyInfo = resolveProxyForRequest("https://chatgpt.com"); + if (proxyInfo && proxyInfo.proxyUrl) { + return proxyInfo.proxyUrl; + } + } catch { + // Ignore resolution errors + } + return undefined; } export interface TlsFetchResult { diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index 96c581eae3..6ba4b02999 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -133,7 +133,7 @@ function resolveEnvProxyUrl(targetUrl) { return normalizeProxyUrl(proxyUrl, "environment proxy"); } -function resolveProxyForRequest(targetUrl) { +export function resolveProxyForRequest(targetUrl) { let target; try { target = new URL(targetUrl); diff --git a/package-lock.json b/package-lock.json index 588bc0ac42..c6ef3056ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "express": "^5.2.1", "fetch-socks": "^1.3.3", "fuse.js": "^7.3.0", + "gray-matter": "^4.0.3", "http-proxy-middleware": "^3.0.5", "https-proxy-agent": "^9.0.0", "ioredis": "^5.10.1", @@ -83,7 +84,6 @@ "eslint": "^9.39.4", "eslint-config-next": "16.2.6", "glob": "^13.0.6", - "gray-matter": "^4.0.3", "husky": "^9.1.7", "jsdom": "^29.1.1", "lint-staged": "^16.4.0", @@ -8258,7 +8258,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -8460,7 +8459,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "dev": true, "license": "MIT", "dependencies": { "is-extendable": "^0.1.0" @@ -9101,7 +9099,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", - "dev": true, "license": "MIT", "dependencies": { "js-yaml": "^3.13.1", @@ -9117,7 +9114,6 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, "license": "MIT", "dependencies": { "sprintf-js": "~1.0.2" @@ -9127,7 +9123,6 @@ "version": "3.14.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -9859,7 +9854,6 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10607,7 +10601,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -13914,7 +13907,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", - "dev": true, "license": "MIT", "dependencies": { "extend-shallow": "^2.0.1", @@ -14386,7 +14378,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/stable-hash": { @@ -14672,7 +14663,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index 1617811a12..7e047d1f52 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -6,6 +6,7 @@ import { Card, Button, Input, Modal, CardSkeleton, SegmentedControl } from "@/sh import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useDisplayBaseUrl } from "@/shared/hooks"; import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers"; +import { getProviderDisplayName } from "@/lib/display/names"; import { useTranslations } from "next-intl"; const BUILD_TIME_CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL || null; @@ -2482,7 +2483,7 @@ function EndpointSection({ const resolveProvider = (id) => AI_PROVIDERS[id] || getProviderByAlias(id); const providerColor = (id) => resolveProvider(id)?.color || "#888"; - const providerName = (id) => resolveProvider(id)?.name || id; + const providerName = (id) => getProviderDisplayName(id, resolveProvider(id)); const copyId = `endpoint_${path}`; return ( diff --git a/src/app/(dashboard)/dashboard/health/page.tsx b/src/app/(dashboard)/dashboard/health/page.tsx index da02692bc1..8d1265b390 100644 --- a/src/app/(dashboard)/dashboard/health/page.tsx +++ b/src/app/(dashboard)/dashboard/health/page.tsx @@ -15,6 +15,7 @@ import { useState, useEffect, useCallback } from "react"; import { Card } from "@/shared/components"; import { AI_PROVIDERS } from "@/shared/constants/providers"; +import { getProviderDisplayName } from "@/lib/display/names"; import { useTranslations } from "next-intl"; import TelemetryCard from "./TelemetryCard"; @@ -762,7 +763,7 @@ export default function HealthPage() { {unhealthy.map(([provider, cb]: [string, any]) => { const style = CB_STYLES[cb.state] || CB_STYLES.OPEN; const providerInfo = AI_PROVIDERS[provider]; - const displayName = providerInfo?.name || provider; + const displayName = getProviderDisplayName(provider, providerInfo); return (
{healthy.map(([provider]) => { const providerInfo = AI_PROVIDERS[provider]; - const displayName = providerInfo?.name || provider; + const displayName = getProviderDisplayName(provider, providerInfo); return (
12) displayName += ` (${customName.slice(0, 8)}…)`; else if (customName) displayName += ` (${customName})`; } else { - displayName = providerInfo?.name || providerId; + displayName = getProviderDisplayName(providerId, providerInfo); } return { providerId, displayName, providerInfo, connectionId, model }; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 5a6a96491c..142d9eb28e 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -1630,9 +1630,7 @@ export default function ProviderDetailPage() { : connection ) ); - notify.success( - enabled ? "Claude extra-usage blocking enabled" : "Claude extra-usage blocking disabled" - ); + notify.success(enabled ? "Claude extra-usage blocked" : "Claude extra-usage allowed"); } catch (error) { console.error("Error toggling Claude extra-usage policy:", error); notify.error("Failed to update Claude extra-usage policy"); @@ -5377,7 +5375,7 @@ function ConnectionRow({ )} diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index d1e7ab0b98..5d7c0956b4 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -21,6 +21,7 @@ import { isGitLabDirectAccessDisabled, resolveGitLabOAuthBaseUrl, } from "@/lib/oauth/gitlab"; +import { providerAllowsOptionalApiKey } from "@/shared/constants/providers"; // OAuth provider test endpoints const OAUTH_TEST_CONFIG = { @@ -522,7 +523,8 @@ async function testOAuthConnection(connection: any) { * Test API key connection */ async function testApiKeyConnection(connection: any) { - if (!connection.apiKey) { + const requiresApiKey = !providerAllowsOptionalApiKey(connection.provider); + if (requiresApiKey && !connection.apiKey) { const error = "Missing API key"; return { valid: false, diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 05ac37dde6..0b88cb6d56 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 948ecf47c1..5fe2fe07e5 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -4836,4 +4836,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index ebe95cadcb..1bc73cfd73 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -4690,4 +4690,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 5c510d65e7..e378df33b8 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index f5e7c2c66c..fd077d2788 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -4682,4 +4682,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 5a0666bf2f..439f6cf003 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 4819a48984..512b429248 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 001d49628d..93d1f02fc4 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -4951,4 +4951,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index da7cff7a34..5acf944cd4 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -4951,4 +4951,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 834dfe46e8..a178d85111 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -4951,4 +4951,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index 8776c4f00a..6f55813dad 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 8bc0fb18d9..c9ce447a70 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index f8d744939d..b81cd285b0 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index e2844cebaf..25ce74b47f 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -4951,4 +4951,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 221a85193e..526e9892c0 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index c5e63c264c..57a95eee6d 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -17,6 +17,7 @@ import { isAnthropicCompatibleProvider, isOpenAICompatibleProvider, isSelfHostedChatProvider, + providerAllowsOptionalApiKey, } from "@/shared/constants/providers"; import { SAFE_OUTBOUND_FETCH_PRESETS, @@ -2967,12 +2968,7 @@ async function validateMuseSparkWebProvider({ apiKey, providerSpecificData = {} } export async function validateProviderApiKey({ provider, apiKey, providerSpecificData = {} }: any) { - const requiresApiKey = - provider !== "searxng-search" && - provider !== "petals" && - !isSelfHostedChatProvider(provider) && - !isOpenAICompatibleProvider(provider) && - !isAnthropicCompatibleProvider(provider); + const requiresApiKey = !providerAllowsOptionalApiKey(provider); if (!provider || (requiresApiKey && !apiKey)) { return { valid: false, error: "Provider and API key required", unsupported: false }; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 0a8b74a79b..aed3227f26 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -793,8 +793,9 @@ export const APIKEY_PROVIDERS = { color: "#4CAF50", textIcon: "PO", website: "https://pollinations.ai", - hasFree: false, - freeNote: "API key required. Spore tier: ~0.01 pollen/hour ($0.01/hr).", + hasFree: true, + freeNote: + "No API key required for free public endpoint. Optional Spore tier: ~0.01 pollen/hour.", }, puter: { id: "puter", @@ -1913,6 +1914,16 @@ export function isSelfHostedChatProvider(providerId: unknown): boolean { return typeof providerId === "string" && SELF_HOSTED_CHAT_PROVIDER_IDS.has(providerId); } +export function providerAllowsOptionalApiKey(providerId: unknown): boolean { + return ( + providerId === "searxng-search" || + providerId === "petals" || + isSelfHostedChatProvider(providerId) || + isOpenAICompatibleProvider(providerId) || + isAnthropicCompatibleProvider(providerId) + ); +} + // ── System Providers (virtual, not user-connectable) ────────────────────────── export const SYSTEM_PROVIDERS = { auto: { diff --git a/tests/unit/proxy-connection-test.test.ts b/tests/unit/proxy-connection-test.test.ts index 3d07ad1c99..358c715847 100644 --- a/tests/unit/proxy-connection-test.test.ts +++ b/tests/unit/proxy-connection-test.test.ts @@ -1,5 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { providerAllowsOptionalApiKey, SELF_HOSTED_CHAT_PROVIDER_IDS } from "@/shared/constants/providers"; // ── Import test targets from connection test route ────────────────────────── @@ -322,6 +323,46 @@ test("OAuth test config covers all expected providers", () => { } }); +// ── testApiKeyConnection requiresApiKey Check ────────────────────────────── +// Uses the centralized providerAllowsOptionalApiKey from providers.ts + +test("testApiKeyConnection: searxng-search with empty API key does NOT require API key", () => { + assert.equal(providerAllowsOptionalApiKey("searxng-search"), true); +}); + +test("testApiKeyConnection: petals with empty API key does NOT require API key", () => { + assert.equal(providerAllowsOptionalApiKey("petals"), true); +}); + +test("testApiKeyConnection: self-hosted chat providers with empty API key do NOT require API key", () => { + for (const provider of SELF_HOSTED_CHAT_PROVIDER_IDS) { + assert.equal( + providerAllowsOptionalApiKey(provider), + true, + `Expected ${provider} to not require API key` + ); + } +}); + +test("testApiKeyConnection: openai-compatible providers with empty API key do NOT require API key", () => { + assert.equal(providerAllowsOptionalApiKey("openai-compatible-chat-test"), true); +}); + +test("testApiKeyConnection: anthropic-compatible providers with empty API key do NOT require API key", () => { + assert.equal(providerAllowsOptionalApiKey("anthropic-compatible-chat-test"), true); +}); + +test("testApiKeyConnection: providers requiring an API key are correctly identified", () => { + const providersThatRequireKeys = ["openai", "groq", "gemini", "unknown-provider"]; + for (const provider of providersThatRequireKeys) { + assert.equal( + providerAllowsOptionalApiKey(provider), + false, + `Expected ${provider} to require an API key` + ); + } +}); + test("Refreshable OAuth providers are correctly identified", () => { const refreshable = [ "codex",