From 0594b1d2ea6e2369aa0e6c02f5aff4b6e6d6e345 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 11 May 2026 15:51:05 -0300 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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); +}); From 25199b20fe1c1f1cf104d98d6fb825ca8ab04c14 Mon Sep 17 00:00:00 2001 From: Anton <39598727+NomenAK@users.noreply.github.com> Date: Tue, 12 May 2026 02:02:28 +0200 Subject: [PATCH 5/6] fix(executors): sanitize reasoning_effort for non-supporting providers (#2162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.0 — adds sanitizeReasoningEffortForProvider hook to BaseExecutor, fixing xhigh→high downgrade for non-supporting providers and full strip for mistral/devstral and GitHub Claude models. --- open-sse/executors/base.ts | 85 ++++++++++- .../base-executor-sanitize-effort.test.ts | 132 ++++++++++++++++++ 2 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 tests/unit/base-executor-sanitize-effort.test.ts diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 832810f841..0fa829c7bb 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -1,5 +1,6 @@ import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts"; +import { supportsXHighEffort } from "../config/providerModels.ts"; import { getRotatingApiKey } from "../services/apiKeyRotator.ts"; import { getOpenAICompatibleType, isClaudeCodeCompatible } from "../services/provider.ts"; import type { ProviderRequestDefaults } from "../services/providerRequestDefaults.ts"; @@ -171,6 +172,77 @@ export function mergeAbortSignals(primary: AbortSignal, secondary: AbortSignal): return controller.signal; } +/** + * Sanitize reasoning_effort for providers that don't accept all values. + * + * The claude→openai translator emits reasoning_effort=xhigh when the client + * sends output_config.effort=max on a Claude-shape request. Combined with + * runtime alias remapping (e.g. claude-opus-4-6 → mimo/mimo-v2.5-pro), this + * routes xhigh to OpenAI-shape providers that don't accept the value: + * + * xiaomi-mimo : low|medium|high only — 400 literal_error on xhigh + * mistral : devstral models reject reasoning_effort entirely + * github : claude/haiku/oswe models reject reasoning_effort entirely + * + * Each rejection burns a combo fallback attempt before reaching a working + * provider. Apply provider-aware sanitation here (after transformRequest, so + * reintroductions by per-provider transforms are also caught) before fetch. + * Models that genuinely support xhigh (registry flag supportsXHighEffort) + * pass through unchanged. + */ +const MISTRAL_NO_REASONING_EFFORT_PATTERN = /devstral/i; +const GITHUB_NO_REASONING_EFFORT_PATTERN = /(claude|haiku|oswe)/i; +export function sanitizeReasoningEffortForProvider( + body: unknown, + provider: string, + model: string | undefined, + log?: { info?: (tag: string, msg: string) => void } | null +): unknown { + if (!body || typeof body !== "object" || Array.isArray(body)) return body; + const b = body as Record; + const reasoning = + b.reasoning && typeof b.reasoning === "object" && !Array.isArray(b.reasoning) + ? (b.reasoning as Record) + : null; + const effort = b.reasoning_effort ?? reasoning?.effort; + if (effort === undefined) return body; + const effortStr = typeof effort === "string" ? effort.toLowerCase() : ""; + const modelStr = model || ""; + + if (effortStr === "xhigh" && !supportsXHighEffort(provider, modelStr)) { + log?.info?.( + "REASONING_SANITIZE", + `${provider}/${modelStr}: downgraded reasoning_effort xhigh → high` + ); + const next: Record = { ...b, reasoning_effort: "high" }; + if (reasoning) { + next.reasoning = { ...reasoning, effort: "high" }; + } + return next; + } + + const rejecting = + (provider === "mistral" && MISTRAL_NO_REASONING_EFFORT_PATTERN.test(modelStr)) || + (provider === "github" && GITHUB_NO_REASONING_EFFORT_PATTERN.test(modelStr)); + if (rejecting) { + log?.info?.( + "REASONING_SANITIZE", + `${provider}/${modelStr}: removed unsupported reasoning_effort` + ); + const next: Record = { ...b }; + delete next.reasoning_effort; + if (reasoning) { + const r = { ...reasoning }; + delete r.effort; + if (Object.keys(r).length === 0) delete next.reasoning; + else next.reasoning = r; + } + return next; + } + + return body; +} + /** * BaseExecutor - Base class for provider executors. * Implements the Strategy pattern: subclasses override specific methods @@ -484,7 +556,18 @@ export class BaseExecutor { appendAnthropicBetaHeader(headers, CONTEXT_1M_BETA_HEADER); } - const transformedBody = await this.transformRequest(model, body, stream, activeCredentials); + const rawTransformedBody = await this.transformRequest( + model, + body, + stream, + activeCredentials + ); + const transformedBody = sanitizeReasoningEffortForProvider( + rawTransformedBody, + this.provider, + model, + log + ); try { // Only enforce the timeout while waiting for the initial fetch() response. diff --git a/tests/unit/base-executor-sanitize-effort.test.ts b/tests/unit/base-executor-sanitize-effort.test.ts new file mode 100644 index 0000000000..af9b2f3b08 --- /dev/null +++ b/tests/unit/base-executor-sanitize-effort.test.ts @@ -0,0 +1,132 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { sanitizeReasoningEffortForProvider } = await import( + "../../open-sse/executors/base.ts" +); + +function makeLog() { + const messages: Array<[string, string]> = []; + return { + info: (tag: string, msg: string) => messages.push([tag, msg]), + messages, + }; +} + +test("sanitizeReasoningEffortForProvider: xiaomi-mimo downgrades xhigh → high", () => { + const log = makeLog(); + const body = { + model: "mimo-v2.5-pro", + reasoning_effort: "xhigh", + messages: [{ role: "user", content: "hi" }], + }; + const result = sanitizeReasoningEffortForProvider( + body, + "xiaomi-mimo", + "mimo-v2.5-pro", + log + ); + assert.notEqual(result, body, "must return a new object when mutating"); + assert.equal((result as any).reasoning_effort, "high"); + assert.equal((result as any).model, "mimo-v2.5-pro", "other fields preserved"); + assert.ok( + log.messages.some(([tag, m]) => tag === "REASONING_SANITIZE" && /xhigh → high/.test(m)), + "logs the downgrade" + ); +}); + +test("sanitizeReasoningEffortForProvider: xiaomi-mimo downgrades xhigh in nested reasoning.effort", () => { + const body = { + model: "mimo-v2.5-pro", + reasoning: { effort: "xhigh", summary: "auto" }, + messages: [], + }; + const result = sanitizeReasoningEffortForProvider(body, "xiaomi-mimo", "mimo-v2.5-pro", null); + assert.equal((result as any).reasoning.effort, "high"); + assert.equal((result as any).reasoning.summary, "auto", "other reasoning fields preserved"); +}); + +test("sanitizeReasoningEffortForProvider: mistral/devstral strips reasoning_effort entirely", () => { + const log = makeLog(); + const body = { + model: "devstral-2512", + reasoning_effort: "medium", + messages: [], + }; + const result = sanitizeReasoningEffortForProvider(body, "mistral", "devstral-2512", log); + assert.equal((result as any).reasoning_effort, undefined, "reasoning_effort must be stripped"); + assert.ok( + log.messages.some(([tag, m]) => tag === "REASONING_SANITIZE" && /removed/.test(m)), + "logs the removal" + ); +}); + +test("sanitizeReasoningEffortForProvider: github/claude-opus strips reasoning_effort entirely", () => { + const body = { + model: "claude-opus-4-6", + reasoning_effort: "high", + messages: [], + }; + const result = sanitizeReasoningEffortForProvider(body, "github", "claude-opus-4-6", null); + assert.equal((result as any).reasoning_effort, undefined); +}); + +test("sanitizeReasoningEffortForProvider: mistral/devstral strips reasoning object when only effort present", () => { + const body = { + model: "devstral-2512", + reasoning: { effort: "medium" }, + messages: [], + }; + const result = sanitizeReasoningEffortForProvider(body, "mistral", "devstral-2512", null); + assert.equal((result as any).reasoning, undefined, "reasoning object dropped when emptied"); +}); + +test("sanitizeReasoningEffortForProvider: mistral/devstral preserves reasoning when other fields remain", () => { + const body = { + model: "devstral-2512", + reasoning: { effort: "medium", summary: "auto" }, + messages: [], + }; + const result = sanitizeReasoningEffortForProvider(body, "mistral", "devstral-2512", null); + assert.deepEqual((result as any).reasoning, { summary: "auto" }); +}); + +test("sanitizeReasoningEffortForProvider: codex with xhigh passes through unchanged when model supports it", () => { + // codex/gpt-5.5-xhigh and related Claude opus models are flagged + // supportsXHighEffort:true in providerRegistry. They must not be downgraded. + const body = { + model: "gpt-5.5-xhigh", + reasoning_effort: "xhigh", + messages: [], + }; + const result = sanitizeReasoningEffortForProvider(body, "codex", "gpt-5.5-xhigh", null); + // Either passes through unchanged (supportsXHighEffort=true) + // or the registry doesn't flag it — in which case downgrade is acceptable. + // We assert no error and that some reasoning_effort is present. + assert.ok( + (result as any).reasoning_effort === "xhigh" || (result as any).reasoning_effort === "high", + "either preserved (xhigh) or downgraded (high)" + ); +}); + +test("sanitizeReasoningEffortForProvider: no-op when reasoning_effort absent", () => { + const body = { model: "mimo-v2.5-pro", messages: [] }; + const result = sanitizeReasoningEffortForProvider(body, "xiaomi-mimo", "mimo-v2.5-pro", null); + assert.equal(result, body, "returns original body unchanged"); +}); + +test("sanitizeReasoningEffortForProvider: handles unknown providers as pass-through", () => { + const body = { model: "some-model", reasoning_effort: "xhigh", messages: [] }; + const result = sanitizeReasoningEffortForProvider(body, "unknown-provider", "some-model", null); + // unknown provider + xhigh + model not in registry → supportsXHighEffort returns false → downgrade + // OR unknown provider isn't in the strip list → returns xhigh + // Both are acceptable behavior; we just assert no exception thrown. + assert.ok(result !== undefined); +}); + +test("sanitizeReasoningEffortForProvider: non-object body returns unchanged", () => { + assert.equal(sanitizeReasoningEffortForProvider(null, "xiaomi-mimo", "x", null), null); + assert.equal(sanitizeReasoningEffortForProvider("string", "xiaomi-mimo", "x", null), "string"); + const arr: unknown[] = []; + assert.equal(sanitizeReasoningEffortForProvider(arr, "xiaomi-mimo", "x", null), arr); +}); From e5974c4ae34d757626979404c8b179efbe4848c5 Mon Sep 17 00:00:00 2001 From: Anton <39598727+NomenAK@users.noreply.github.com> Date: Tue, 12 May 2026 02:03:26 +0200 Subject: [PATCH 6/6] feat(responses): degrade background mode to synchronous execution (#2164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.0 — degrades background:true to synchronous execution instead of 400, enabling Capy and similar clients that set background:true by default to work seamlessly. --- .../translator/request/openai-responses.ts | 25 ++++-- .../translator-openai-responses-req.test.ts | 85 ++++++++++++++++--- 2 files changed, 91 insertions(+), 19 deletions(-) diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 45beee74b0..6d72f1f5f0 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -77,13 +77,28 @@ export function openaiResponsesToOpenAIRequest( } } - if (root.background) { - throw unsupportedFeature( - "Unsupported Responses API feature: background mode is not supported by omniroute" + const result: JsonRecord = { ...root }; + + // background: true requests a deferred Responses API run (the upstream + // returns 202 with response_id and the client polls GET /responses/). + // OmniRoute is a forward proxy that streams responses synchronously — + // implementing the queue/poll contract would require persistence and a + // separate retrieval surface. Degrade: log a marker when true was + // actually requested (operators can observe clients that should be + // reconfigured) and strip the flag. Clients that set background=true + // opportunistically (Capy Captain Pro, Codex agents) work unchanged. + // Clients that strictly require the async contract still observe a + // completed response on the first poll and can adapt. + if (result.background === true) { + const providerStr = toString(credentialRecord.provider); + const modelStr = toString(model); + console.warn( + `BACKGROUND_DEGRADE provider=${providerStr || "unknown"} model=${modelStr || "unknown"}` ); } - - const result: JsonRecord = { ...root }; + if (result.background !== undefined) { + delete result.background; + } const messages: JsonRecord[] = []; result.messages = messages; diff --git a/tests/unit/translator-openai-responses-req.test.ts b/tests/unit/translator-openai-responses-req.test.ts index e66c4c7469..55b8791713 100644 --- a/tests/unit/translator-openai-responses-req.test.ts +++ b/tests/unit/translator-openai-responses-req.test.ts @@ -110,7 +110,7 @@ test("Responses -> Chat filters orphan tool outputs and supports role-based mess }); }); -test("Responses -> Chat rejects unsupported built-in tools and background mode", () => { +test("Responses -> Chat rejects unsupported built-in tools", () => { assert.throws( () => openaiResponsesToOpenAIRequest( @@ -124,20 +124,77 @@ test("Responses -> Chat rejects unsupported built-in tools and background mode", ), (error: any) => error.statusCode === 400 && error.errorType === "unsupported_feature" ); +}); - assert.throws( - () => - openaiResponsesToOpenAIRequest( - "gpt-4o", - { - input: [], - background: true, - }, - false, - null - ), - (error: any) => error.statusCode === 400 && error.errorType === "unsupported_feature" - ); +test("Responses -> Chat strips background flag and degrades to synchronous execution", () => { + // Previously this threw 400 unsupported_feature. OmniRoute is a forward proxy + // and cannot host the deferred run + poll contract, so background=true is + // silently dropped and the request runs synchronously. Clients that set the + // flag opportunistically (Capy Captain Pro, Codex agents) work unchanged. + const warnLog: string[] = []; + const originalWarn = console.warn; + console.warn = (msg: unknown) => warnLog.push(String(msg)); + try { + const result = openaiResponsesToOpenAIRequest( + "gpt-5.5", + { + input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }], + background: true, + }, + true, + { provider: "codex" } + ); + const r = result as Record; + assert.equal(r.background, undefined, "background flag must be stripped from output"); + assert.ok(Array.isArray(r.messages), "translation must complete and produce messages"); + assert.equal((r.messages as unknown[]).length, 1, "user message must be preserved"); + assert.ok( + warnLog.some((m) => m.startsWith("BACKGROUND_DEGRADE provider=codex model=gpt-5.5")), + "BACKGROUND_DEGRADE warning log must be emitted when background=true" + ); + } finally { + console.warn = originalWarn; + } +}); + +test("Responses -> Chat passes through when background flag is unset or false (no degrade log)", () => { + // Verifies the inverse of the degradation case: when background is absent or + // explicitly false, no warning should be emitted and the request body should + // not carry a residual background field. Guards against accidental log spam + // and confirms the degradation logic is conditional on background === true. + const warnLog: string[] = []; + const originalWarn = console.warn; + console.warn = (msg: unknown) => warnLog.push(String(msg)); + try { + // Case 1: background unset + const r1 = openaiResponsesToOpenAIRequest( + "gpt-5.5", + { input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }] }, + true, + { provider: "codex" } + ) as Record; + assert.equal(r1.background, undefined, "background must be absent on output (unset case)"); + + // Case 2: background explicitly false + const r2 = openaiResponsesToOpenAIRequest( + "gpt-5.5", + { + input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }], + background: false, + }, + true, + { provider: "codex" } + ) as Record; + assert.equal(r2.background, undefined, "background must be stripped on output (false case)"); + + assert.equal( + warnLog.filter((m) => m.startsWith("BACKGROUND_DEGRADE")).length, + 0, + "BACKGROUND_DEGRADE must NOT be emitted for unset or false values" + ); + } finally { + console.warn = originalWarn; + } }); test("Chat -> Responses converts messages, tool calls, tool outputs, tools and pass-through params", () => {