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