Merge branch 'release/v3.8.0' into upstream/executors-sanitize-reasoning-effort

This commit is contained in:
diegosouzapw
2026-05-11 21:01:41 -03:00
33 changed files with 305 additions and 116 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

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

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

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