mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
Merge branch 'release/v3.8.0' into upstream/registry-per-model-specs-refresh
This commit is contained in:
@@ -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`.
|
||||
|
||||
7
.github/workflows/claude-code-review.yml
vendored
7
.github/workflows/claude-code-review.yml
vendored
@@ -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
|
||||
|
||||
|
||||
1
.github/workflows/claude.yml
vendored
1
.github/workflows/claude.yml
vendored
@@ -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 *)'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
12
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4658,4 +4658,4 @@
|
||||
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
|
||||
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4836,4 +4836,4 @@
|
||||
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
|
||||
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4690,4 +4690,4 @@
|
||||
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
|
||||
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4658,4 +4658,4 @@
|
||||
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
|
||||
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4682,4 +4682,4 @@
|
||||
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
|
||||
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4658,4 +4658,4 @@
|
||||
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
|
||||
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4658,4 +4658,4 @@
|
||||
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
|
||||
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4951,4 +4951,4 @@
|
||||
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
|
||||
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4951,4 +4951,4 @@
|
||||
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
|
||||
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4951,4 +4951,4 @@
|
||||
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
|
||||
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4658,4 +4658,4 @@
|
||||
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
|
||||
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4658,4 +4658,4 @@
|
||||
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
|
||||
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4658,4 +4658,4 @@
|
||||
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
|
||||
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4951,4 +4951,4 @@
|
||||
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
|
||||
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4658,4 +4658,4 @@
|
||||
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
|
||||
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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: {
|
||||
|
||||
132
tests/unit/base-executor-sanitize-effort.test.ts
Normal file
132
tests/unit/base-executor-sanitize-effort.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
128
tests/unit/translator-claude-helper-thinking.test.ts
Normal file
128
tests/unit/translator-claude-helper-thinking.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user