Merge branch 'release/v3.8.0' into upstream/registry-per-model-specs-refresh

This commit is contained in:
diegosouzapw
2026-05-11 21:03:39 -03:00
37 changed files with 612 additions and 136 deletions

View File

@@ -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 <email>"` 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`.

View File

@@ -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

View File

@@ -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 *)'

View File

@@ -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<number>
// 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 {
<input
type="checkbox"
checked={selectedIds.size === connections.length && connections.length > 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"
/>
<span className="text-sm font-medium text-text-muted">
{selectedIds.size > 0
? `${selectedIds.size} selected`
: `${connections.length} accounts`}
{selectedIds.size > 0 ? `${selectedIds.size} selected` : `${connections.length} accounts`}
</span>
</label>
@@ -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

View File

@@ -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

View File

@@ -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<string, unknown>;
const reasoning =
b.reasoning && typeof b.reasoning === "object" && !Array.isArray(b.reasoning)
? (b.reasoning as Record<string, unknown>)
: 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<string, unknown> = { ...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<string, unknown> = { ...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.

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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,
});
}
}
}

View File

@@ -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/<id>).
// 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;

View File

@@ -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);

12
package-lock.json generated
View File

@@ -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"

View File

@@ -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 (

View File

@@ -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 (
<div
key={provider}
@@ -820,7 +821,7 @@ export default function HealthPage() {
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
{healthy.map(([provider]) => {
const providerInfo = AI_PROVIDERS[provider];
const displayName = providerInfo?.name || provider;
const displayName = getProviderDisplayName(provider, providerInfo);
return (
<div
key={provider}
@@ -873,7 +874,7 @@ export default function HealthPage() {
if (customName.length > 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 };

View File

@@ -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({
<button
onClick={() => onToggleClaudeExtraUsage?.(!claudeBlockExtraUsageEnabled)}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
claudeBlockExtraUsageEnabled
!claudeBlockExtraUsageEnabled
? "bg-amber-500/15 text-amber-500 hover:bg-amber-500/25"
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
}`}
@@ -5385,7 +5383,7 @@ function ConnectionRow({
>
<span className="material-symbols-outlined text-[13px]">payments</span>
{t("claudeExtraUsageShort")}{" "}
{claudeBlockExtraUsageEnabled ? t("toggleOnShort") : t("toggleOffShort")}
{!claudeBlockExtraUsageEnabled ? t("toggleOnShort") : t("toggleOffShort")}
</button>
</>
)}

View File

@@ -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,

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4836,4 +4836,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4690,4 +4690,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4682,4 +4682,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4951,4 +4951,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4951,4 +4951,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4951,4 +4951,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4951,4 +4951,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -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 };

View File

@@ -793,8 +793,9 @@ export const APIKEY_PROVIDERS = {
color: "#4CAF50",
textIcon: "PO",
website: "https://pollinations.ai",
hasFree: false,
freeNote: "API key required. Spore tier: ~0.01 pollen/hour ($0.01/hr).",
hasFree: true,
freeNote:
"No API key required for free public endpoint. Optional Spore tier: ~0.01 pollen/hour.",
},
puter: {
id: "puter",
@@ -1913,6 +1914,16 @@ export function isSelfHostedChatProvider(providerId: unknown): boolean {
return typeof providerId === "string" && SELF_HOSTED_CHAT_PROVIDER_IDS.has(providerId);
}
export function providerAllowsOptionalApiKey(providerId: unknown): boolean {
return (
providerId === "searxng-search" ||
providerId === "petals" ||
isSelfHostedChatProvider(providerId) ||
isOpenAICompatibleProvider(providerId) ||
isAnthropicCompatibleProvider(providerId)
);
}
// ── System Providers (virtual, not user-connectable) ──────────────────────────
export const SYSTEM_PROVIDERS = {
auto: {

View File

@@ -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);
});

View File

@@ -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",

View File

@@ -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);
});

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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", () => {