From 0594b1d2ea6e2369aa0e6c02f5aff4b6e6d6e345 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 11 May 2026 15:51:05 -0300 Subject: [PATCH 1/4] fix(providers): correct pollinations requests and provider dashboard state Update Pollinations request transformation to send the selected model and stream flag so requests match the active endpoint behavior. Align the ChatGPT TLS client with shared proxy resolution so dashboard proxy context is honored before falling back to environment settings. Also refresh provider display names across dashboard pages, correct the Claude extra-usage toggle messaging and visual state, and mark Pollinations as offering a free public endpoint. --- .npmignore | 4 +-- open-sse/executors/pollinations.ts | 4 ++- open-sse/services/chatgptTlsClient.ts | 25 ++++++++++--------- open-sse/utils/proxyFetch.ts | 2 +- package-lock.json | 12 +-------- .../dashboard/endpoint/EndpointPageClient.tsx | 3 ++- src/app/(dashboard)/dashboard/health/page.tsx | 7 +++--- .../dashboard/providers/[id]/page.tsx | 8 +++--- src/shared/constants/providers.ts | 5 ++-- 9 files changed, 32 insertions(+), 38 deletions(-) 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/shared/constants/providers.ts b/src/shared/constants/providers.ts index 0a8b74a79b..f30f276ef0 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", From c52b365e1d3139d6223225edf653c76888d9f209 Mon Sep 17 00:00:00 2001 From: Hernan Javier Ardila Sanchez Date: Tue, 12 May 2026 01:55:20 +0200 Subject: [PATCH 2/4] refactor(catalog): remove .ts imports, as any casts, normalize alias resolution (#2152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.0 — removes .ts import extensions, replaces as any casts with proper types, and normalizes provider alias resolution in combo context_length calculation. --- .agents/workflows/review-prs.md | 2 +- .github/workflows/claude-code-review.yml | 7 ++- .github/workflows/claude.yml | 1 - .../feat-batch-delete-provider-accounts.md | 54 ++++++++++--------- src/i18n/messages/pl.json | 2 +- src/i18n/messages/pt-BR.json | 2 +- src/i18n/messages/pt.json | 2 +- src/i18n/messages/ro.json | 2 +- src/i18n/messages/ru.json | 2 +- src/i18n/messages/sk.json | 2 +- src/i18n/messages/sv.json | 2 +- src/i18n/messages/sw.json | 2 +- src/i18n/messages/ta.json | 2 +- src/i18n/messages/te.json | 2 +- src/i18n/messages/th.json | 2 +- src/i18n/messages/tr.json | 2 +- src/i18n/messages/uk-UA.json | 2 +- src/i18n/messages/ur.json | 2 +- src/i18n/messages/vi.json | 2 +- 19 files changed, 48 insertions(+), 46 deletions(-) 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/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 +} From 6d722ecd279e7efd0c5ca073544faa72fff4f269 Mon Sep 17 00:00:00 2001 From: Andrew Munsell Date: Mon, 11 May 2026 16:57:01 -0700 Subject: [PATCH 3/4] fix(providers): allow optional-key providers to pass connection test (#2169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.0 — allows optional-key providers (SearXNG, Petals, self-hosted chat, OpenAI/Anthropic-compatible) to pass connection test by centralizing the check in providerAllowsOptionalApiKey(). --- src/app/api/providers/[id]/test/route.ts | 4 ++- src/lib/providers/validation.ts | 8 ++--- src/shared/constants/providers.ts | 10 ++++++ tests/unit/proxy-connection-test.test.ts | 41 ++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 7 deletions(-) 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/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 f30f276ef0..aed3227f26 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -1914,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", From d995713e21146db40f994bea5988147b7bde3839 Mon Sep 17 00:00:00 2001 From: Anton <39598727+NomenAK@users.noreply.github.com> Date: Tue, 12 May 2026 02:01:23 +0200 Subject: [PATCH 4/4] fix(translator): inject thinking placeholder for all Claude-shape upstreams (#2161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.0 — removes redundant provider guard in prepareClaudeRequest, fixing thinking placeholder injection for all Claude-shape upstreams (kimi-coding, glmt, zai). --- open-sse/translator/helpers/claudeHelper.ts | 66 +++++---- .../translator-claude-helper-thinking.test.ts | 128 ++++++++++++++++++ 2 files changed, 169 insertions(+), 25 deletions(-) create mode 100644 tests/unit/translator-claude-helper-thinking.test.ts diff --git a/open-sse/translator/helpers/claudeHelper.ts b/open-sse/translator/helpers/claudeHelper.ts index d42c86dc08..49f4f0d5c6 100644 --- a/open-sse/translator/helpers/claudeHelper.ts +++ b/open-sse/translator/helpers/claudeHelper.ts @@ -250,34 +250,50 @@ export function prepareClaudeRequest( lastAssistantProcessed = true; } - // Handle thinking blocks for Anthropic endpoints (native + compatible) - if (provider === "claude" || provider?.startsWith?.("anthropic-compatible-")) { - let hasToolUse = false; - let hasThinking = false; + // Handle thinking blocks for Anthropic-shape endpoints. + // prepareClaudeRequest is only invoked when targetFormat === claude + // (translator/index.ts:165-168), so any provider that lands here has + // a Claude-format upstream: claude native, anthropic-compatible-*, + // kimi-coding (api.kimi.com/coding/v1/messages), glmt, zai, etc. + // All of these enforce the same body-shape contract for thinking mode: + // when body.thinking.type === "enabled" and an assistant turn contains + // a tool_use, the same content[] must include a thinking (or + // redacted_thinking) block emitted before the tool_use. Without it, + // the upstream rejects with errors like: + // "thinking is enabled but reasoning_content is missing in + // assistant tool call message at index N" (kimi-coding) + // "Invalid signature in thinking block" (claude native, on + // cross-provider replay) + let hasToolUse = false; + let hasThinking = false; - // Convert thinking blocks to redacted_thinking and replace signature. - // When requests cross provider boundaries (e.g., combo fallback), the - // original thinking signature is invalid for the new provider, causing - // "Invalid signature in thinking block" 400 errors. redacted_thinking - // blocks are accepted without signature validation. - for (const block of content) { - if (block.type === "thinking" || block.type === "redacted_thinking") { - block.type = "redacted_thinking"; - block.signature = DEFAULT_THINKING_CLAUDE_SIGNATURE; - delete block.thinking; - hasThinking = true; - } - if (block.type === "tool_use") hasToolUse = true; + // Convert thinking blocks to redacted_thinking and replace signature. + // When requests cross provider boundaries (e.g., combo fallback), the + // original thinking signature is invalid for the new provider, causing + // "Invalid signature in thinking block" 400 errors. redacted_thinking + // blocks are accepted without signature validation. + for (const block of content) { + if (block.type === "thinking" || block.type === "redacted_thinking") { + block.type = "redacted_thinking"; + block.signature = DEFAULT_THINKING_CLAUDE_SIGNATURE; + delete block.thinking; + hasThinking = true; } + if (block.type === "tool_use") hasToolUse = true; + } - // Add thinking block if thinking enabled + has tool_use but no thinking - if (thinkingEnabled && !hasThinking && hasToolUse) { - content.unshift({ - type: "thinking", - thinking: ".", - signature: DEFAULT_THINKING_CLAUDE_SIGNATURE, - }); - } + // Add thinking block if thinking enabled + has tool_use but no thinking. + // Required for Anthropic-shape thinking-mode upstreams (claude, kimi, + // glm) when the assistant turn's content[] needs a precursor thinking + // block in front of any tool_use. Placeholder content (".") is enough + // to satisfy shape validation; the upstream still runs its own + // reasoning on the current turn. + if (thinkingEnabled && !hasThinking && hasToolUse) { + content.unshift({ + type: "thinking", + thinking: ".", + signature: DEFAULT_THINKING_CLAUDE_SIGNATURE, + }); } } } diff --git a/tests/unit/translator-claude-helper-thinking.test.ts b/tests/unit/translator-claude-helper-thinking.test.ts new file mode 100644 index 0000000000..ca153a44d1 --- /dev/null +++ b/tests/unit/translator-claude-helper-thinking.test.ts @@ -0,0 +1,128 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { prepareClaudeRequest } = await import( + "../../open-sse/translator/helpers/claudeHelper.ts" +); +const { DEFAULT_THINKING_CLAUDE_SIGNATURE } = await import( + "../../open-sse/config/defaultThinkingSignature.ts" +); + +function multiTurnBodyWithoutThinkingBlock() { + return { + thinking: { type: "enabled", budget_tokens: 4096 }, + messages: [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { + role: "assistant", + content: [ + { type: "tool_use", id: "call_x", name: "ls", input: { path: "." } }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_x", + content: "README.md\npackage.json", + }, + ], + }, + ], + }; +} + +test("prepareClaudeRequest: claude provider — injects thinking before tool_use (regression)", () => { + const body = multiTurnBodyWithoutThinkingBlock(); + const result = prepareClaudeRequest(body as any, "claude"); + const assistantContent = (result as any).messages[1].content; + assert.equal(assistantContent.length, 2, "thinking + tool_use"); + assert.equal(assistantContent[0].type, "thinking"); + assert.equal(assistantContent[0].thinking, "."); + assert.equal(assistantContent[0].signature, DEFAULT_THINKING_CLAUDE_SIGNATURE); + assert.equal(assistantContent[1].type, "tool_use"); +}); + +test("prepareClaudeRequest: kimi-coding provider — injects thinking before tool_use (new behavior)", () => { + // Previously: kimi-coding was excluded by the provider gate and the assistant + // turn shipped to api.kimi.com/coding/v1/messages without a thinking + // precursor, triggering 400 "thinking is enabled but reasoning_content is + // missing in assistant tool call message at index N". + const body = multiTurnBodyWithoutThinkingBlock(); + const result = prepareClaudeRequest(body as any, "kimi-coding"); + const assistantContent = (result as any).messages[1].content; + assert.equal(assistantContent.length, 2, "thinking + tool_use"); + assert.equal(assistantContent[0].type, "thinking"); + assert.equal(assistantContent[0].thinking, "."); + assert.equal(assistantContent[0].signature, DEFAULT_THINKING_CLAUDE_SIGNATURE); +}); + +test("prepareClaudeRequest: existing thinking block — redacted, signature replaced, no double-inject", () => { + const body = { + thinking: { type: "enabled", budget_tokens: 4096 }, + messages: [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "reasoning here", signature: "old-sig" }, + { type: "tool_use", id: "call_y", name: "ls", input: {} }, + ], + }, + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "call_y", content: "ok" }, + ], + }, + ], + }; + const result = prepareClaudeRequest(body as any, "kimi-coding"); + const assistantContent = (result as any).messages[1].content; + assert.equal(assistantContent.length, 2, "no double-inject — exactly 2 blocks"); + assert.equal(assistantContent[0].type, "redacted_thinking", "thinking → redacted_thinking"); + assert.equal(assistantContent[0].signature, DEFAULT_THINKING_CLAUDE_SIGNATURE); + assert.equal(assistantContent[0].thinking, undefined, "thinking field stripped"); + assert.equal(assistantContent[1].type, "tool_use"); +}); + +test("prepareClaudeRequest: thinking disabled — no inject regardless of tool_use presence", () => { + const body = { + thinking: { type: "disabled" }, + messages: [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { + role: "assistant", + content: [ + { type: "tool_use", id: "call_z", name: "ls", input: {} }, + ], + }, + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "call_z", content: "ok" }, + ], + }, + ], + }; + const result = prepareClaudeRequest(body as any, "kimi-coding"); + const assistantContent = (result as any).messages[1].content; + assert.equal(assistantContent.length, 1, "no thinking injected when thinking is disabled"); + assert.equal(assistantContent[0].type, "tool_use"); +}); + +test("prepareClaudeRequest: thinking enabled + no tool_use — no inject (single-turn text)", () => { + const body = { + thinking: { type: "enabled", budget_tokens: 4096 }, + messages: [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + ], + }; + const result = prepareClaudeRequest(body as any, "kimi-coding"); + const userContent = (result as any).messages[0].content; + assert.ok(Array.isArray(userContent)); + assert.equal(userContent[0].type, "text"); + // No new thinking block prepended on user messages + assert.equal(userContent.length, 1); +});